@saltcorn/copilot 0.7.2 → 0.7.4

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.
@@ -5,7 +5,7 @@ const Table = require("@saltcorn/data/models/table");
5
5
  const Field = require("@saltcorn/data/models/field");
6
6
  const { apply, removeAllWhiteSpace } = require("@saltcorn/data/utils");
7
7
  const { getActionConfigFields } = require("@saltcorn/data/plugin-helper");
8
- const { a, pre, script, div } = require("@saltcorn/markup/tags");
8
+ const { a, pre, script, div, domReady } = require("@saltcorn/markup/tags");
9
9
  const { fieldProperties } = require("../common");
10
10
 
11
11
  class GenerateTables {
@@ -143,6 +143,10 @@ class GenerateTables {
143
143
  need to specify a primary key, a primary key called id with autoincrementing integers is
144
144
  autmatically generated.
145
145
 
146
+ Only include brand-new tables in the generate_tables arguments. If the user references a table
147
+ that already exists, explain that generate_tables can only add new tables (not modify existing
148
+ ones) and omit those tables from the tool call.
149
+
146
150
  The database already contains the following tables:
147
151
 
148
152
  ${tableLines.join("\n\n")}
@@ -154,7 +158,14 @@ class GenerateTables {
154
158
  const mmdia = buildMermaidMarkup(sctables);
155
159
  return (
156
160
  pre({ class: "mermaid" }, mmdia) +
157
- script(`mermaid.run({querySelector: 'pre.mermaid'});`)
161
+ script(
162
+ domReady(`
163
+ ensure_script_loaded("/static_assets/"+_sc_version_tag+"/mermaid.min.js", () => {
164
+ mermaid.initialize({ startOnLoad: false });
165
+ mermaid.run({ querySelector: ".mermaid" });
166
+ });
167
+ `),
168
+ )
158
169
  );
159
170
  }
160
171
 
@@ -3,24 +3,89 @@ const WorkflowStep = require("@saltcorn/data/models/workflow_step");
3
3
  const Trigger = require("@saltcorn/data/models/trigger");
4
4
  const Table = require("@saltcorn/data/models/table");
5
5
  const { getActionConfigFields } = require("@saltcorn/data/plugin-helper");
6
- const { a, pre, script, div } = require("@saltcorn/markup/tags");
6
+ const { a, pre, script, div, domReady } = require("@saltcorn/markup/tags");
7
7
  const { fieldProperties } = require("../common");
8
8
 
9
+ const table_triggers = ["Insert", "Update", "Delete", "Validate"];
10
+ const additional_triggers_with_onlyif = ["Login", "PageLoad"];
11
+
12
+ const optionNames = (options = []) =>
13
+ options
14
+ .map((opt) =>
15
+ typeof opt === "string"
16
+ ? opt
17
+ : opt?.name || opt?.label || opt?.value || "",
18
+ )
19
+ .filter(Boolean);
20
+
21
+ const joinOptionNames = (options = [], limit = 10) => {
22
+ const names = optionNames(options);
23
+ if (!names.length) return "none";
24
+ const shown = names.slice(0, limit).join(", ");
25
+ return names.length > limit ? `${shown}, ...` : shown;
26
+ };
27
+
28
+ const summarizeTriggerActionMatrix = () => {
29
+ try {
30
+ const allActions = Trigger.action_options({
31
+ notRequireRow: false,
32
+ workflow: true,
33
+ });
34
+ const noRowActions = Trigger.action_options({
35
+ notRequireRow: true,
36
+ workflow: true,
37
+ });
38
+ const lines = Trigger.when_options.map((when) => {
39
+ const pool = table_triggers.includes(when) ? allActions : noRowActions;
40
+ return `* ${when}: ${joinOptionNames(pool)}`;
41
+ });
42
+ const notes = `Triggers requiring a table context: ${table_triggers.join(", ")}.
43
+ Additional triggers that commonly use contextual only_if checks: ${additional_triggers_with_onlyif.join(", ")}.`;
44
+ return `${notes}
45
+ ${lines.join("\n")}`;
46
+ } catch (e) {
47
+ console.error("GenerateWorkflow: action matrix failed", e);
48
+ return "";
49
+ }
50
+ };
51
+
52
+ const summarizeTables = async () => {
53
+ try {
54
+ const tables = await Table.find({});
55
+ if (!tables?.length) return "";
56
+ const fieldType = (f = {}) =>
57
+ f.pretty_type || f.type?.name || f.type || f.input_type || "unknown";
58
+ const rows = tables.map((table) => {
59
+ const description = table.description ? ` – ${table.description}` : "";
60
+ const fields = (table.fields || [])
61
+ .slice(0, 8)
62
+ .map((f) => `${f.name}:${fieldType(f)}`)
63
+ .join(", ");
64
+ const fieldText = fields ? ` fields: ${fields}` : "";
65
+ return `* ${table.name}${description}${fieldText}`;
66
+ });
67
+ return rows.join("\n");
68
+ } catch (e) {
69
+ console.error("GenerateWorkflow: table summary failed", e);
70
+ return "";
71
+ }
72
+ };
73
+
9
74
  const steps = async () => {
10
75
  const actionExplainers = WorkflowStep.builtInActionExplainers();
11
76
  const actionFields = await WorkflowStep.builtInActionConfigFields();
12
77
 
13
78
  let stateActions = getState().actions;
14
79
  const stateActionList = Object.entries(stateActions).filter(
15
- ([k, v]) => !v.disableInWorkflow
80
+ ([k, v]) => !v.disableInWorkflow,
16
81
  );
17
82
 
18
83
  const stepTypeAndCfg = Object.keys(actionExplainers).map((actionName) => {
19
- const properties = {
20
- step_type: { type: "string", enum: [actionName] }
84
+ const properties = {
85
+ step_type: { type: "string", enum: [actionName] },
21
86
  };
22
87
  const myFields = actionFields.filter(
23
- (f) => f.showIf?.wf_action_name === actionName
88
+ (f) => f.showIf?.wf_action_name === actionName,
24
89
  );
25
90
  const required = ["step_type"];
26
91
  myFields.forEach((f) => {
@@ -39,8 +104,8 @@ const steps = async () => {
39
104
  });
40
105
  for (const [actionName, action] of stateActionList) {
41
106
  try {
42
- const properties = {
43
- step_type: { type: "string", enum: [actionName] }
107
+ const properties = {
108
+ step_type: { type: "string", enum: [actionName] },
44
109
  };
45
110
  const cfgFields = await getActionConfigFields(action, null, {
46
111
  mode: "workflow",
@@ -73,9 +138,9 @@ const steps = async () => {
73
138
  //TODO workflows
74
139
  for (const trigger of triggers) {
75
140
  const properties = {
76
- step_type: {
141
+ step_type: {
77
142
  type: "string",
78
- enum: [trigger.name],
143
+ enum: [trigger.name],
79
144
  },
80
145
  };
81
146
  if (trigger.table_id) {
@@ -88,7 +153,7 @@ const steps = async () => {
88
153
  properties.row_expr = {
89
154
  type: "string",
90
155
  description: `JavaScript expression for the input to the action. This should be an expression for an object, with the following field name and types: ${fieldSpecs.join(
91
- "; "
156
+ "; ",
92
157
  )}.`,
93
158
  };
94
159
  }
@@ -150,11 +215,10 @@ class GenerateWorkflow {
150
215
  description:
151
216
  "When the workflow should trigger. Optional, leave blank if unspecified or workflow will be run on button click",
152
217
  type: "string",
153
- enum: ["Insert", "Delete", "Update", "Daily", "Hourly", "Weekly"],
218
+ enum: Trigger.when_options,
154
219
  },
155
220
  trigger_table: {
156
- description:
157
- "If the workflow trigger is Insert, Delete or Update, the name of the table that triggers the workflow",
221
+ description: `If the workflow trigger is ${table_triggers.join(", ")}, the name of the table that triggers the workflow`,
158
222
  type: "string",
159
223
  },
160
224
  },
@@ -165,15 +229,29 @@ class GenerateWorkflow {
165
229
  const actionExplainers = WorkflowStep.builtInActionExplainers();
166
230
  let stateActions = getState().actions;
167
231
  const stateActionList = Object.entries(stateActions).filter(
168
- ([k, v]) => !v.disableInWorkflow
232
+ ([k, v]) => !v.disableInWorkflow,
169
233
  );
234
+ const [tableSummary, triggerMatrix] = await Promise.all([
235
+ summarizeTables(),
236
+ Promise.resolve(summarizeTriggerActionMatrix()),
237
+ ]);
238
+ const contextBlocks = [
239
+ tableSummary && `Current tables and key fields:\n${tableSummary}`,
240
+ triggerMatrix && `Workflow trigger compatibility:\n${triggerMatrix}`,
241
+ ]
242
+ .filter(Boolean)
243
+ .join("\n\n");
170
244
 
171
245
  return `Use the generate_workflow tool to construct computational workflows according to specifications. You must create
172
246
  the workflow by calling the generate_workflow tool, with the step required to implement the specification.
173
247
 
248
+ ${contextBlocks}
249
+
174
250
  The steps are specified as JSON objects. Each step has a name, specified in the step_name key in the JSON object.
175
251
  The step name should be a valid JavaScript identifier.
176
252
 
253
+ When the user explicitly names an action/step type (for example "ForLoop" or "Toast"), ensure the generated workflow contains at least one step whose step_configuration.step_type exactly matches every requested action. Only skip this if the action genuinely does not exist; in that case, clearly explain the omission in the workflow description.
254
+
177
255
  Each run of the workflow is executed in the presence of a context, which is a JavaScript object that individual
178
256
  steps can read values from and write values to. This context is a state that is persisted on disk for each workflow
179
257
  run.
@@ -239,7 +317,7 @@ class GenerateWorkflow {
239
317
 
240
318
  static async execute(
241
319
  { workflow_steps, workflow_name, when_trigger, trigger_table },
242
- req
320
+ req,
243
321
  ) {
244
322
  const steps = this.process_all_steps(workflow_steps);
245
323
  let table_id;
@@ -268,7 +346,7 @@ class GenerateWorkflow {
268
346
  "Workflow created. " +
269
347
  a(
270
348
  { target: "_blank", href: `/actions/configure/${trigger.id}` },
271
- "Configure workflow."
349
+ "Configure workflow.",
272
350
  ),
273
351
  };
274
352
  }
@@ -286,21 +364,29 @@ class GenerateWorkflow {
286
364
  step.id = ix + 1;
287
365
  });
288
366
  const mmdia = WorkflowStep.generate_diagram(
289
- steps.map((s) => new WorkflowStep(s))
367
+ steps.map((s) => new WorkflowStep(s)),
290
368
  );
369
+ console.log({ mmdia });
291
370
  return (
292
371
  div(
293
372
  `${workflow_name}${when_trigger ? `: ${when_trigger}` : ""}${
294
373
  trigger_table ? ` on ${trigger_table}` : ""
295
- }`
374
+ }`,
296
375
  ) +
297
376
  pre({ class: "mermaid" }, mmdia) +
298
- script(`mermaid.run({querySelector: 'pre.mermaid'});`)
377
+ script(
378
+ domReady(`
379
+ ensure_script_loaded("/static_assets/"+_sc_version_tag+"/mermaid.min.js", () => {
380
+ mermaid.initialize({ startOnLoad: false });
381
+ mermaid.run({ querySelector: ".mermaid" });
382
+ });
383
+ `),
384
+ )
299
385
  );
300
386
  }
301
387
 
302
388
  return `A workflow! Step names: ${workflow_steps.map(
303
- (s) => s.step_name
389
+ (s) => s.step_name,
304
390
  )}. Upgrade Saltcorn to see diagrams in copilot`;
305
391
  }
306
392
 
@@ -1,4 +1,5 @@
1
1
  const GenerateTables = require("../actions/generate-tables");
2
+ const Table = require("@saltcorn/data/models/table");
2
3
 
3
4
  const normalizeTablesPayload = (rawPayload) => {
4
5
  if (!rawPayload) return { tables: [] };
@@ -68,18 +69,73 @@ const collectTableWarnings = (tables) => {
68
69
 
69
70
  if (!field?.type_and_configuration?.data_type)
70
71
  warnings.push(
71
- `${tableLabel}.${fieldLabel} must include type_and_configuration.data_type.`
72
+ `${tableLabel}.${fieldLabel} must include type_and_configuration.data_type.`,
72
73
  );
73
74
 
74
75
  if ((field?.name || "").toLowerCase() === "id")
75
76
  warnings.push(
76
- `${tableLabel}.${fieldLabel} should be omitted because every table already has an auto-increment id.`
77
+ `${tableLabel}.${fieldLabel} should be omitted because every table already has an auto-increment id.`,
77
78
  );
78
79
  });
79
80
  });
80
81
  return warnings;
81
82
  };
82
83
 
84
+ const fetchExistingTableNameSet = async () => {
85
+ const existingTables = await Table.find({});
86
+ const names = new Set();
87
+ existingTables.forEach((table) => {
88
+ if (table?.name) names.add(table.name.toLowerCase());
89
+ });
90
+ return names;
91
+ };
92
+
93
+ const partitionTablesByExistence = async (tables = []) => {
94
+ const existingNames = await fetchExistingTableNameSet();
95
+ const seenNewNames = new Set();
96
+ const newTables = [];
97
+ const skippedExisting = [];
98
+ const skippedDuplicates = [];
99
+ tables.forEach((table) => {
100
+ const tableName =
101
+ typeof table?.table_name === "string" ? table.table_name.trim() : "";
102
+ const normalized = tableName.toLowerCase();
103
+ if (tableName && existingNames.has(normalized)) {
104
+ skippedExisting.push(tableName);
105
+ return;
106
+ }
107
+ if (tableName && seenNewNames.has(normalized)) {
108
+ skippedDuplicates.push(tableName);
109
+ return;
110
+ }
111
+ if (tableName) seenNewNames.add(normalized);
112
+ newTables.push(table);
113
+ });
114
+ return { newTables, skippedExisting, skippedDuplicates };
115
+ };
116
+
117
+ const partitionTablesByValidity = (tables = []) => {
118
+ const validTables = [];
119
+ const skippedMissingNames = [];
120
+ const skippedMissingFields = [];
121
+ tables.forEach((table, idx) => {
122
+ const rawName =
123
+ typeof table?.table_name === "string" ? table.table_name.trim() : "";
124
+ const fallbackLabel = rawName || `Table #${idx + 1}`;
125
+ if (!rawName) {
126
+ skippedMissingNames.push(fallbackLabel);
127
+ return;
128
+ }
129
+ const fields = Array.isArray(table?.fields) ? table.fields : [];
130
+ if (!fields.length) {
131
+ skippedMissingFields.push(rawName);
132
+ return;
133
+ }
134
+ validTables.push({ ...table, table_name: rawName, fields });
135
+ });
136
+ return { validTables, skippedMissingNames, skippedMissingFields };
137
+ };
138
+
83
139
  const payloadFromToolCall = (tool_call) => {
84
140
  if (!tool_call) return { tables: [] };
85
141
  if (tool_call.input) return normalizeTablesPayload(tool_call.input);
@@ -96,79 +152,187 @@ class GenerateTablesSkill {
96
152
  }
97
153
 
98
154
  constructor(cfg) {
99
- console.log("GenerateTablesSkill.constructor called", { cfg });
100
155
  Object.assign(this, cfg);
101
156
  }
102
157
 
103
158
  async systemPrompt() {
104
- console.log("GenerateTablesSkill.systemPrompt called");
105
159
  return await GenerateTables.system_prompt();
106
160
  }
107
161
 
108
162
  get userActions() {
109
- console.log("GenerateTablesSkill.userActions getter accessed");
110
163
  return {
111
164
  async apply_copilot_tables({ user, tables }) {
112
- console.log("GenerateTablesSkill.userActions.apply_copilot_tables called", {
113
- user_id: user?.id,
114
- table_count: tables?.length,
115
- });
116
165
  if (!tables?.length) return { notify: "Nothing to create." };
117
- await GenerateTables.execute({ tables }, { user });
166
+ const { newTables, skippedExisting, skippedDuplicates } =
167
+ await partitionTablesByExistence(tables);
168
+ const { validTables, skippedMissingNames, skippedMissingFields } =
169
+ partitionTablesByValidity(newTables);
170
+ if (!validTables.length) {
171
+ const skippedMessages = [];
172
+ if (skippedExisting.length)
173
+ skippedMessages.push(
174
+ `Existing tables: ${skippedExisting.join(", ")}`,
175
+ );
176
+ if (skippedDuplicates.length)
177
+ skippedMessages.push(
178
+ `Duplicate definitions: ${skippedDuplicates.join(", ")}`,
179
+ );
180
+ if (skippedMissingNames.length)
181
+ skippedMessages.push(
182
+ `Missing table_name: ${skippedMissingNames.join(", ")}`,
183
+ );
184
+ if (skippedMissingFields.length)
185
+ skippedMessages.push(
186
+ `Tables without fields: ${skippedMissingFields.join(", ")}`,
187
+ );
188
+ return {
189
+ notify:
190
+ skippedMessages.length > 0
191
+ ? `Nothing to create. Skipped ${skippedMessages.join("; ")}.`
192
+ : "Nothing to create.",
193
+ };
194
+ }
195
+ await GenerateTables.execute({ tables: validTables }, { user });
196
+ const createdNames = validTables.map((t) => t.table_name).join(", ");
197
+ const skippedMessages = [];
198
+ if (skippedExisting.length)
199
+ skippedMessages.push(
200
+ `Skipped existing tables: ${skippedExisting.join(", ")}`,
201
+ );
202
+ if (skippedDuplicates.length)
203
+ skippedMessages.push(
204
+ `Ignored duplicate definitions: ${skippedDuplicates.join(", ")}`,
205
+ );
206
+ if (skippedMissingNames.length)
207
+ skippedMessages.push(
208
+ `Missing table_name: ${skippedMissingNames.join(", ")}`,
209
+ );
210
+ if (skippedMissingFields.length)
211
+ skippedMessages.push(
212
+ `Tables without fields: ${skippedMissingFields.join(", ")}`,
213
+ );
118
214
  return {
119
- notify: `Created tables: ${tables.map((t) => t.table_name).join(", ")}`,
215
+ notify: [`Created tables: ${createdNames}`, ...skippedMessages].join(
216
+ ". ",
217
+ ),
120
218
  };
121
219
  },
122
220
  };
123
221
  }
124
222
 
125
223
  provideTools = () => {
126
- console.log("GenerateTablesSkill.provideTools called");
127
224
  const parameters = GenerateTables.json_schema();
128
225
  return {
129
226
  type: "function",
130
227
  process: async (input) => {
131
228
  const payload = normalizeTablesPayload(input);
132
229
  const tables = payload.tables || [];
133
- console.log("GenerateTablesSkill.provideTools.process called", {
134
- table_count: tables.length,
135
- });
136
- if (!tables.length) return "No tables were provided for generate_tables.";
137
- const summaryLines = summarizeTables(tables).map((line) => `- ${line}`);
230
+ if (!tables.length) {
231
+ return "No tables were provided for generate_tables.";
232
+ }
233
+ const { newTables, skippedExisting, skippedDuplicates } =
234
+ await partitionTablesByExistence(tables);
235
+ const { validTables, skippedMissingNames, skippedMissingFields } =
236
+ partitionTablesByValidity(newTables);
237
+ const summaryLines = validTables.length
238
+ ? summarizeTables(validTables).map((line) => `- ${line}`)
239
+ : [];
138
240
  const warnings = collectTableWarnings(tables);
241
+ if (skippedExisting.length)
242
+ skippedExisting.forEach((name) =>
243
+ warnings.push(
244
+ `Table "${name}" already exists and will not be recreated by generate_tables.`,
245
+ ),
246
+ );
247
+ if (skippedDuplicates.length)
248
+ skippedDuplicates.forEach((name) =>
249
+ warnings.push(
250
+ `Table "${name}" was defined multiple times in this request; only the first definition will be used.`,
251
+ ),
252
+ );
253
+ skippedMissingNames.forEach((label) =>
254
+ warnings.push(
255
+ `${label} is skipped because it does not include a table_name.`,
256
+ ),
257
+ );
258
+ skippedMissingFields.forEach((label) =>
259
+ warnings.push(
260
+ `Table "${label}" is skipped because it does not define any fields.`,
261
+ ),
262
+ );
139
263
  const warningLines = warnings.length
140
264
  ? ["Warnings:", ...warnings.map((w) => `- ${w}`)]
141
265
  : [];
266
+ const summarySection = summaryLines.length
267
+ ? [
268
+ `Ready to create ${validTables.length} new table${
269
+ validTables.length === 1 ? "" : "s"
270
+ }:`,
271
+ ...summaryLines,
272
+ ]
273
+ : [
274
+ "No new tables remain after removing existing, duplicate, or invalid table definitions.",
275
+ ];
142
276
  return [
143
- `Received ${tables.length} table definition${tables.length === 1 ? "" : "s"}:`,
144
- ...summaryLines,
277
+ `Received ${tables.length} table definition${tables.length === 1 ? "" : "s"}.`,
278
+ ...summarySection,
145
279
  ...warningLines,
146
280
  ].join("\n");
147
281
  },
148
282
  postProcess: async ({ tool_call }) => {
149
- console.log("GenerateTablesSkill.provideTools.postProcess called", {
150
- has_input: !!tool_call?.input,
151
- });
152
283
  const payload = payloadFromToolCall(tool_call);
153
284
  const tables = payload.tables || [];
285
+ const { newTables, skippedExisting, skippedDuplicates } =
286
+ await partitionTablesByExistence(tables);
287
+ const { validTables, skippedMissingNames, skippedMissingFields } =
288
+ partitionTablesByValidity(newTables);
154
289
  let preview = "";
155
290
  try {
156
- preview = GenerateTables.render_html({ tables });
291
+ if (validTables.length) {
292
+ preview = GenerateTables.render_html({ tables: validTables });
293
+ } else {
294
+ preview =
295
+ '<div class="alert alert-info">No new tables to preview because every provided table already exists or was invalid.</div>';
296
+ }
157
297
  } catch (e) {
298
+ console.log("We are in postProcess but rendering failed", {
299
+ e,
300
+ time: new Date(),
301
+ });
158
302
  preview = `<pre>${JSON.stringify(payload, null, 2)}</pre>`;
159
303
  }
304
+ const warningChunks = [];
305
+ if (skippedExisting.length)
306
+ warningChunks.push(
307
+ `Skipped existing tables: ${skippedExisting.join(", ")}`,
308
+ );
309
+ if (skippedDuplicates.length)
310
+ warningChunks.push(
311
+ `Ignored duplicate definitions: ${skippedDuplicates.join(", ")}`,
312
+ );
313
+ if (skippedMissingNames.length)
314
+ warningChunks.push(
315
+ `Missing table_name: ${skippedMissingNames.join(", ")}`,
316
+ );
317
+ if (skippedMissingFields.length)
318
+ warningChunks.push(
319
+ `Tables without fields: ${skippedMissingFields.join(", ")}`,
320
+ );
321
+ const warningHtml = warningChunks.length
322
+ ? `<div class="alert alert-warning">${warningChunks.join("<br/>")}</div>`
323
+ : "";
160
324
  return {
161
325
  stop: true,
162
- add_response: preview,
326
+ add_response: `${warningHtml}${preview}`,
163
327
  add_user_action:
164
- tables.length > 0
328
+ validTables.length > 0
165
329
  ? {
166
330
  name: "apply_copilot_tables",
167
331
  type: "button",
168
- label: `Create tables (${tables
332
+ label: `Create tables (${validTables
169
333
  .map((t) => t.table_name)
170
334
  .join(", ")})`,
171
- input: { tables },
335
+ input: { tables: validTables },
172
336
  }
173
337
  : undefined,
174
338
  };
@@ -33,7 +33,6 @@ class GeneratePageSkill {
33
33
  }
34
34
 
35
35
  constructor(cfg) {
36
- console.log("GeneratePageSkill.constructor called", { cfg });
37
36
  Object.assign(this, cfg);
38
37
  }
39
38
 
@@ -77,16 +76,6 @@ class GeneratePageSkill {
77
76
  };
78
77
  }
79
78
  provideTools = () => {
80
- let properties = {};
81
- (this.toolargs || []).forEach((arg) => {
82
- properties[arg.name] = {
83
- description: arg.description,
84
- type: arg.argtype,
85
- };
86
- if (arg.options && arg.argtype === "string")
87
- properties[arg.name].enum = arg.options.split(",").map((s) => s.trim());
88
- });
89
-
90
79
  return {
91
80
  type: "function",
92
81
  process: async ({ name, existing_page_name }) => {