@saltcorn/copilot 0.7.5 → 0.8.0

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.
@@ -15,6 +15,90 @@ const {
15
15
  } = require("@saltcorn/markup/tags");
16
16
  const builderGen = require("../builder-gen");
17
17
 
18
+ const collectLayoutFieldNames = (segment, out = new Set()) => {
19
+ if (!segment || typeof segment !== "object") return out;
20
+ if (Array.isArray(segment)) {
21
+ segment.forEach((s) => collectLayoutFieldNames(s, out));
22
+ return out;
23
+ }
24
+ if (segment.type === "field" && segment.field_name)
25
+ out.add(segment.field_name);
26
+ if (segment.above) collectLayoutFieldNames(segment.above, out);
27
+ if (segment.besides) collectLayoutFieldNames(segment.besides, out);
28
+ if (segment.contents) collectLayoutFieldNames(segment.contents, out);
29
+ if (Array.isArray(segment.tabs))
30
+ segment.tabs.forEach((t) => collectLayoutFieldNames(t?.contents, out));
31
+ return out;
32
+ };
33
+
34
+ const findFilterFieldSegment = (segment) => {
35
+ if (!segment || typeof segment !== "object") return null;
36
+ if (segment.type === "field") return segment;
37
+ if (segment.type === "dropdown_filter" || segment.type === "toggle_filter") {
38
+ return { field_name: segment.field_name, fieldview: "edit" };
39
+ }
40
+ if (Array.isArray(segment.above)) {
41
+ for (const item of segment.above) {
42
+ const found = findFilterFieldSegment(item);
43
+ if (found) return found;
44
+ }
45
+ }
46
+ if (Array.isArray(segment.besides)) {
47
+ for (const item of segment.besides) {
48
+ const found = findFilterFieldSegment(item);
49
+ if (found) return found;
50
+ }
51
+ }
52
+ if (segment.contents) {
53
+ if (Array.isArray(segment.contents)) {
54
+ for (const item of segment.contents) {
55
+ const found = findFilterFieldSegment(item);
56
+ if (found) return found;
57
+ }
58
+ } else {
59
+ const found = findFilterFieldSegment(segment.contents);
60
+ if (found) return found;
61
+ }
62
+ }
63
+ if (Array.isArray(segment.tabs)) {
64
+ for (const tab of segment.tabs) {
65
+ if (tab?.contents) {
66
+ const found = findFilterFieldSegment(tab.contents);
67
+ if (found) return found;
68
+ }
69
+ }
70
+ }
71
+ if (Array.isArray(segment.contents) && Array.isArray(segment.contents[0])) {
72
+ for (const row of segment.contents) {
73
+ if (Array.isArray(row)) {
74
+ for (const cell of row) {
75
+ const found = findFilterFieldSegment(cell);
76
+ if (found) return found;
77
+ }
78
+ }
79
+ }
80
+ }
81
+ return null;
82
+ };
83
+
84
+ const normalizeFilterField = (segment) => ({
85
+ type: "field",
86
+ field_name: segment.field_name,
87
+ fieldview: segment.fieldview || "edit",
88
+ textStyle: segment.textStyle || "",
89
+ block: segment.block ?? false,
90
+ configuration: segment.configuration || {},
91
+ });
92
+
93
+ const toFilterColumn = (segment) => ({
94
+ type: "Field",
95
+ field_name: segment.field_name,
96
+ fieldview: segment.fieldview || "edit",
97
+ textStyle: segment.textStyle || "",
98
+ block: segment.block ?? false,
99
+ configuration: segment.configuration || {},
100
+ });
101
+
18
102
  class GenerateViewSkill {
19
103
  static skill_name = "Generate View";
20
104
 
@@ -27,8 +111,11 @@ class GenerateViewSkill {
27
111
  }
28
112
 
29
113
  async systemPrompt() {
30
- return `If the user asks to generate a view, use the generate_view tool to enter
31
- a view generation mode. The tool call only requires high-level details to start this sequence.`;
114
+ return (
115
+ `If the user asks to generate a view, use the generate_view tool to enter ` +
116
+ `a view generation mode. The tool call only requires high-level details to start this sequence.\n` +
117
+ `The Edit viewtemplate serves both create (no id in state) and edit (id in state) — one view covers both modes.`
118
+ );
32
119
  }
33
120
 
34
121
  get userActions() {
@@ -50,6 +137,10 @@ a view generation mode. The tool call only requires high-level details to start
50
137
  min_role: { admin: 1, public: 100, user: 80 }[normalizedRole],
51
138
  configuration: wfctx,
52
139
  });
