@saltcorn/copilot 0.8.0 → 0.8.2

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.
@@ -163,6 +163,13 @@ class GenerateTables {
163
163
  },
164
164
  },
165
165
  },
166
+ reused_table_names: {
167
+ type: "array",
168
+ items: { type: "string" },
169
+ description:
170
+ "Names of existing tables that are already complete and require no changes. " +
171
+ "List them here so the caller knows which tables were reused as-is. Do NOT repeat their field definitions in the tables array.",
172
+ },
166
173
  },
167
174
  };
168
175
  }
@@ -202,6 +209,12 @@ class GenerateTables {
202
209
  want to add or update. The system will automatically add new fields and update the settings of
203
210
  existing fields — it will not recreate or drop the table.
204
211
 
212
+ ## Reused tables
213
+
214
+ If an existing table is used by the application as-is (no new fields needed), do NOT repeat it
215
+ in the tables array. Instead, add its name to the reused_table_names array. This tells the
216
+ system to include it in the schema diagram without attempting to modify it.
217
+
205
218
  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
219
 
207
220
  If a table has a ForeignKey field that references another table which does not yet exist in the
@@ -451,6 +464,7 @@ const buildMermaidMarkup = (tables) => {
451
464
  };
452
465
 
453
466
  module.exports = GenerateTables;
467
+ module.exports.buildMermaidMarkup = buildMermaidMarkup;
454
468
 
455
469
  /* todo
456
470
 
@@ -0,0 +1,61 @@
1
+ const Trigger = require("@saltcorn/data/models/trigger");
2
+ const Table = require("@saltcorn/data/models/table");
3
+ const { a, div, pre } = require("@saltcorn/markup/tags");
4
+
5
+ class GenerateTrigger {
6
+ static title = "Generate Trigger";
7
+ static function_name = "generate_trigger";
8
+ static description =
9
+ "Generate a Saltcorn trigger with any available action type";
10
+
11
+ static render_html({
12
+ action_name,
13
+ action_type,
14
+ when_trigger,
15
+ trigger_table,
16
+ action_config,
17
+ }) {
18
+ const summary =
19
+ `<strong>${action_name}</strong> — ${action_type}` +
20
+ (when_trigger ? `: ${when_trigger}` : "") +
21
+ (trigger_table ? ` on ${trigger_table}` : "");
22
+ const configSection =
23
+ action_config && Object.keys(action_config).length
24
+ ? pre({ class: "mt-2" }, JSON.stringify(action_config, null, 2))
25
+ : "";
26
+ return div({ class: "mb-3" }, summary) + configSection;
27
+ }
28
+
29
+ static async execute(
30
+ { action_name, action_type, when_trigger, trigger_table, action_config },
31
+ req,
32
+ ) {
33
+ let table_id;
34
+ if (trigger_table) {
35
+ const table = Table.findOne({ name: trigger_table });
36
+ if (!table) return { postExec: `Table not found: ${trigger_table}` };
37
+ table_id = table.id;
38
+ }
39
+ const trigger = await Trigger.create({
40
+ name: action_name,
41
+ when_trigger: when_trigger || "Never",
42
+ table_id,
43
+ action: action_type,
44
+ configuration: action_config || {},
45
+ });
46
+ Trigger.emitEvent("AppChange", `Trigger ${trigger.name}`, req?.user, {
47
+ entity_type: "Trigger",
48
+ entity_name: trigger.name,
49
+ });
50
+ return {
51
+ postExec:
52
+ "Trigger created. " +
53
+ a(
54
+ { target: "_blank", href: `/actions/configure/${trigger.id}` },
55
+ "Configure trigger.",
56
+ ),
57
+ };
58
+ }
59
+ }
60
+
61
+ module.exports = GenerateTrigger;
@@ -215,7 +215,8 @@ class GenerateWorkflow {
215
215
  workflow_steps: await steps(),
216
216
  workflow_name: {
217
217
  description:
218
- "The name of the workflow. Can include spaces and mixed case, should be 1-5 words.",
218
+ "The name of the workflow. Can include spaces and mixed case, should be 1-5 words. " +
219
+ "Must be unique across all workflows. When creating variants for different events on the same table (e.g. insert, update, delete), include the event in each name — e.g. 'Recalc trip packing insert', 'Recalc trip packing update'.",
219
220
  type: "string",
220
221
  },
221
222
  when_trigger: {
@@ -254,6 +255,10 @@ class GenerateWorkflow {
254
255
  return `Use the generate_workflow tool to construct computational workflows according to specifications. You must create
255
256
  the workflow by calling the generate_workflow tool, with the step required to implement the specification.
256
257
 
258
+ **Trigger vs workflow:** If the task can be completed in a single step (e.g. updating one field with modify_row, sending a single notification), use the generate_trigger tool instead — a workflow is only appropriate when multiple steps, branching, or looping are required. If the task requires several independent single-step actions (e.g. "mark complete" and "mark incomplete"), create a separate trigger for each — do NOT bundle them into one workflow.
259
+
260
+ **Naming:** Workflow names must be unique. When creating variants for different events on the same table (e.g. insert, update, delete), include the event in each name — e.g. "Recalc trip packing insert", "Recalc trip packing update", "Recalc trip packing delete".
261
+
257
262
  ${contextBlocks}
258
263
 
259
264
  The steps are specified as JSON objects. Each step has a name, specified in the step_name key in the JSON object.
@@ -52,20 +52,27 @@ class GeneratePageSkill {
52
52
  }
53
53
  get userActions() {
54
54
  return {
55
- async build_copilot_page_gen({ user, name, title, description, html }) {
55
+ async build_copilot_page_gen({
56
+ user,
57
+ name,
58
+ title,
59
+ description,
60
+ html,
61
+ min_role = 100,
62
+ }) {
56
63
  const file = await File.from_contents(
57
64
  `${name}.html`,
58
65
  "text/html",
59
66
  html,
60
- user.id,
61
- 100,
67
+ user?.id,
68
+ min_role
62
69
  );
63
70
 
64
71
  await Page.create({
65
72
  name,
66
73
  title,
67
74
  description,
68
- min_role: 100,
75
+ min_role,
69
76
  layout: { html_file: file.path_to_serve },
70
77
  });
71
78
  setTimeout(() => getState().refresh_pages(), 200);
@@ -96,7 +103,7 @@ class GeneratePageSkill {
96
103
  );
97
104
  } else return "Metadata recieved";
98
105
  },
99
- postProcess: async ({ tool_call, generate }) => {
106
+ postProcess: async ({ tool_call, generate, req }) => {
100
107
  const str = await generate(
101
108
  `Now generate the contents of the ${tool_call.input.name} HTML page. If I asked you to embed a view,
102
109
  use the <embed-view> self-closing tag to do so, setting the view name in the viewname attribute. For example,
@@ -114,12 +121,26 @@ class GeneratePageSkill {
114
121
 
115
122
  <script src="/static_assets/js/saltcorn-common.js"></script>
116
123
  <script src="/static_assets/js/saltcorn.js">
117
- `,
124
+ `
118
125
  );
119
126
  const html = str.includes("```html")
120
127
  ? str.split("```html")[1].split("```")[0]
121
128
  : str;
122
129
 
130
+ if (this.yoloMode) {
131
+ await this.userActions.build_copilot_page_gen({
132
+ user: req?.user,
133
+ name: tool_call.input.name,
134
+ title: tool_call.input.title,
135
+ description: tool_call.input.description,
136
+ min_role: tool_call.input.min_role ?? 100,
137
+ html,
138
+ });
139
+ return {
140
+ stop: true,
141
+ add_response: `Page ${tool_call.input.name} created.`,
142
+ };
143
+ }
123
144
  return {
124
145
  stop: true,
125
146
  add_response: iframe({
@@ -127,15 +148,15 @@ class GeneratePageSkill {
127
148
  width: 500,
128
149
  height: 800,
129
150
  }),
130
- add_system_prompt: `If the user asks you to regenerate the page,
131
- you must run the generate_page tool again. After running this tool
132
- you will be prompted to generate the html again. You should repeat
133
- the html from the previous answer except for the changes the user
151
+ add_system_prompt: `If the user asks you to regenerate the page,
152
+ you must run the generate_page tool again. After running this tool
153
+ you will be prompted to generate the html again. You should repeat
154
+ the html from the previous answer except for the changes the user
134
155
  is requesting.`,
135
156
  add_user_action: {
136
157
  name: "build_copilot_page_gen",
137
158
  type: "button",
138
- label: "Save page "+tool_call.input.name,
159
+ label: "Save page " + tool_call.input.name,
139
160
  input: { html },
140
161
  },
141
162
  };
@@ -150,7 +171,7 @@ class GeneratePageSkill {
150
171
  response.includes("Unable to provide HTML for this page")
151
172
  )
152
173
  return response;
153
- if (
174
+ if (
154
175
  typeof response === "string" &&
155
176
  response.includes("The HTML code for the ")
156
177
  )
@@ -181,6 +202,12 @@ class GeneratePageSkill {
181
202
  "A longer description that is not visible but appears in the page header and is indexed by search engines",
182
203
  type: "string",
183
204
  },
205
+ min_role: {
206
+ description:
207
+ "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.",
208
+ type: "integer",
209
+ enum: [1, 40, 80, 100],
210
+ },
184
211
  page_type: {
185
212
  description:
186
213
  "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",
@@ -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`;
@@ -473,6 +480,7 @@ with both the entity type and name, and the new JSON definition as a string as a
473
480
  "system-configuration-value",
474
481
  "module-configuration",
475
482
  "role",
483
+ "file",
476
484
  ],
477
485
  },
478
486
  entity_name: {
@@ -897,6 +905,13 @@ with both the entity type and name, and the new JSON definition as a string as a
897
905
  await getState().refresh_roles();
898
906
  return "Role created";
899
907
  }
908
+
909
+ case "file": {
910
+ const file = await File.findOne({ filename: input.entity_name });
911
+ if (!file) return `file not found: ${input.entity_name}`;
912
+ await file.set_role(entityValue.min_role_read);
913
+ return "Done";
914
+ }
900
915
  }
901
916
  return "Done";
902
917
  } catch (e) {
@@ -0,0 +1,259 @@
1
+ const Trigger = require("@saltcorn/data/models/trigger");
2
+ const Table = require("@saltcorn/data/models/table");
3
+ const { getState } = require("@saltcorn/data/db/state");
4
+ const { getActionConfigFields } = require("@saltcorn/data/plugin-helper");
5
+ const { fieldProperties } = require("../common");
6
+ const GenerateAnyAction = require("../actions/generate-trigger");
7
+
8
+ const flattenOptionGroups = (options = []) =>
9
+ options.flatMap((opt) =>
10
+ opt?.optgroup && Array.isArray(opt.options) ? opt.options : [opt],
11
+ );
12
+
13
+ class AnyActionSkill {
14
+ static skill_name = "Generate trigger";
15
+
16
+ get skill_label() {
17
+ return "Generate trigger";
18
+ }
19
+
20
+ constructor(cfg) {
21
+ Object.assign(this, cfg);
22
+ }
23
+
24
+ async systemPrompt() {
25
+ return (
26
+ `If the user asks to create an action or trigger, use the generate_trigger tool. ` +
27
+ `Pick the most appropriate action_type from the available options. ` +
28
+ `Only set when_trigger and trigger_table if the user has specified them. ` +
29
+ `Trigger names must be unique — when creating variants for different events on the same table, include the event in each name (e.g. "Recalc trip packing insert", "Recalc trip packing update", "Recalc trip packing delete").\n\n` +
30
+ `**Trigger vs workflow:** Use a trigger (this tool) when the action is a single step — ` +
31
+ `for example, setting a field value with modify_row, sending a notification, or calling an API. ` +
32
+ `Only use a workflow when the logic requires multiple steps, branching, or looping. ` +
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
+ `**Navigation is a view concern:** If a task description says "return the user to X" or "navigate back", ` +
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.`
37
+ );
38
+ }
39
+
40
+ get userActions() {
41
+ return {
42
+ async build_copilot_any_action(input) {
43
+ const {
44
+ name,
45
+ action_type,
46
+ when_trigger,
47
+ trigger_table,
48
+ action_config,
49
+ user,
50
+ } = input;
51
+ if (!name || !action_type) {
52
+ return { notify: "Action name and type are required." };
53
+ }
54
+ const result = await GenerateAnyAction.execute(
55
+ {
56
+ action_name: name,
57
+ action_type,
58
+ when_trigger,
59
+ trigger_table,
60
+ action_config,
61
+ },
62
+ { user },
63
+ );
64
+ return { notify: result?.postExec || `Action saved: ${name}` };
65
+ },
66
+ };
67
+ }
68
+
69
+ provideTools = () => {
70
+ const state = getState();
71
+ const allActionOptions = (() => {
72
+ try {
73
+ return (
74
+ Trigger.action_options({ notRequireRow: false, workflow: false }) ||
75
+ []
76
+ );
77
+ } catch (_) {
78
+ return [];
79
+ }
80
+ })();
81
+ const stateActions = state?.actions || {};
82
+ const stateActionNames = Object.keys(stateActions);
83
+ const catalogNames = flattenOptionGroups(allActionOptions);
84
+ const actionEnum = Array.from(
85
+ new Set([...catalogNames, ...stateActionNames]),
86
+ ).sort();
87
+
88
+ const tables = (state.tables || []).map((t) => t.name);
89
+
90
+ return {
91
+ type: "function",
92
+ function: {
93
+ name: GenerateAnyAction.function_name,
94
+ description: GenerateAnyAction.description,
95
+ parameters: {
96
+ type: "object",
97
+ required: ["name", "action_type"],
98
+ properties: {
99
+ name: {
100
+ type: "string",
101
+ description:
102
+ "A human-readable name for the trigger/action (1–5 words). " +
103
+ "Must be unique across all triggers. When creating multiple triggers for different events on the same table (e.g. insert, update, delete), include the event in the name — e.g. 'Recalc trip packing insert', 'Recalc trip packing update'.",
104
+ },
105
+ action_type: {
106
+ type: "string",
107
+ enum: actionEnum.length ? actionEnum : undefined,
108
+ description: "The action to run when the trigger fires.",
109
+ },
110
+ when_trigger: {
111
+ type: "string",
112
+ enum: Trigger.when_options,
113
+ description:
114
+ "When to fire this trigger. Only set if the user has specified. Leave unset for manual/API-call triggers.",
115
+ },
116
+ trigger_table: {
117
+ type: "string",
118
+ enum: tables,
119
+ description:
120
+ "Table for row-level triggers (Insert/Update/Delete/Validate). Only set when when_trigger requires a table.",
121
+ },
122
+ },
123
+ },
124
+ },
125
+ process: async (input) => {
126
+ const { name, action_type, when_trigger, trigger_table } = input || {};
127
+ return [
128
+ `Generating ${action_type} action: ${name}.`,
129
+ when_trigger ? `Trigger: ${when_trigger}` : null,
130
+ trigger_table ? `Table: ${trigger_table}` : null,
131
+ ]
132
+ .filter(Boolean)
133
+ .join("\n");
134
+ },
135
+ postProcess: async ({ tool_call, generate }) => {
136
+ const { name, action_type, when_trigger, trigger_table } =
137
+ tool_call.input || {};
138
+ if (!name || !action_type) {
139
+ return {
140
+ stop: true,
141
+ add_response: "Action name and type are required.",
142
+ };
143
+ }
144
+
145
+ const stateAction = getState()?.actions?.[action_type];
146
+ let action_config = {};
147
+
148
+ if (stateAction) {
149
+ const table = trigger_table
150
+ ? Table.findOne({ name: trigger_table })
151
+ : null;
152
+ let cfgFields = [];
153
+ try {
154
+ cfgFields = await getActionConfigFields(stateAction, table, {
155
+ copilot: true,
156
+ });
157
+ } catch (_) {}
158
+
159
+ const configurable = cfgFields.filter(
160
+ (f) => f.input_type !== "section_header",
161
+ );
162
+ if (configurable.length > 0) {
163
+ const properties = {};
164
+ for (const f of configurable) {
165
+ properties[f.name] = {
166
+ description: f.sublabel || f.label || f.name,
167
+ ...fieldProperties(f),
168
+ };
169
+ if (!properties[f.name].type) properties[f.name].type = "string";
170
+ }
171
+
172
+ let actionPrompt = "";
173
+ if (stateAction.copilot_generate_trigger_prompt) {
174
+ if (
175
+ typeof stateAction.copilot_generate_trigger_prompt === "string"
176
+ )
177
+ actionPrompt = stateAction.copilot_generate_trigger_prompt;
178
+ else if (
179
+ typeof stateAction.copilot_generate_trigger_prompt ===
180
+ "function"
181
+ )
182
+ actionPrompt =
183
+ await stateAction.copilot_generate_trigger_prompt(
184
+ tool_call.input,
185
+ );
186
+ }
187
+
188
+ const llm = getState().functions.llm_generate;
189
+ const answer = await llm.run(
190
+ `${actionPrompt ? actionPrompt + "\n\n" : ""}Configure the "${action_type}" action named "${name}". ` +
191
+ `Fill in the configuration by calling the generate_action_config tool.`,
192
+ {
193
+ tools: [
194
+ {
195
+ type: "function",
196
+ function: {
197
+ name: "generate_action_config",
198
+ description: `Provide configuration fields for the ${action_type} action`,
199
+ parameters: { type: "object", properties },
200
+ },
201
+ },
202
+ ],
203
+ tool_choice: {
204
+ type: "function",
205
+ function: { name: "generate_action_config" },
206
+ },
207
+ },
208
+ );
209
+
210
+ const tc = answer.getToolCalls()[0];
211
+ if (tc?.input) action_config = tc.input;
212
+ }
213
+ }
214
+
215
+ if (this.yoloMode) {
216
+ const result = await GenerateAnyAction.execute(
217
+ {
218
+ action_name: name,
219
+ action_type,
220
+ when_trigger,
221
+ trigger_table,
222
+ action_config,
223
+ },
224
+ {},
225
+ );
226
+ return {
227
+ stop: true,
228
+ add_response: result?.postExec || `Action ${name} created.`,
229
+ };
230
+ }
231
+
232
+ return {
233
+ stop: true,
234
+ add_response: GenerateAnyAction.render_html({
235
+ action_name: name,
236
+ action_type,
237
+ when_trigger,
238
+ trigger_table,
239
+ action_config,
240
+ }),
241
+ add_user_action: {
242
+ name: "build_copilot_any_action",
243
+ type: "button",
244
+ label: `Save action (${name})`,
245
+ input: {
246
+ name,
247
+ action_type,
248
+ when_trigger,
249
+ trigger_table,
250
+ action_config,
251
+ },
252
+ },
253
+ };
254
+ },
255
+ };
256
+ };
257
+ }
258
+
259
+ module.exports = AnyActionSkill;