@saltcorn/copilot 0.7.5 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,10 @@
1
1
  const Table = require("@saltcorn/data/models/table");
2
2
  const View = require("@saltcorn/data/models/view");
3
3
  const { fieldProperties } = require("../common");
4
- const { initial_config_all_fields } = require("@saltcorn/data/plugin-helper");
4
+ const {
5
+ initial_config_all_fields,
6
+ build_schema_data,
7
+ } = require("@saltcorn/data/plugin-helper");
5
8
  const { getState } = require("@saltcorn/data/db/state");
6
9
  const {
7
10
  div,
@@ -14,6 +17,95 @@ const {
14
17
  text_attr,
15
18
  } = require("@saltcorn/markup/tags");
16
19
  const builderGen = require("../builder-gen");
20
+ const {
21
+ RELATION_PATH_DOC,
22
+ GET_RELATION_PATHS_FUNCTION,
23
+ getRelationPathsForPairs,
24
+ } = require("../relation-paths");
25
+
26
+ const collectLayoutFieldNames = (segment, out = new Set()) => {
27
+ if (!segment || typeof segment !== "object") return out;
28
+ if (Array.isArray(segment)) {
29
+ segment.forEach((s) => collectLayoutFieldNames(s, out));
30
+ return out;
31
+ }
32
+ if (segment.type === "field" && segment.field_name)
33
+ out.add(segment.field_name);
34
+ if (segment.above) collectLayoutFieldNames(segment.above, out);
35
+ if (segment.besides) collectLayoutFieldNames(segment.besides, out);
36
+ if (segment.contents) collectLayoutFieldNames(segment.contents, out);
37
+ if (Array.isArray(segment.tabs))
38
+ segment.tabs.forEach((t) => collectLayoutFieldNames(t?.contents, out));
39
+ return out;
40
+ };
41
+
42
+ const findFilterFieldSegment = (segment) => {
43
+ if (!segment || typeof segment !== "object") return null;
44
+ if (segment.type === "field") return segment;
45
+ if (segment.type === "dropdown_filter" || segment.type === "toggle_filter") {
46
+ return { field_name: segment.field_name, fieldview: "edit" };
47
+ }
48
+ if (Array.isArray(segment.above)) {
49
+ for (const item of segment.above) {
50
+ const found = findFilterFieldSegment(item);
51
+ if (found) return found;
52
+ }
53
+ }
54
+ if (Array.isArray(segment.besides)) {
55
+ for (const item of segment.besides) {
56
+ const found = findFilterFieldSegment(item);
57
+ if (found) return found;
58
+ }
59
+ }
60
+ if (segment.contents) {
61
+ if (Array.isArray(segment.contents)) {
62
+ for (const item of segment.contents) {
63
+ const found = findFilterFieldSegment(item);
64
+ if (found) return found;
65
+ }
66
+ } else {
67
+ const found = findFilterFieldSegment(segment.contents);
68
+ if (found) return found;
69
+ }
70
+ }
71
+ if (Array.isArray(segment.tabs)) {
72
+ for (const tab of segment.tabs) {
73
+ if (tab?.contents) {
74
+ const found = findFilterFieldSegment(tab.contents);
75
+ if (found) return found;
76
+ }
77
+ }
78
+ }
79
+ if (Array.isArray(segment.contents) && Array.isArray(segment.contents[0])) {
80
+ for (const row of segment.contents) {
81
+ if (Array.isArray(row)) {
82
+ for (const cell of row) {
83
+ const found = findFilterFieldSegment(cell);
84
+ if (found) return found;
85
+ }
86
+ }
87
+ }
88
+ }
89
+ return null;
90
+ };
91
+
92
+ const normalizeFilterField = (segment) => ({
93
+ type: "field",
94
+ field_name: segment.field_name,
95
+ fieldview: segment.fieldview || "edit",
96
+ textStyle: segment.textStyle || "",
97
+ block: segment.block ?? false,
98
+ configuration: segment.configuration || {},
99
+ });
100
+
101
+ const toFilterColumn = (segment) => ({
102
+ type: "Field",
103
+ field_name: segment.field_name,
104
+ fieldview: segment.fieldview || "edit",
105
+ textStyle: segment.textStyle || "",
106
+ block: segment.block ?? false,
107
+ configuration: segment.configuration || {},
108
+ });
17
109
 
18
110
  class GenerateViewSkill {
19
111
  static skill_name = "Generate View";
@@ -27,12 +119,35 @@ class GenerateViewSkill {
27
119
  }
28
120
 
29
121
  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.`;
122
+ return (
123
+ `If the user asks to generate a view, use the generate_view tool but ONLY if the view does not already exist. ` +
124
+ `If a view with that name already exists, do NOT call generate_view — doing so will create a duplicate. Instead follow the modification sequence below.\n` +
125
+ `The Edit viewtemplate serves both create (no id in state) and edit (id in state) — one view covers both.\n\n` +
126
+ `**Modifying an existing view — required sequence:**\n` +
127
+ `(1) Call get_view_config to fetch the current configuration.\n` +
128
+ `(2) Only if you are adding view_link columns or embedded view (type "view") segments: call get_relation_paths once with all the source_table/target_view pairs you need. For changes that don't involve linking or embedding views (e.g. adding a field, changing a label), skip this step.\n` +
129
+ `(3) Write out the complete updated configuration JSON in full — every key from the existing config must be present, with only your targeted changes merged in.\n` +
130
+ `(4) Call apply_view_config with that complete object. NEVER call apply_view_config before step (3) is finished. NEVER call it with only the name or a partial object — the configuration field is mandatory and must be the full merged result from step (3). Calling apply_view_config without a complete configuration is an error.\n\n` +
131
+ `**Generating a new view that contains view_links or embedded views:**\n` +
132
+ `Call get_relation_paths once with all source_table/target_view pairs you need before constructing the layout.\n\n` +
133
+ `**Embedded view segment format (for Show layouts):**\n` +
134
+ ` { "type": "view", "view": "<viewName>", "name": "<viewName>", "relation": "<from get_relation_paths>" }\n` +
135
+ `Do NOT use blank text segments as placeholders — always use a real view segment with a relation string from get_relation_paths.\n\n` +
136
+ RELATION_PATH_DOC
137
+ );
32
138
  }