140
+ const vt = getState().viewtemplates[viewpattern];
141
+ if (vt?.copilot_post_create) {
142
+ await vt.copilot_post_create({ name, configuration: wfctx });
143
+ }
53
144
  setTimeout(() => getState().refresh_views(), 200);
54
145
  return {
55
146
  notify: `View saved: <a target="_blank" href="/view/${name}">${name}</a>`,
@@ -66,13 +157,17 @@ a view generation mode. The tool call only requires high-level details to start
66
157
  const enabled_vt_names = all_vt_names.filter(
67
158
  (vtnm) =>
68
159
  vts[vtnm].enable_copilot_viewgen ||
69
- vts[vtnm].copilot_generate_view_prompt,
160
+ vts[vtnm].copilot_generate_view_prompt
70
161
  );
71
162
  if (!enabled_vt_names.includes("Show")) enabled_vt_names.push("Show");
163
+ if (!enabled_vt_names.includes("Edit")) enabled_vt_names.push("Edit");
164
+ if (!enabled_vt_names.includes("List")) enabled_vt_names.push("List");
165
+ if (!enabled_vt_names.includes("Filter")) enabled_vt_names.push("Filter");
72
166
  //const roles = await User.get_roles();
73
167
  const tableless = enabled_vt_names.filter(
74
- (vtnm) => vts[vtnm].tableless === true,
168
+ (vtnm) => vts[vtnm].tableless === true
75
169
  );
170
+ const roles = state.roles;
76
171
  const parameters = {
77
172
  type: "object",
78
173
  required: ["name", "viewpattern"],
@@ -82,7 +177,9 @@ a view generation mode. The tool call only requires high-level details to start
82
177
  type: "string",
83
178
  },
84
179
  viewpattern: {
85
- description: `The type of view to generate. Some of the view descriptions: ${enabled_vt_names.map((vtnm) => `${vtnm}: ${vts[vtnm].description}.`).join(" ")}`,
180
+ description: `The type of view to generate. Some of the view descriptions: ${enabled_vt_names
181
+ .map((vtnm) => `${vtnm}: ${vts[vtnm].description}.`)
182
+ .join(" ")}`,
86
183
  type: "string",
87
184
  enum: enabled_vt_names,
88
185
  },
@@ -97,7 +194,7 @@ a view generation mode. The tool call only requires high-level details to start
97
194
  description:
98
195
  "The minimum role needed to access the view. For views accessible only by admin, use 'admin', pages with min_role 'public' is publicly accessible and also available to all users",
99
196
  type: "string",
100
- enum: ["admin", "user", "public"],
197
+ enum: roles ? roles.map((r) => r.role) : ["admin", "user", "public"],
101
198
  },
102
199
  },
103
200
  };
@@ -122,25 +219,87 @@ a view generation mode. The tool call only requires high-level details to start
122
219
  : Table.findOne({ name: tool_call.input.table });
123
220
 
124
221
  const wfctx = { viewname: tool_call.input.name, table_id: table?.id };
125
- if (tool_call.input.viewpattern === "Show") {
222
+ const viewpattern = tool_call.input.viewpattern;
223
+ const builderModeByPattern = {
224
+ Show: "show",
225
+ Edit: "edit",
226
+ List: "listcolumns",
227
+ Filter: "filter",
228
+ };
229
+ const builderMode = builderModeByPattern[viewpattern];
230
+ if (builderMode) {
231
+ const extractText = (c) => {
232
+ if (typeof c === "string") return c;
233
+ if (Array.isArray(c)) {
234
+ const textPart = c.find(
235
+ (p) => p?.type === "text" || typeof p === "string"
236
+ );
237
+ return (
238
+ textPart?.text || (typeof textPart === "string" ? textPart : "")
239
+ );
240
+ }
241
+ return "";
242
+ };
243
+ const isToolResultMessage = (item) => {
244
+ if (!Array.isArray(item?.content)) return false;
245
+ return item.content.every((p) => p?.type === "tool_result");
246
+ };
126
247
  const promptFromChat = Array.isArray(chat)
127
- ? [...chat]
128
- .reverse()
129
- .find((item) => item?.role === "user" && item?.content)?.content
248
+ ? (() => {
249
+ const userMsgs = chat.filter(
250
+ (item) =>
251
+ item?.role === "user" &&
252
+ item?.content &&
253
+ !isToolResultMessage(item)
254
+ );
255
+ return userMsgs.length ? extractText(userMsgs[0].content) : "";
256
+ })()
130
257
  : "";
131
258
  const layoutPrompt = promptFromChat || tool_call.input.name || "";
132
259
  wfctx.layout = await builderGen.run(
133
260
  layoutPrompt,
134
- "show",
261
+ builderMode,
135
262
  table?.name,
136
- chat,
263
+ null,
264
+ chat
137
265
  );
138
- if (table) {
266
+ if (table && viewpattern !== "Filter") {
139
267
  const baseCfg = await initial_config_all_fields(false)({
140
268
  table_id: table.id,
141
269
  });
142
270
  if (baseCfg?.columns) wfctx.columns = baseCfg.columns;
143
271
  }
272
+ if (viewpattern === "Edit" && table) {
273
+ const layoutFieldNames = collectLayoutFieldNames(wfctx.layout);
274
+ const fields = table.fields || [];
275
+ const fixed = {};
276
+ for (const f of fields) {
277
+ if (f.primary_key || f.calculated) continue;
278
+ if (layoutFieldNames.has(f.name)) continue;
279
+ if (f.type === "Key" && f.reftable_name === "users") {
280
+ fixed[`preset_${f.name}`] = "LoggedIn";
281
+ fixed[`_block_${f.name}`] = true;
282
+ }
283
+ }
284
+ if (Object.keys(fixed).length > 0) wfctx.fixed = fixed;
285
+ wfctx.destination_type = "Back to referer";
286
+ }
287
+ if (viewpattern === "Filter") {
288
+ const filterFieldSegment = findFilterFieldSegment(wfctx.layout);
289
+ if (filterFieldSegment) {
290
+ const normalized = normalizeFilterField(filterFieldSegment);
291
+ wfctx.layout = normalized;
292
+ wfctx.columns = [toFilterColumn(normalized)];
293
+ }
294
+ }
295
+ }
296
+
297
+ if (
298
+ viewpattern === "Show" ||
299
+ viewpattern === "Edit" ||
300
+ viewpattern === "Filter"
301
+ ) {
302
+ // No extra configuration steps for these modes.
144
303
  } else {
145
304
  const flow = vt.configuration_workflow(req);
146
305
  let vt_prompt = "";
@@ -149,26 +308,64 @@ a view generation mode. The tool call only requires high-level details to start
149
308
  vt_prompt = vt.copilot_generate_view_prompt;
150
309
  else if (typeof vt.copilot_generate_view_prompt === "function")
151
310
  vt_prompt = await vt.copilot_generate_view_prompt(
152
- tool_call.input,
311
+ tool_call.input
153
312
  );
154
313
  }
155
314
 
315
+ const prefilledFields = new Set();
316
+ if (wfctx.layout !== undefined) prefilledFields.add("layout");
317
+ if (wfctx.columns !== undefined) prefilledFields.add("columns");
318
+
319
+ // For List views: pre-fill view_to_create with the best Edit view for the table
320
+ if (viewpattern === "List" && table) {
321
+ const candidateViews = await View.find_table_views_where(
322
+ table.id,
323
+ ({ state_fields, viewrow }) =>
324
+ viewrow.name !== tool_call.input.name &&
325
+ state_fields.every((sf) => !sf.required)
326
+ );
327
+ if (candidateViews.length > 0) {
328
+ const editView =
329
+ candidateViews.find((v) =>
330
+ v.name.toLowerCase().includes("edit")
331
+ ) || candidateViews[0];
332
+ wfctx.view_to_create =
333
+ editView.select_option?.name || editView.name;
334
+ wfctx.create_view_display = "Popup";
335
+ wfctx.create_view_location = "Top right";
336
+ prefilledFields.add("view_to_create");
337
+ prefilledFields.add("create_view_display");
338
+ prefilledFields.add("create_view_location");
339
+ }
340
+ }
341
+
156
342
  for (const step of flow.steps) {
343
+ if (typeof step.form !== "function") continue;
157
344
  const form = await step.form(wfctx);
158
345
  const properties = {};
159
346
  //TODO onlyWhen
160
347
  for (const field of form.fields) {
348
+ if (prefilledFields.has(field.name)) continue;
161
349
  //TODO showIf
162
350
  properties[field.name] = {
163
351
  description:
164
352
  field.copilot_description ||
165
- `${field.label}.${field.sublabel ? ` ${field.sublabel}` : ""}`,
353
+ `${field.label}.${
354
+ field.sublabel ? ` ${field.sublabel}` : ""
355
+ }`,
166
356
  ...fieldProperties(field),
167
357
  };
358
+ if (!properties[field.name].type) {
359
+ properties[field.name].type = "string";
360
+ }
168
361
  }
169
362
 
363
+ if (!Object.keys(properties).length) continue;
364
+
170
365
  const answer = await generate(
171
- `${vt_prompt ? vt_prompt + "\n\n" : ""}Now generate the ${step.name} details of the view by calling the generate_view_details tool`,
366
+ `${vt_prompt ? vt_prompt + "\n\n" : ""}Now generate the ${
367
+ step.name
368
+ } details of the view by calling the generate_view_details tool`,
172
369
  {
173
370
  tools: [
174
371
  {
@@ -189,22 +386,43 @@ a view generation mode. The tool call only requires high-level details to start
189
386
  name: "generate_view_details",
190
387
  },
191
388
  },
192
- },
389
+ }
193
390
  );
194
391
  const tc = answer.getToolCalls()[0];
392
+ await getState().functions.llm_add_message.run(
393
+ "tool_response",
394
+ { type: "text", value: "Details provided" },
395
+ { chat, tool_call: tc }
396
+ );
195
397
  Object.assign(wfctx, tc.input);
196
398
  }
197
399
  }