33
139
 
34
140
  get userActions() {
35
141
  return {
142
+ async build_copilot_view_update({ name, configuration }) {
143
+ const existingView = View.findOne({ name });
144
+ if (!existingView) return { error: `View "${name}" not found` };
145
+ await View.update({ configuration }, existingView.id);
146
+ setTimeout(() => getState().refresh_views(), 200);
147
+ return {
148
+ notify: `View updated: <a target="_blank" href="/view/${name}">${name}</a>`,
149
+ };
150
+ },
36
151
  async build_copilot_view_gen({
37
152
  wfctx,
38
153
  name,
@@ -40,16 +155,29 @@ a view generation mode. The tool call only requires high-level details to start
40
155
  table,
41
156
  min_role,
42
157
  }) {
43
- const normalizedRole = min_role || "public";
158
+ const existing = View.findOne({ name });
159
+ if (existing)
160
+ return {
161
+ error: `View "${name}" already exists. Use get_view_config and apply_view_config to update it.`,
162
+ };
44
163
  const tableRow = table ? Table.findOne({ name: table }) : null;
164
+ const roleName = typeof min_role === "number" ? null : (min_role || "public");
165
+ const resolvedRole =
166
+ typeof min_role === "number"
167
+ ? min_role
168
+ : ((getState().roles || []).find((r) => r.role === roleName) || { id: 100 }).id;
45
169
  await View.create({
46
170
  name,
47
171
  viewtemplate: viewpattern,
48
172
  table_id: tableRow?.id,
49
173
  table: tableRow,
50
- min_role: { admin: 1, public: 100, user: 80 }[normalizedRole],
174
+ min_role: resolvedRole,
51
175
  configuration: wfctx,
52
176
  });
177
+ const vt = getState().viewtemplates[viewpattern];
178
+ if (vt?.copilot_post_create) {
179
+ await vt.copilot_post_create({ name, configuration: wfctx });
180
+ }
53
181
  setTimeout(() => getState().refresh_views(), 200);
54
182
  return {
55
183
  notify: `View saved: <a target="_blank" href="/view/${name}">${name}</a>`,
@@ -66,13 +194,17 @@ a view generation mode. The tool call only requires high-level details to start
66
194
  const enabled_vt_names = all_vt_names.filter(
67
195
  (vtnm) =>
68
196
  vts[vtnm].enable_copilot_viewgen ||
69
- vts[vtnm].copilot_generate_view_prompt,
197
+ vts[vtnm].copilot_generate_view_prompt
70
198
  );
71
199
  if (!enabled_vt_names.includes("Show")) enabled_vt_names.push("Show");
200
+ if (!enabled_vt_names.includes("Edit")) enabled_vt_names.push("Edit");
201
+ if (!enabled_vt_names.includes("List")) enabled_vt_names.push("List");
202
+ if (!enabled_vt_names.includes("Filter")) enabled_vt_names.push("Filter");
72
203
  //const roles = await User.get_roles();
73
204
  const tableless = enabled_vt_names.filter(
74
- (vtnm) => vts[vtnm].tableless === true,
205
+ (vtnm) => vts[vtnm].tableless === true
75
206
  );
207
+ const roles = state.roles;
76
208
  const parameters = {
77
209
  type: "object",
78
210
  required: ["name", "viewpattern"],
@@ -82,7 +214,9 @@ a view generation mode. The tool call only requires high-level details to start
82
214
  type: "string",
83
215
  },
84
216
  viewpattern: {
85
- description: `The type of view to generate. Some of the view descriptions: ${enabled_vt_names.map((vtnm) => `${vtnm}: ${vts[vtnm].description}.`).join(" ")}`,
217
+ description: `The type of view to generate. Some of the view descriptions: ${enabled_vt_names
218
+ .map((vtnm) => `${vtnm}: ${vts[vtnm].description}.`)
219
+ .join(" ")}`,
86
220
  type: "string",
87
221
  enum: enabled_vt_names,
88
222
  },
@@ -97,17 +231,17 @@ a view generation mode. The tool call only requires high-level details to start
97
231
  description:
98
232
  "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
233
  type: "string",
100
- enum: ["admin", "user", "public"],
234
+ enum: roles ? roles.map((r) => r.role) : ["admin", "user", "public"],
101
235
  },
102
236
  },
103
237
  };
104
238
 
105
- return {
239
+ const generateViewTool = {
106
240
  type: "function",
107
241
  function: {
108
242
  name: "generate_view",
109
243
  description:
110
- "Generate a view by supplying high-level details. This will trigger a view generation sequence",
244
+ "Generate a NEW view by supplying high-level details. Only call this for views that do not yet exist — if the view already exists, use get_view_config + apply_view_config instead.",
111
245
  parameters,
112
246
  },
113
247
  process: async (input) => {
@@ -122,25 +256,101 @@ a view generation mode. The tool call only requires high-level details to start
122
256
  : Table.findOne({ name: tool_call.input.table });
123
257
 
124
258
  const wfctx = { viewname: tool_call.input.name, table_id: table?.id };
125
- if (tool_call.input.viewpattern === "Show") {
259
+ const viewpattern = tool_call.input.viewpattern;
260
+ const builderModeByPattern = {
261
+ Show: "show",
262
+ Edit: "edit",
263
+ List: "listcolumns",
264
+ Filter: "filter",
265
+ };
266
+ const builderMode = builderModeByPattern[viewpattern];
267
+ if (builderMode) {
268
+ const extractText = (c) => {
269
+ if (typeof c === "string") return c;
270
+ if (Array.isArray(c)) {
271
+ const textPart = c.find(
272
+ (p) => p?.type === "text" || typeof p === "string"
273
+ );
274
+ return (
275
+ textPart?.text || (typeof textPart === "string" ? textPart : "")
276
+ );
277
+ }
278
+ return "";
279
+ };
280
+ const isToolResultMessage = (item) => {
281
+ if (!Array.isArray(item?.content)) return false;
282
+ return item.content.every((p) => p?.type === "tool_result");
283
+ };
126
284
  const promptFromChat = Array.isArray(chat)
127
- ? [...chat]
128
- .reverse()
129
- .find((item) => item?.role === "user" && item?.content)?.content
285
+ ? (() => {
286
+ const userMsgs = chat.filter(
287
+ (item) =>
288
+ item?.role === "user" &&
289
+ item?.content &&
290
+ !isToolResultMessage(item)
291
+ );
292
+ return userMsgs.length ? extractText(userMsgs[0].content) : "";
293
+ })()
130
294
  : "";
131
295
  const layoutPrompt = promptFromChat || tool_call.input.name || "";
132
296
  wfctx.layout = await builderGen.run(
133
297
  layoutPrompt,
134
- "show",
298
+ builderMode,
135
299
  table?.name,
136
- chat,
300
+ null,
301
+ chat
137
302
  );
138
- if (table) {
139
- const baseCfg = await initial_config_all_fields(false)({
303
+ if (table && viewpattern !== "Filter") {
304
+ // isEdit=true: FK fields get Field+select columns; false gives JoinField (display-only)
305
+ const isEditView = viewpattern === "Edit";
306
+ const baseCfg = await initial_config_all_fields(isEditView)({
140
307
  table_id: table.id,
141
308
  });
142
309
  if (baseCfg?.columns) wfctx.columns = baseCfg.columns;
143
310
  }
311
+ if (viewpattern === "Edit" && table) {
312
+ const layoutFieldNames = collectLayoutFieldNames(wfctx.layout);
313
+ const fields = table.fields || [];
314
+ const fixed = {};
315
+ const usersFkColumnsToAdd = [];
316
+ for (const f of fields) {
317
+ if (f.primary_key || f.calculated) continue;
318
+ if (f.type === "Key" && f.reftable_name === "users") {
319
+ if (layoutFieldNames.has(f.name)) {
320
+ // Explicitly placed in layout — add a select column so getForm renders it
321
+ usersFkColumnsToAdd.push({
322
+ field_name: f.name,
323
+ type: "Field",
324
+ fieldview: "select",
325
+ state_field: true,
326
+ });
327
+ } else {
328
+ fixed[`preset_${f.name}`] = "LoggedIn";
329
+ fixed[`_block_${f.name}`] = true;
330
+ }
331
+ }
332
+ }
333
+ if (usersFkColumnsToAdd.length > 0)
334
+ wfctx.columns = [...(wfctx.columns || []), ...usersFkColumnsToAdd];
335
+ if (Object.keys(fixed).length > 0) wfctx.fixed = fixed;
336
+ wfctx.destination_type = "Back to referer";
337
+ }
338
+ if (viewpattern === "Filter") {
339
+ const filterFieldSegment = findFilterFieldSegment(wfctx.layout);
340
+ if (filterFieldSegment) {
341
+ const normalized = normalizeFilterField(filterFieldSegment);
342
+ wfctx.layout = normalized;
343
+ wfctx.columns = [toFilterColumn(normalized)];
344
+ }
345
+ }
346
+ }
347
+
348
+ if (
349
+ viewpattern === "Show" ||
350
+ viewpattern === "Edit" ||
351
+ viewpattern === "Filter"
352
+ ) {
353
+ // No extra configuration steps for these modes.
144
354
  } else {
145
355
  const flow = vt.configuration_workflow(req);
146
356
  let vt_prompt = "";
@@ -149,26 +359,64 @@ a view generation mode. The tool call only requires high-level details to start
149
359
  vt_prompt = vt.copilot_generate_view_prompt;
150
360
  else if (typeof vt.copilot_generate_view_prompt === "function")
151
361
  vt_prompt = await vt.copilot_generate_view_prompt(
152
- tool_call.input,
362
+ tool_call.input
153
363
  );
154
364
  }
155
365
 
366
+ const prefilledFields = new Set();
367
+ if (wfctx.layout !== undefined) prefilledFields.add("layout");
368
+ if (wfctx.columns !== undefined) prefilledFields.add("columns");
369
+
370
+ // For List views: pre-fill view_to_create with the best Edit view for the table
371
+ if (viewpattern === "List" && table) {
372
+ const candidateViews = await View.find_table_views_where(
373
+ table.id,
374
+ ({ state_fields, viewrow }) =>
375
+ viewrow.name !== tool_call.input.name &&
376
+ state_fields.every((sf) => !sf.required)
377
+ );
378
+ if (candidateViews.length > 0) {
379
+ const editView =
380
+ candidateViews.find((v) =>
381
+ v.name.toLowerCase().includes("edit")
382
+ ) || candidateViews[0];
383
+ wfctx.view_to_create =
384
+ editView.select_option?.name || editView.name;
385
+ wfctx.create_view_display = "Popup";
386
+ wfctx.create_view_location = "Top right";
387
+ prefilledFields.add("view_to_create");
388
+ prefilledFields.add("create_view_display");
389
+ prefilledFields.add("create_view_location");
390
+ }
391
+ }
392
+
156
393
  for (const step of flow.steps) {
394
+ if (typeof step.form !== "function") continue;
157
395
  const form = await step.form(wfctx);
158
396
  const properties = {};
159
397
  //TODO onlyWhen
160
398
  for (const field of form.fields) {
399
+ if (prefilledFields.has(field.name)) continue;
161
400
  //TODO showIf
162
401
  properties[field.name] = {
163
402
  description:
164
403
  field.copilot_description ||
165
- `${field.label}.${field.sublabel ? ` ${field.sublabel}` : ""}`,
404
+ `${field.label}.${
405
+ field.sublabel ? ` ${field.sublabel}` : ""
406
+ }`,
166
407
  ...fieldProperties(field),
167
408
  };
409
+ if (!properties[field.name].type) {
410
+ properties[field.name].type = "string";
411
+ }
168
412
  }
169
413
 
414
+ if (!Object.keys(properties).length) continue;
415
+
170
416
  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`,
417
+ `${vt_prompt ? vt_prompt + "\n\n" : ""}Now generate the ${
418
+ step.name
419
+ } details of the view by calling the generate_view_details tool`,
172
420
  {
173
421
  tools: [
174
422
  {
@@ -189,22 +437,55 @@ a view generation mode. The tool call only requires high-level details to start
189
437
  name: "generate_view_details",
190
438
  },
191
439
  },
192
- },
440
+ }
193
441
  );
194
- const tc = answer.getToolCalls()[0];
195
- Object.assign(wfctx, tc.input);
442
+ const tc =
443
+ typeof answer?.getToolCalls === "function"
444
+ ? answer.getToolCalls()[0]
445
+ : null;
446
+ if (tc) {
447
+ await getState().functions.llm_add_message.run(
448
+ "tool_response",
449
+ { type: "text", value: "Details provided" },
450
+ { chat, tool_call: tc }
451
+ );
452
+ Object.assign(wfctx, tc.input);
453
+ }
196
454
  }
197
455
  }
456
+ const roleName = tool_call.input.min_role || "public";
457
+ const rolesState = getState().roles;
458
+ const min_role = rolesState
459
+ ? (rolesState.find((r) => r.role === roleName) || { id: 100 }).id
460
+ : { admin: 1, public: 100, user: 80 }[roleName] ?? 100;
461
+ const existingView = View.findOne({ name: tool_call.input.name });
462
+ if (existingView) {
463
+ return {
464
+ stop: true,
465
+ add_response: `Error: view "${tool_call.input.name}" already exists. Do NOT call generate_view again — use get_view_config to inspect the current configuration and apply_view_config to update it.`,
466
+ };
467
+ }
198
468
  const view = new View({
199
469
  name: tool_call.input.name,
200
470
  viewtemplate: tool_call.input.viewpattern,
201
471
  table,
202
472
  table_id: table?.id,
203
- min_role: { admin: 1, public: 100, user: 80 }[
204
- tool_call.input.min_role || "public"
205
- ],
473
+ min_role,
206
474
  configuration: wfctx,
207
475
  });
476
+ if (this.yoloMode) {
477
+ await this.userActions.build_copilot_view_gen({
478
+ wfctx,
479
+ name: tool_call.input.name,
480
+ viewpattern: tool_call.input.viewpattern,
481
+ table: tool_call.input.table,
482
+ min_role: tool_call.input.min_role,
483
+ });
484
+ return {
485
+ stop: true,
486
+ add_response: `View ${tool_call.input.name} created.`,
487
+ };
488
+ }
208
489
  const runres = await view.run({}, { req });
209
490
  return {
210
491
  stop: true,
@@ -212,7 +493,7 @@ a view generation mode. The tool call only requires high-level details to start
212
493
  pre(JSON.stringify(wfctx, null, 2)) +
213
494
  div(
214
495
  { style: { maxHeight: 800, maxWidth: 500, overflow: "scroll" } },
215
- runres,
496
+ runres
216
497
  ),
217
498
  add_user_action: {
218
499
  name: "build_copilot_view_gen",
@@ -223,6 +504,127 @@ a view generation mode. The tool call only requires high-level details to start
223
504
  };
224
505
  },
225
506
  };
507
+
508
+ const getViewConfigTool = {
509
+ type: "function",
510
+ function: {
511
+ name: "get_view_config",
512
+ description:
513
+ "Retrieve the current configuration of an existing view. " +
514
+ "Call this first to inspect the layout before calling apply_view_config to save changes. " +
515
+ "Returns the full configuration JSON and the viewtemplate name.",
516
+ parameters: {
517
+ type: "object",
518
+ required: ["name"],
519
+ properties: {
520
+ name: {
521
+ description: "The name of the existing view to inspect.",
522
+ type: "string",
523
+ },
524
+ },
525
+ },
526
+ },
527
+ process: async ({ name }) => {
528
+ const existingView = View.findOne({ name });
529
+ if (!existingView)
530
+ return `View "${name}" not found. Use generate_view to create a new view instead.`;
531
+ return (
532
+ `Current configuration of view "${name}" (viewtemplate: ${existingView.viewtemplate}):\n` +
533
+ JSON.stringify(existingView.configuration, null, 2)
534
+ );
535
+ },
536
+ };
537
+
538
+ const applyViewConfigTool = {
539
+ type: "function",
540
+ function: {
541
+ name: "apply_view_config",
542
+ description:
543
+ "Save an updated configuration to an existing view. " +
544
+ "STRICT PRECONDITION: you must have already called get_view_config AND written out the complete merged configuration JSON before calling this tool. " +
545
+ "Do NOT call this tool as a placeholder or before the configuration is fully constructed. " +
546
+ "Calling this tool without a complete configuration object is always wrong and will fail.",
547
+ parameters: {
548
+ type: "object",
549
+ required: ["name", "configuration"],
550
+ properties: {
551
+ name: {
552
+ description: "The name of the existing view to update.",
553
+ type: "string",
554
+ },
555
+ configuration: {
556
+ type: "object",
557
+ description:
558
+ "REQUIRED. The complete updated configuration object — every key from the existing config preserved, with only your changes merged in. " +
559
+ "You MUST have the full object written out before calling this tool. " +
560
+ "Passing null, an empty object, or a partial object (e.g. only the name) is always wrong and will return an error.",
561
+ },
562
+ },
563
+ },
564
+ },
565
+ process: async ({ name, configuration }) => {
566
+ const existingView = View.findOne({ name });
567
+ if (!existingView) return `View "${name}" not found.`;
568
+ if (!configuration || typeof configuration !== "object")
569
+ return (
570
+ `ERROR: configuration is missing. ` +
571
+ `You must call get_view_config first, merge your changes into the full existing configuration, then call apply_view_config again with the complete configuration object.`
572
+ );
573
+ return { name, configuration, view_id: existingView.id };
574
+ },
575
+ postProcess: async ({ tool_call, req }) => {
576
+ const { name, configuration } = tool_call.input;
577
+ const existingView = View.findOne({ name });
578
+ if (!existingView)
579
+ return { stop: true, add_response: `View "${name}" not found.` };
580
+ if (!configuration || typeof configuration !== "object")
581
+ return {
582
+ stop: true,
583
+ add_response:
584
+ `apply_view_config called for "${name}" without a configuration object. ` +
585
+ `Call get_view_config first, merge your changes into the full existing configuration, then call apply_view_config again with the complete configuration.`,
586
+ };
587
+ const cfg = configuration;
588
+
589
+ if (this.yoloMode) {
590
+ await View.update({ configuration: cfg }, existingView.id);
591
+ setTimeout(() => getState().refresh_views(), 200);
592
+ return { stop: true, add_response: `View ${name} updated.` };
593
+ }
594
+ return {
595
+ stop: true,
596
+ add_response: pre(JSON.stringify(cfg, null, 2)),
597
+ add_user_action: {
598
+ name: "build_copilot_view_update",
599
+ type: "button",
600
+ label: "Save updated view " + name,
601
+ input: { name, configuration: cfg },
602
+ },
603
+ };
604
+ },
605
+ };
606
+
607
+ const getRelationPathsTool = {
608
+ type: "function",
609
+ function: GET_RELATION_PATHS_FUNCTION,
610
+ process: async ({ pairs }) => {
611
+ const schemaData = await build_schema_data();
612
+ const sections = getRelationPathsForPairs(pairs || [], schemaData);
613
+ return (
614
+ sections.join("\n\n") +
615
+ `\n\nFor each pair, set the "relation" property to one of the strings listed above.\n` +
616
+ `Pick by type: ChildList = multiple child rows, ParentShow = single parent, OneToOneShow = unique child. ` +
617
+ `If multiple paths of the same type exist, choose the one whose FK field name best matches the task. Prefer shorter paths.`
618
+ );
619
+ },
620
+ };
621
+
622
+ return [
623
+ generateViewTool,
624
+ getViewConfigTool,
625
+ applyViewConfigTool,
626
+ getRelationPathsTool,
627
+ ];
226
628
  };
227
629
  }
228
630
 
@@ -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}`,