400
+ const roleName = tool_call.input.min_role || "public";
401
+ const rolesState = getState().roles;
402
+ const min_role = rolesState
403
+ ? (rolesState.find((r) => r.role === roleName) || { id: 100 }).id
404
+ : { admin: 1, public: 100, user: 80 }[roleName] ?? 100;
198
405
  const view = new View({
199
406
  name: tool_call.input.name,
200
407
  viewtemplate: tool_call.input.viewpattern,
201
408
  table,
202
409
  table_id: table?.id,
203
- min_role: { admin: 1, public: 100, user: 80 }[
204
- tool_call.input.min_role || "public"
205
- ],
410
+ min_role,
206
411
  configuration: wfctx,
207
412
  });
413
+ if (this.yoloMode) {
414
+ await this.userActions.build_copilot_view_gen({
415
+ wfctx,
416
+ name: tool_call.input.name,
417
+ viewpattern: tool_call.input.viewpattern,
418
+ table: tool_call.input.table,
419
+ min_role: tool_call.input.min_role,
420
+ });
421
+ return {
422
+ stop: true,
423
+ add_response: `View ${tool_call.input.name} created.`,
424
+ };
425
+ }
208
426
  const runres = await view.run({}, { req });
209
427
  return {
210
428
  stop: true,
@@ -212,7 +430,7 @@ a view generation mode. The tool call only requires high-level details to start
212
430
  pre(JSON.stringify(wfctx, null, 2)) +
213
431
  div(
214
432
  { style: { maxHeight: 800, maxWidth: 500, overflow: "scroll" } },
215
- runres,
433
+ runres
216
434
  ),
217
435
  add_user_action: {
218
436
  name: "build_copilot_view_gen",
@@ -550,11 +550,50 @@ class GenerateWorkflowSkill {
550
550
  ensureActionCatalog();
551
551
  }
552
552
 
553
+ static async configFields() {
554
+ return [
555
+ {
556
+ name: "context_vars",
557
+ label: "Initial context variables",
558
+ input_type: "code",
559
+ attributes: { mode: "application/json" },
560
+ sublabel:
561
+ 'JSON object of key-value pairs pre-loaded into the workflow context at startup (e.g. {"ELEVENLABS_API_KEY": "sk-..."}). Keys are available by name in every run_js_code step.',
562
+ },
563
+ ];
564
+ }
565
+
566
+ _parseContextVars() {
567
+ if (!this.context_vars) return null;
568
+ try {
569
+ const vars =
570
+ typeof this.context_vars === "string"
571
+ ? JSON.parse(this.context_vars)
572
+ : this.context_vars;
573
+ return Object.keys(vars).length ? vars : null;
574
+ } catch {
575
+ return null;
576
+ }
577
+ }
578
+
553
579
  async systemPrompt() {
554
- return await GenerateWorkflow.system_prompt();
580
+ const base = await GenerateWorkflow.system_prompt();
581
+ const vars = this._parseContextVars();
582
+ if (!vars) return base;
583
+ const keyList = Object.keys(vars).join(", ");
584
+ return (
585
+ base +
586
+ `\n\nThe following values are pre-loaded into the workflow context before the first step runs: ${keyList}. ` +
587
+ `Use them directly by name in run_js_code steps (e.g. \`${
588
+ Object.keys(vars)[0]
589
+ }\`) ` +
590
+ `or via the context object (e.g. \`context.${Object.keys(vars)[0]}\`). ` +
591
+ `Do not ask the user to supply these values — they are already available.`
592
+ );
555
593
  }
556
594
 
557
595
  get userActions() {
596
+ const context_vars = this._parseContextVars();
558
597
  return {
559
598
  async apply_copilot_workflow({ user, ...raw }) {
560
599
  const payload = ensureWorkflowHasSteps(normalizeWorkflowPayload(raw));
@@ -563,7 +602,11 @@ class GenerateWorkflowSkill {
563
602
  return {
564
603
  notify: `Cannot create workflow: ${analysis.blocking.join("; ")}`,
565
604
  };
566
- const result = await GenerateWorkflow.execute(payload, { user });
605
+ const result = await GenerateWorkflow.execute(
606
+ payload,
607
+ { user },
608
+ context_vars
609
+ );
567
610
  return {
568
611
  notify:
569
612
  result?.postExec ||
@@ -608,6 +651,13 @@ class GenerateWorkflowSkill {
608
651
  const canCreate =
609
652
  analysis.blocking.length === 0 &&
610
653
  preparedPayload.workflow_steps.length > 0;
654
+ if (this.yoloMode && canCreate) {
655
+ await this.userActions.apply_copilot_workflow(preparedPayload);
656
+ return {
657
+ stop: true,
658
+ add_response: `Workflow ${preparedPayload.workflow_name || "(unnamed)"} created.`,
659
+ };
660
+ }
611
661
  return {
612
662
  stop: true,
613
663
  add_response: `${issuesHtml}${previewHtml}`,
@@ -0,0 +1,12 @@
1
+ const viewname = "Saltcorn AppConstructor (experimental)";
2
+
3
+ const tool_choice = (tool_name) => ({
4
+ tool_choice: {
5
+ type: "function",
6
+ function: {
7
+ name: tool_name,
8
+ },
9
+ },
10
+ });
11
+
12
+ module.exports = { viewname, tool_choice };
@@ -0,0 +1,102 @@
1
+ const Field = require("@saltcorn/data/models/field");
2
+ const Table = require("@saltcorn/data/models/table");
3
+ const Form = require("@saltcorn/data/models/form");
4
+ const MetaData = require("@saltcorn/data/models/metadata");
5
+ const View = require("@saltcorn/data/models/view");
6
+ const Trigger = require("@saltcorn/data/models/trigger");
7
+ const { findType } = require("@saltcorn/data/models/discovery");
8
+ const { save_menu_items } = require("@saltcorn/data/models/config");
9
+ const db = require("@saltcorn/data/db");
10
+ const WorkflowRun = require("@saltcorn/data/models/workflow_run");
11
+ const {
12
+ localeDateTime,
13
+ renderForm,
14
+ mkTable,
15
+ post_delete_btn,
16
+ } = require("@saltcorn/markup");
17
+ const {
18
+ div,
19
+ script,
20
+ domReady,
21
+ pre,
22
+ code,
23
+ input,
24
+ h4,
25
+ style,
26
+ h5,
27
+ button,
28
+ text_attr,
29
+ i,
30
+ p,
31
+ span,
32
+ small,
33
+ form,
34
+ textarea,
35
+ } = require("@saltcorn/markup/tags");
36
+ const { getState } = require("@saltcorn/data/db/state");
37
+ const renderLayout = require("@saltcorn/markup/layout");
38
+ const { viewname } = require("./common");
39
+
40
+ const errorList = async (req) => {
41
+ const errs = await MetaData.find({
42
+ type: "CopilotConstructMgr",
43
+ name: "error",
44
+ });
45
+ if (errs.length) {
46
+ return div(
47
+ { class: "mt-2" },
48
+ mkTable(
49
+ [
50
+ { label: "Status", key: (m) => m.body.status },
51
+ {
52
+ label: "Error",
53
+ key: (m) => pre(JSON.stringify(m.body.error, null, 2)),
54
+ },
55
+ {
56
+ label: "Delete",
57
+ key: (r) =>
58
+ button(
59
+ {
60
+ class: "btn btn-outline-danger btn-sm",
61
+ onclick: `view_post("${viewname}", "del_err", {id:${r.id}})`,
62
+ },
63
+ i({ class: "fas fa-trash-alt" }),
64
+ ),
65
+ },
66
+ ],
67
+ errs,
68
+ ),
69
+ button(
70
+ {
71
+ class: "btn btn-outline-danger",
72
+ onclick: `view_post("${viewname}", "del_all_errs")`,
73
+ },
74
+ "Delete all",
75
+ ),
76
+ );
77
+ } else {
78
+ return div({ class: "mt-2" }, p("No errors"));
79
+ }
80
+ };
81
+
82
+ const del_err = async (table_id, viewname, config, body, { req, res }) => {
83
+ const r = await MetaData.findOne({
84
+ id: body.id,
85
+ });
86
+
87
+ if (!r) throw new Error("Error not found");
88
+ await r.delete();
89
+ return { json: { reload_page: true } };
90
+ };
91
+ const del_all_errs = async (table_id, viewname, config, body, { req, res }) => {
92
+ const rs = await MetaData.find({
93
+ type: "CopilotConstructMgr",
94
+ name: "error",
95
+ });
96
+ for (const r of rs) await r.delete();
97
+ return { json: { reload_page: true } };
98
+ };
99
+
100
+ const error_routes = { del_err, del_all_errs };
101
+
102
+ module.exports = { errorList, error_routes };