@saltcorn/copilot 0.7.0 → 0.7.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.
@@ -28,7 +28,7 @@ class GeneratePage {
28
28
  let namedescription = `The name of the page, this should be a short name which is part of the url. `;
29
29
  if (allPageNames.length) {
30
30
  namedescription += `These are the names of the exising pages: ${allPageNames.join(
31
- ", "
31
+ ", ",
32
32
  )}. Do not pick a name that is identical but follow the same naming convention.`;
33
33
  }
34
34
  const roles = await User.get_roles();
@@ -65,7 +65,7 @@ class GeneratePage {
65
65
  };
66
66
  }
67
67
  static async system_prompt() {
68
- return `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
69
  }
70
70
  static async follow_on_generate({ name, page_type }) {
71
71
  if (page_type === "Marketing page") {
@@ -272,7 +272,7 @@ class GeneratePage {
272
272
  }
273
273
  if (segment.type === "container") {
274
274
  const { customStyle, style, display, overflow } = splitContainerStyle(
275
- segment.style
275
+ segment.style,
276
276
  );
277
277
  return {
278
278
  ...segment,
@@ -310,17 +310,17 @@ class GeneratePage {
310
310
  JSON.stringify(
311
311
  GeneratePage.walk_response(JSON.parse(contents)),
312
312
  null,
313
- 2
314
- )
315
- )
316
- )
313
+ 2,
314
+ ),
315
+ ),
316
+ ),
317
317
  )
318
318
  );
319
319
  }
320
320
  static async execute(
321
321
  { name, title, description, min_role, page_type },
322
322
  req,
323
- contents
323
+ contents,
324
324
  ) {
325
325
  console.log("execute", name, contents);
326
326
  const roles = await User.get_roles();
@@ -341,12 +341,12 @@ class GeneratePage {
341
341
  "Page created. " +
342
342
  a(
343
343
  { target: "_blank", href: `/page/${name}`, class: "me-1" },
344
- "Go to page"
344
+ "Go to page",
345
345
  ) +
346
346
  " | " +
347
347
  a(
348
348
  { target: "_blank", href: `/pageedit/edit/${name}`, class: "ms-1" },
349
- "Configure page"
349
+ "Configure page",
350
350
  ),
351
351
  };
352
352
  }
@@ -13,7 +13,7 @@ class GenerateTables {
13
13
  static function_name = "generate_tables";
14
14
  static description = "Generate database tables";
15
15
 
16
- static async json_schema() {
16
+ static json_schema() {
17
17
  const types = Object.values(getState().types);
18
18
  const fieldTypeCfg = types.map((ty) => {
19
19
  const properties = {
@@ -175,9 +175,14 @@ class GenerateTables {
175
175
 
176
176
  static process_tables(tables) {
177
177
  return tables.map((table) => {
178
+ const sanitizedFields = Array.isArray(table.fields)
179
+ ? table.fields.filter(
180
+ (f) => (f?.name || "").toLowerCase() !== "id"
181
+ )
182
+ : [];
178
183
  return new Table({
179
184
  name: table.table_name,
180
- fields: table.fields.map((f) => {
185
+ fields: sanitizedFields.map((f) => {
181
186
  const { data_type, reference_table, ...attributes } =
182
187
  f.type_and_configuration;
183
188
  let type = data_type;
@@ -0,0 +1,185 @@
1
+ const GenerateTables = require("../actions/generate-tables");
2
+
3
+ const normalizeTablesPayload = (rawPayload) => {
4
+ if (!rawPayload) return { tables: [] };
5
+ let payload = rawPayload;
6
+ if (typeof payload === "string") {
7
+ try {
8
+ payload = JSON.parse(payload);
9
+ } catch (e) {
10
+ console.error("Failed to parse generate_tables payload", e);
11
+ return { tables: [] };
12
+ }
13
+ }
14
+ if (typeof payload !== "object") return { tables: [] };
15
+ const normalized = { ...payload };
16
+ normalized.tables = Array.isArray(normalized.tables)
17
+ ? normalized.tables.filter(Boolean).map((table) => ({
18
+ ...table,
19
+ fields: Array.isArray(table?.fields)
20
+ ? table.fields.filter(Boolean).map((field) => ({
21
+ ...field,
22
+ type_and_configuration: field?.type_and_configuration || {},
23
+ }))
24
+ : [],
25
+ }))
26
+ : [];
27
+ return normalized;
28
+ };
29
+
30
+ const summarizeTables = (tables) =>
31
+ tables.map((table, idx) => {
32
+ const fields = (table.fields || []).slice(0, 5);
33
+ const fieldSummary = fields
34
+ .map((field) => {
35
+ const fname = field?.name || "(missing name)";
36
+ const ftype = field?.type_and_configuration?.data_type || "Unknown";
37
+ return `${fname}:${ftype}`;
38
+ })
39
+ .join(", ");
40
+ const ellipsis = table.fields.length > fields.length ? "..." : "";
41
+ return `${idx + 1}. ${table.table_name || "(missing name)"} – ${
42
+ table.fields.length
43
+ } field(s)${fieldSummary ? ` (${fieldSummary}${ellipsis})` : ""}`;
44
+ });
45
+
46
+ const collectTableWarnings = (tables) => {
47
+ const warnings = [];
48
+ const seenTables = new Set();
49
+ tables.forEach((table, tableIdx) => {
50
+ const tableLabel = table.table_name || `Table #${tableIdx + 1}`;
51
+ if (!table.table_name)
52
+ warnings.push(`${tableLabel} is missing a table_name.`);
53
+ else if (seenTables.has(table.table_name))
54
+ warnings.push(`Duplicate table name "${table.table_name}".`);
55
+ else seenTables.add(table.table_name);
56
+
57
+ if (!Array.isArray(table.fields) || table.fields.length === 0)
58
+ warnings.push(`${tableLabel} does not define any fields.`);
59
+
60
+ const fieldNames = new Set();
61
+ (table.fields || []).forEach((field, fieldIdx) => {
62
+ const fieldLabel = field?.name || `Field #${fieldIdx + 1}`;
63
+ if (!field?.name)
64
+ warnings.push(`${tableLabel} has a field without a name.`);
65
+ else if (fieldNames.has(field.name))
66
+ warnings.push(`${tableLabel} repeats the field name "${field.name}".`);
67
+ else fieldNames.add(field.name);
68
+
69
+ if (!field?.type_and_configuration?.data_type)
70
+ warnings.push(
71
+ `${tableLabel}.${fieldLabel} must include type_and_configuration.data_type.`
72
+ );
73
+
74
+ if ((field?.name || "").toLowerCase() === "id")
75
+ warnings.push(
76
+ `${tableLabel}.${fieldLabel} should be omitted because every table already has an auto-increment id.`
77
+ );
78
+ });
79
+ });
80
+ return warnings;
81
+ };
82
+
83
+ const payloadFromToolCall = (tool_call) => {
84
+ if (!tool_call) return { tables: [] };
85
+ if (tool_call.input) return normalizeTablesPayload(tool_call.input);
86
+ if (tool_call.function?.arguments)
87
+ return normalizeTablesPayload(tool_call.function.arguments);
88
+ return { tables: [] };
89
+ };
90
+
91
+ class GenerateTablesSkill {
92
+ static skill_name = "Database design";
93
+
94
+ get skill_label() {
95
+ return "Database Design";
96
+ }
97
+
98
+ constructor(cfg) {
99
+ console.log("GenerateTablesSkill.constructor called", { cfg });
100
+ Object.assign(this, cfg);
101
+ }
102
+
103
+ async systemPrompt() {
104
+ console.log("GenerateTablesSkill.systemPrompt called");
105
+ return await GenerateTables.system_prompt();
106
+ }
107
+
108
+ get userActions() {
109
+ console.log("GenerateTablesSkill.userActions getter accessed");
110
+ return {
111
+ 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
+ if (!tables?.length) return { notify: "Nothing to create." };
117
+ await GenerateTables.execute({ tables }, { user });
118
+ return {
119
+ notify: `Created tables: ${tables.map((t) => t.table_name).join(", ")}`,
120
+ };
121
+ },
122
+ };
123
+ }
124
+
125
+ provideTools = () => {
126
+ console.log("GenerateTablesSkill.provideTools called");
127
+ const parameters = GenerateTables.json_schema();
128
+ return {
129
+ type: "function",
130
+ process: async (input) => {
131
+ const payload = normalizeTablesPayload(input);
132
+ 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}`);
138
+ const warnings = collectTableWarnings(tables);
139
+ const warningLines = warnings.length
140
+ ? ["Warnings:", ...warnings.map((w) => `- ${w}`)]
141
+ : [];
142
+ return [
143
+ `Received ${tables.length} table definition${tables.length === 1 ? "" : "s"}:`,
144
+ ...summaryLines,
145
+ ...warningLines,
146
+ ].join("\n");
147
+ },
148
+ postProcess: async ({ tool_call }) => {
149
+ console.log("GenerateTablesSkill.provideTools.postProcess called", {
150
+ has_input: !!tool_call?.input,
151
+ });
152
+ const payload = payloadFromToolCall(tool_call);
153
+ const tables = payload.tables || [];
154
+ let preview = "";
155
+ try {
156
+ preview = GenerateTables.render_html({ tables });
157
+ } catch (e) {
158
+ preview = `<pre>${JSON.stringify(payload, null, 2)}</pre>`;
159
+ }
160
+ return {
161
+ stop: true,
162
+ add_response: preview,
163
+ add_user_action:
164
+ tables.length > 0
165
+ ? {
166
+ name: "apply_copilot_tables",
167
+ type: "button",
168
+ label: `Create tables (${tables
169
+ .map((t) => t.table_name)
170
+ .join(", ")})`,
171
+ input: { tables },
172
+ }
173
+ : undefined,
174
+ };
175
+ },
176
+ function: {
177
+ name: GenerateTables.function_name,
178
+ description: GenerateTables.description,
179
+ parameters,
180
+ },
181
+ };
182
+ };
183
+ }
184
+
185
+ module.exports = GenerateTablesSkill;
@@ -33,6 +33,7 @@ class GeneratePageSkill {
33
33
  }
34
34
 
35
35
  constructor(cfg) {
36
+ console.log("GeneratePageSkill.constructor called", { cfg });
36
37
  Object.assign(this, cfg);
37
38
  }
38
39
 
@@ -88,12 +89,43 @@ class GeneratePageSkill {
88
89
 
89
90
  return {
90
91
  type: "function",
91
- process: async ({ name }) => {
92
- return "Metadata recieved";
92
+ process: async ({ name, existing_page_name }) => {
93
+ if (existing_page_name) {
94
+ const page = Page.findOne({ name: existing_page_name });
95
+
96
+ if (!page)
97
+ return `Existing page ${existing_page_name} not found. Unable to provide HTML for this page`;
98
+ if (!page.layout.html_file)
99
+ return `Existing page ${existing_page_name} is not HTML-based. Unable to provide HTML for this page`;
100
+ const file = await File.findOne(page.layout.html_file);
101
+ const html = await file.get_contents("utf8");
102
+ return (
103
+ `The HTML code for the ${existing_page_name} page is:` +
104
+ "\n```html\n" +
105
+ html +
106
+ "\n```\n"
107
+ );
108
+ } else return "Metadata recieved";
93
109
  },
94
110
  postProcess: async ({ tool_call, generate }) => {
95
111
  const str = await generate(
96
- `Now generate the contents of the ${tool_call.input.name} page with HTML`,
112
+ `Now generate the contents of the ${tool_call.input.name} HTML page. If I asked you to embed a view,
113
+ use the <embed-view> self-closing tag to do so, setting the view name in the viewname attribute. For example,
114
+ to embed the view LeadForm inside a div, write: <div><embed-view viewname="LeadForm"></div>
115
+
116
+ If you need to include the standard bootstrap CSS and javascript files, they are available as:
117
+
118
+ <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB" crossorigin="anonymous">
119
+
120
+ and
121
+ <script src="/static_assets/js/jquery-3.6.0.min.js"></script>
122
+ <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>
123
+
124
+ 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:
125
+
126
+ <script src="/static_assets/js/saltcorn-common.js"></script>
127
+ <script src="/static_assets/js/saltcorn.js">
128
+ `,
97
129
  );
98
130
  const html = str.includes("```html")
99
131
  ? str.split("```html")[1].split("```")[0]
@@ -114,7 +146,7 @@ class GeneratePageSkill {
114
146
  add_user_action: {
115
147
  name: "build_copilot_page_gen",
116
148
  type: "button",
117
- label: "Save page",
149
+ label: "Save page "+tool_call.input.name,
118
150
  input: { html },
119
151
  },
120
152
  };
@@ -124,6 +156,16 @@ class GeneratePageSkill {
124
156
  return div({ class: "border border-primary p-2 m-2" }, phrase);
125
157
  },*/
126
158
  renderToolResponse: async (response, { req }) => {
159
+ if (
160
+ typeof response === "string" &&
161
+ response.includes("Unable to provide HTML for this page")
162
+ )
163
+ return response;
164
+ if (
165
+ typeof response === "string" &&
166
+ response.includes("The HTML code for the ")
167
+ )
168
+ return `Existing page retrieved...`;
127
169
  return null;
128
170
  },
129
171
  function: {
@@ -133,8 +175,12 @@ class GeneratePageSkill {
133
175
  type: "object",
134
176
  required: ["name", "title", "min_role", "page_type"],
135
177
  properties: {
178
+ existing_page_name: {
179
+ description: `If the user asks to modify or change a page, or create a new page based on an existing page, set this to retrieve the contents of the existing page.`,
180
+ type: "string",
181
+ },
136
182
  name: {
137
- description: `The name of the page, this should be a short name which is part of the url. `,
183
+ description: `The name of the new page to generate, this should be a short name which is part of the url. If an existing page name if given, set this to the same name to modify the existing page, and a different name to create a new page based on the existing page`,
138
184
  type: "string",
139
185
  },
140
186
  title: {
package/builder-gen.js ADDED
@@ -0,0 +1,28 @@
1
+ const { getState } = require("@saltcorn/data/db/state");
2
+ const WorkflowStep = require("@saltcorn/data/models/workflow_step");
3
+ const Trigger = require("@saltcorn/data/models/trigger");
4
+ const Table = require("@saltcorn/data/models/table");
5
+ const { getActionConfigFields } = require("@saltcorn/data/plugin-helper");
6
+
7
+ module.exports = {
8
+ run: async (prompt, mode, table) => {
9
+ await new Promise((resolve) => {
10
+ setTimeout(resolve, 5000);
11
+ });
12
+ return {
13
+ type: "blank",
14
+ isHTML: true,
15
+ contents: `<h4>Placeholder copilot response</h4>
16
+ <blockquote class="blockquote"><p>${prompt}</p></blockquote>
17
+ <pre>mode=${mode} table=${table}</pre>`,
18
+ text_strings: [],
19
+ };
20
+ },
21
+ isAsync: true,
22
+ description: "Generate a builder layout",
23
+ arguments: [
24
+ { name: "prompt", type: "String" },
25
+ { name: "mode", type: "String" },
26
+ { name: "table", type: "String" },
27
+ ],
28
+ };
package/chat-copilot.js CHANGED
@@ -36,9 +36,81 @@ const {
36
36
  const MarkdownIt = require("markdown-it"),
37
37
  md = new MarkdownIt();
38
38
 
39
+ const AGENT_VIEWTEMPLATE_NAME = "Agent Chat";
40
+ const COPILOT_AGENT_TRIGGER_NAME = "Saltcorn Copilot";
41
+
42
+ const getAgentViewtemplate = () => {
43
+ const state = getState();
44
+ return state?.viewtemplates?.[AGENT_VIEWTEMPLATE_NAME];
45
+ };
46
+
47
+ const agentActionRegistered = () => {
48
+ const state = getState();
49
+ return !!(state?.actions && (state.actions.Agent || state.actions["Agent"]));
50
+ };
51
+
52
+ let copilotAgentTriggerCreateFailed = false;
53
+ let copilotAgentTriggerCreateLogged = false;
54
+
55
+ const ensureCopilotAgentTrigger = async () => {
56
+ if (!agentActionRegistered()) return null;
57
+ let trigger = await Trigger.findOne({
58
+ name: COPILOT_AGENT_TRIGGER_NAME,
59
+ action: "Agent",
60
+ });
61
+ if (trigger) return trigger;
62
+
63
+ if (copilotAgentTriggerCreateFailed) return null;
64
+ try {
65
+ return await Trigger.create({
66
+ action: "Agent",
67
+ when_trigger: "Never",
68
+ name: COPILOT_AGENT_TRIGGER_NAME,
69
+ configuration: {
70
+ prompt: "",
71
+ sys_prompt:
72
+ "You are Saltcorn Copilot. Help users build Saltcorn applications by proposing database schema, pages, views, workflows and actions.",
73
+ skills: [
74
+ { skill_type: "Database design" },
75
+ { skill_type: "Generate Page" },
76
+ ],
77
+ },
78
+ });
79
+ } catch (e) {
80
+ copilotAgentTriggerCreateFailed = true;
81
+ const state = getState();
82
+ const msg =
83
+ e && e.message
84
+ ? e.message
85
+ : "Unknown error while auto-creating Copilot Agent trigger";
86
+ if (!copilotAgentTriggerCreateLogged) {
87
+ copilotAgentTriggerCreateLogged = true;
88
+ if (state?.log) state.log(2, `Copilot: ${msg}`);
89
+ else console.error("Copilot:", msg);
90
+ }
91
+ return null;
92
+ }
93
+ };
94
+
95
+ const getAgentChatCfg = async () => {
96
+ const trigger = await ensureCopilotAgentTrigger();
97
+ if (!trigger) return null;
98
+ return {
99
+ action_id: trigger.id,
100
+ show_prev_runs: true,
101
+ prev_runs_closed: false,
102
+ placeholder: "How can I help you?",
103
+ explainer: "",
104
+ image_upload: false,
105
+ stream: true,
106
+ audio_recorder: false,
107
+ layout: "Vertical",
108
+ };
109
+ };
110
+
39
111
  const get_state_fields = () => [];
40
112
 
41
- const run = async (table_id, viewname, cfg, state, { res, req }) => {
113
+ const runLegacy = async (table_id, viewname, cfg, state, { res, req }) => {
42
114
  const prevRuns = (
43
115
  await WorkflowRun.find(
44
116
  { trigger_id: null /*started_by: req.user?.id*/ }, //todo uncomment
@@ -49,6 +121,7 @@ const run = async (table_id, viewname, cfg, state, { res, req }) => {
49
121
  r.context.interactions &&
50
122
  (r.context.copilot === "_system" || !r.context.copilot)
51
123
  );
124
+ console.log({prevRuns})
52
125
  const cfgMsg = incompleteCfgMsg();
53
126
  if (cfgMsg) return cfgMsg;
54
127
  let runInteractions = "";
@@ -284,6 +357,23 @@ const run = async (table_id, viewname, cfg, state, { res, req }) => {
284
357
  };
285
358
  };
286
359
 
360
+ const run = async (table_id, viewname, cfg, state, extra) => {
361
+ const agentVt = getAgentViewtemplate();
362
+ if (!agentVt) return await runLegacy(table_id, viewname, cfg, state, extra);
363
+
364
+ const agentCfg = await getAgentChatCfg();
365
+ if (!agentCfg) return await runLegacy(table_id, viewname, cfg, state, extra);
366
+
367
+ const agentView = new View({
368
+ name: viewname,
369
+ viewtemplate: AGENT_VIEWTEMPLATE_NAME,
370
+ configuration: agentCfg,
371
+ min_role: 100,
372
+ });
373
+ console.log({agentView})
374
+ return await agentView.run(state, extra);
375
+ };
376
+
287
377
  const ellipsize = (s, nchars) => {
288
378
  if (!s || !s.length) return "";
289
379
  if (s.length <= (nchars || 20)) return text_attr(s);
@@ -330,7 +420,7 @@ build a workflow that asks the user for their name and age
330
420
 
331
421
  */
332
422
 
333
- const execute = async (table_id, viewname, config, body, { req }) => {
423
+ const executeLegacy = async (table_id, viewname, config, body, { req }) => {
334
424
  const { fcall_id, run_id } = body;
335
425
 
336
426
  const run = await WorkflowRun.findOne({ id: +run_id });
@@ -360,7 +450,7 @@ const execute = async (table_id, viewname, config, body, { req }) => {
360
450
  return { json: { success: "ok", fcall_id, ...(result || {}) } };
361
451
  };
362
452
 
363
- const interact = async (table_id, viewname, config, body, { req }) => {
453
+ const interactLegacy = async (table_id, viewname, config, body, { req }) => {
364
454
  const { userinput, run_id } = body;
365
455
  let run;
366
456
  if (!run_id || run_id === "undefined")
@@ -487,6 +577,81 @@ const interact = async (table_id, viewname, config, body, { req }) => {
487
577
  };
488
578
  };
489
579
 
580
+ const runAgentRoute = async (routeName, table_id, viewname, body, extra) => {
581
+ const agentVt = getAgentViewtemplate();
582
+ if (!agentVt?.routes?.[routeName]) return null;
583
+ const agentCfg = await getAgentChatCfg();
584
+ if (!agentCfg) return null;
585
+ return await agentVt.routes[routeName](table_id, viewname, agentCfg, body, extra);
586
+ };
587
+
588
+ const interact = async (table_id, viewname, config, body, extra) => {
589
+ const agentResp = await runAgentRoute(
590
+ "interact",
591
+ table_id,
592
+ viewname,
593
+ body,
594
+ extra,
595
+ );
596
+ if (agentResp) return agentResp;
597
+ return await interactLegacy(table_id, viewname, config, body, extra);
598
+ };
599
+
600
+ // Legacy route used only by the legacy UI. The Agent Chat UI uses `execute_user_action`.
601
+ const execute = async (table_id, viewname, config, body, extra) => {
602
+ return await executeLegacy(table_id, viewname, config, body, extra);
603
+ };
604
+
605
+ const delprevrun = async (table_id, viewname, config, body, extra) => {
606
+ const agentResp = await runAgentRoute(
607
+ "delprevrun",
608
+ table_id,
609
+ viewname,
610
+ body,
611
+ extra,
612
+ );
613
+ if (agentResp) return agentResp;
614
+ return { json: { error: "delprevrun is only available in Agent Chat mode" } };
615
+ };
616
+
617
+ const debug_info = async (table_id, viewname, config, body, extra) => {
618
+ const agentResp = await runAgentRoute(
619
+ "debug_info",
620
+ table_id,
621
+ viewname,
622
+ body,
623
+ extra,
624
+ );
625
+ if (agentResp) return agentResp;
626
+ return { json: { error: "debug_info is only available in Agent Chat mode" } };
627
+ };
628
+
629
+ const skillroute = async (table_id, viewname, config, body, extra) => {
630
+ const agentResp = await runAgentRoute(
631
+ "skillroute",
632
+ table_id,
633
+ viewname,
634
+ body,
635
+ extra,
636
+ );
637
+ if (agentResp) return agentResp;
638
+ return { json: { error: "skillroute is only available in Agent Chat mode" } };
639
+ };
640
+
641
+ const execute_user_action = async (table_id, viewname, config, body, extra) => {
642
+ const agentResp = await runAgentRoute(
643
+ "execute_user_action",
644
+ table_id,
645
+ viewname,
646
+ body,
647
+ extra,
648
+ );
649
+ if (agentResp) return agentResp;
650
+ return {
651
+ json: { error: "execute_user_action is only available in Agent Chat mode" },
652
+ };
653
+ };
654
+
490
655
  const getFollowOnGeneration = async (tool_call) => {
491
656
  const fname = tool_call.function?.name || tool_call.toolName;
492
657
  const actionClass = classesWithSkills().find(
@@ -595,5 +760,12 @@ module.exports = {
595
760
  tableless: true,
596
761
  singleton: true,
597
762
  run,
598
- routes: { interact, execute },
763
+ routes: {
764
+ interact,
765
+ execute,
766
+ delprevrun,
767
+ debug_info,
768
+ skillroute,
769
+ execute_user_action,
770
+ },
599
771
  };
@@ -0,0 +1,75 @@
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 View = require("@saltcorn/data/models/view");
5
+ const Trigger = require("@saltcorn/data/models/trigger");
6
+ const { findType } = require("@saltcorn/data/models/discovery");
7
+ const { save_menu_items } = require("@saltcorn/data/models/config");
8
+ const db = require("@saltcorn/data/db");
9
+ const WorkflowRun = require("@saltcorn/data/models/workflow_run");
10
+ const { localeDateTime } = require("@saltcorn/markup");
11
+ const {
12
+ div,
13
+ script,
14
+ domReady,
15
+ pre,
16
+ code,
17
+ input,
18
+ h4,
19
+ style,
20
+ h5,
21
+ button,
22
+ text_attr,
23
+ i,
24
+ p,
25
+ span,
26
+ small,
27
+ form,
28
+ textarea,
29
+ } = require("@saltcorn/markup/tags");
30
+ const { getState } = require("@saltcorn/data/db/state");
31
+
32
+ const get_state_fields = () => [];
33
+
34
+ const sys_prompt = ``;
35
+
36
+ const get_agent_view = () => {
37
+ const agent_action = new Trigger({
38
+ action: "Agent",
39
+ when_trigger: "Never",
40
+ configuration: {
41
+ sys_prompt,
42
+ skills: [
43
+ { skill_type: "Generate Page" },
44
+ { skill_type: "Database design" },
45
+ ],
46
+ },
47
+ });
48
+ return new View({
49
+ viewtemplate: "Agent Chat",
50
+ name: "Saltcorn Agent copilot",
51
+ min_role: 1,
52
+ configuration: {
53
+ agent_action,
54
+ },
55
+ });
56
+ };
57
+ const run = async (table_id, viewname, cfg, state, reqres) => {
58
+ return await get_agent_view().run(state, reqres);
59
+ };
60
+
61
+ const interact = async (table_id, viewname, config, body, reqres) => {
62
+ console.log("copilot interact with body", body);
63
+ const view = get_agent_view();
64
+ return await view.runRoute("interact", body, reqres.res, reqres);
65
+ };
66
+
67
+ module.exports = {
68
+ name: "Saltcorn Agent copilot",
69
+ display_state_form: false,
70
+ get_state_fields,
71
+ tableless: true,
72
+ singleton: true,
73
+ run,
74
+ routes: { interact },
75
+ };
package/index.js CHANGED
@@ -4,15 +4,25 @@ const { features } = require("@saltcorn/data/db/state");
4
4
 
5
5
  module.exports = {
6
6
  sc_plugin_api_version: 1,
7
- dependencies: ["@saltcorn/large-language-model"],
7
+ dependencies: ["@saltcorn/large-language-model", "@saltcorn/agents"],
8
8
  viewtemplates: features.workflows
9
- ? [require("./chat-copilot"), require("./user-copilot")]
9
+ ? [
10
+ require("./chat-copilot"),
11
+ require("./user-copilot"),
12
+ require("./copilot-as-agent"),
13
+ ]
10
14
  : [require("./action-builder"), require("./database-designer")],
11
15
  functions: features.workflows
12
- ? { copilot_generate_workflow: require("./workflow-gen") }
16
+ ? {
17
+ copilot_generate_layout: require("./builder-gen.js"),
18
+ copilot_generate_workflow: require("./workflow-gen"),
19
+ }
13
20
  : {},
14
21
  actions: { copilot_generate_page: require("./page-gen-action") },
15
22
  exchange: {
16
- agent_skills: [require("./agent-skills/pagegen.js")],
23
+ agent_skills: [
24
+ require("./agent-skills/pagegen.js"),
25
+ require("./agent-skills/database-design.js"),
26
+ ],
17
27
  },
18
28
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saltcorn/copilot",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
4
4
  "description": "AI assistant for building Saltcorn applications",
5
5
  "main": "index.js",
6
6
  "dependencies": {
@@ -7,9 +7,129 @@ const Page = require("@saltcorn/data/models/page");
7
7
  const GeneratePage = require("./actions/generate-page");
8
8
  const { parseHTML } = require("./common");
9
9
 
10
+ const numericIdRE = /^\d+$/;
11
+
12
+ const arrayify = (value) => {
13
+ if (value === null || typeof value === "undefined") return [];
14
+ return Array.isArray(value) ? value : [value];
15
+ };
16
+
17
+ const normalizeName = (value) =>
18
+ typeof value === "string" ? value.trim() : value || "";
19
+
20
+ const findFileSafe = async (identifier) => {
21
+ if (identifier === null || typeof identifier === "undefined") return null;
22
+ if (
23
+ typeof identifier === "object" &&
24
+ identifier.mimetype &&
25
+ typeof identifier.get_contents === "function"
26
+ )
27
+ return identifier;
28
+ const attempts = [];
29
+ if (typeof identifier === "object" && identifier.id)
30
+ attempts.push({ id: identifier.id });
31
+ if (typeof identifier === "object" && identifier.path_to_serve)
32
+ attempts.push(identifier.path_to_serve);
33
+ if (typeof identifier === "number") attempts.push({ id: identifier });
34
+ if (typeof identifier === "string") {
35
+ const trimmed = identifier.trim();
36
+ if (trimmed) {
37
+ attempts.push(trimmed);
38
+ if (numericIdRE.test(trimmed)) attempts.push({ id: +trimmed });
39
+ attempts.push({ name: trimmed });
40
+ attempts.push({ filename: trimmed });
41
+ attempts.push({ path_to_serve: trimmed });
42
+ }
43
+ }
44
+ for (const attempt of attempts) {
45
+ try {
46
+ const file = await File.findOne(attempt);
47
+ if (file) return file;
48
+ } catch (e) {
49
+ // ignore lookup errors
50
+ }
51
+ }
52
+ return null;
53
+ };
54
+
55
+ const valueToDataUrl = async (value) => {
56
+ if (value === null || typeof value === "undefined") return null;
57
+ if (typeof value === "string" && value.trim().startsWith("data:"))
58
+ return value.trim();
59
+ const file = await findFileSafe(value);
60
+ if (!file) return null;
61
+ const base64 = await file.get_contents("base64");
62
+ return `data:${file.mimetype};base64,${base64}`;
63
+ };
64
+
65
+ const gatherImageDataFromValue = async (value) => {
66
+ const urls = [];
67
+ for (const entry of arrayify(value)) {
68
+ const url = await valueToDataUrl(entry);
69
+ if (url) urls.push(url);
70
+ }
71
+ return urls;
72
+ };
73
+
74
+ const gatherImagesFromExpression = async (expression, row, user, label) => {
75
+ if (!expression) return [];
76
+ const resolved = eval_expression(expression, row, user, label);
77
+ return await gatherImageDataFromValue(resolved);
78
+ };
79
+
80
+ const buildImageMessages = (dataUrls, label) =>
81
+ (dataUrls || []).map((image) => ({
82
+ role: "user",
83
+ content: [
84
+ ...(label ? [{ type: "text", text: label }] : []),
85
+ { type: "image", image },
86
+ ],
87
+ }));
88
+
89
+ const loadExistingPageAssets = async (pageName) => {
90
+ if (!pageName) return { page: null, html: null };
91
+ const page = await Page.findOne({ name: pageName });
92
+ if (!page) return { page: null, html: null };
93
+ let html = null;
94
+ if (page.layout?.html_file) {
95
+ const file = await findFileSafe(page.layout.html_file);
96
+ if (file) html = await file.get_contents("utf8");
97
+ }
98
+ return { page, html };
99
+ };
100
+
101
+ const refreshPagesSoon = () =>
102
+ setTimeout(() => getState().refresh_pages(), 200);
103
+
104
+ const upsertHtmlPreviewPage = async (name, html, title, description, user) => {
105
+ const file = await File.from_contents(
106
+ `${name}.html`,
107
+ "text/html",
108
+ html,
109
+ user.id,
110
+ 100,
111
+ );
112
+ const layout = { html_file: file.path_to_serve };
113
+ const existing = await Page.findOne({ name });
114
+ if (existing) await existing.update({ title, description, layout });
115
+ else
116
+ await Page.create({
117
+ name,
118
+ title,
119
+ description,
120
+ min_role: 100,
121
+ layout,
122
+ });
123
+ refreshPagesSoon();
124
+ };
125
+
10
126
  module.exports = {
11
127
  description: "Generate page with AI copilot",
12
128
  configFields: ({ table, mode }) => {
129
+ console.log({
130
+ table,
131
+ mode,
132
+ }, "OOOOOOOOOO");
13
133
  if (mode === "workflow") {
14
134
  return [
15
135
  {
@@ -36,6 +156,30 @@ module.exports = {
36
156
  class: "validate-expression",
37
157
  type: "String",
38
158
  },
159
+ {
160
+ name: "design_image_expression",
161
+ label: "Design image expression",
162
+ sublabel:
163
+ "Optional expression returning a file, file id, data URL, or array with reference design images to guide generation",
164
+ class: "validate-expression",
165
+ type: "String",
166
+ },
167
+ {
168
+ name: "feedback_image_expression",
169
+ label: "Feedback image expression",
170
+ sublabel:
171
+ "Optional expression returning a file, file id, data URL, or array with annotated feedback screenshots",
172
+ class: "validate-expression",
173
+ type: "String",
174
+ },
175
+ {
176
+ name: "existing_page_name",
177
+ label: "Existing page name",
178
+ sublabel:
179
+ "Optional expression evaluating to the name of the page to use as a baseline. If provided, its HTML will be included automatically",
180
+ class: "validate-expression",
181
+ type: "String",
182
+ },
39
183
  {
40
184
  name: "existing_page_html",
41
185
  label: "Existing page HTML",
@@ -66,9 +210,13 @@ module.exports = {
66
210
  },
67
211
  ];
68
212
  } else if (table) {
213
+ console.log(JSON.stringify(table, null, 2), "TABLE");
69
214
  const textFields = table.fields
70
215
  .filter((f) => f.type?.sql_name === "text")
71
216
  .map((f) => f.name);
217
+ const fileFields = table.fields
218
+ .filter((f) => ["File", "Image"].includes(f.type?.name))
219
+ .map((f) => f.name);
72
220
 
73
221
  return [
74
222
  {
@@ -93,6 +241,30 @@ module.exports = {
93
241
  required: true,
94
242
  attributes: { options: textFields },
95
243
  },
244
+ {
245
+ name: "existing_page_field",
246
+ label: "Existing page name field",
247
+ sublabel:
248
+ "Optional text field storing the name of the page to update",
249
+ type: "String",
250
+ attributes: { options: textFields },
251
+ },
252
+ {
253
+ name: "design_image_field",
254
+ label: "Design image field",
255
+ sublabel:
256
+ "Optional file/image field containing a reference design supplied by the user",
257
+ type: "String",
258
+ attributes: { options: fileFields },
259
+ },
260
+ {
261
+ name: "feedback_image_field",
262
+ label: "Feedback image field",
263
+ sublabel:
264
+ "Optional file/image field with annotated feedback screenshots to consider during updates",
265
+ type: "String",
266
+ attributes: { options: fileFields },
267
+ },
96
268
  // ...override_fields,
97
269
  ];
98
270
  }
@@ -109,6 +281,12 @@ module.exports = {
109
281
  prompt_template,
110
282
  answer_field,
111
283
  image_prompt,
284
+ design_image_expression,
285
+ feedback_image_expression,
286
+ design_image_field,
287
+ feedback_image_field,
288
+ existing_page_field,
289
+ existing_page_name,
112
290
  existing_page_html,
113
291
  chat_history_field,
114
292
  convert_to_saltcorn,
@@ -117,7 +295,7 @@ module.exports = {
117
295
  }) => {
118
296
  let prompt;
119
297
  if (mode === "workflow") prompt = interpolate(prompt_template, row, user);
120
- else if (prompt_field === "Formula" || mode === "workflow")
298
+ else if (prompt_field === "Formula")
121
299
  prompt = eval_expression(
122
300
  prompt_formula,
123
301
  row,
@@ -125,61 +303,104 @@ module.exports = {
125
303
  "copilot_generate_page prompt formula",
126
304
  );
127
305
  else prompt = row[prompt_field];
128
-
129
- if(existing_page_html) {
130
- exist_html = eval_expression(
306
+
307
+ const resolvedExistingName = normalizeName(
308
+ mode === "workflow"
309
+ ? existing_page_name
310
+ ? eval_expression(
311
+ existing_page_name,
312
+ row,
313
+ user,
314
+ "copilot_generate_page existing page name",
315
+ )
316
+ : null
317
+ : existing_page_field
318
+ ? row?.[existing_page_field]
319
+ : null,
320
+ );
321
+ const existingAssets = resolvedExistingName
322
+ ? await loadExistingPageAssets(resolvedExistingName)
323
+ : { page: null, html: null };
324
+
325
+ let manualExistingHtml;
326
+ if (existing_page_html) {
327
+ manualExistingHtml = eval_expression(
131
328
  existing_page_html,
132
329
  row,
133
330
  user,
134
331
  "copilot_generate_page existing page html",
135
332
  );
136
- prompt = "This is the HTML code for the existing pages you should edit:\n\n```html\n"+exist_html+"\n```\n\n"+prompt
137
333
  }
138
-
139
- const opts = {};
334
+ const existingHtmlForPrompt = manualExistingHtml || existingAssets.html;
335
+ if (existingHtmlForPrompt) {
336
+ const label = resolvedExistingName ? ` ${resolvedExistingName}` : "";
337
+ prompt = `This is the HTML code for the existing page${label} you should edit:\n\n\`\`\`html\n${existingHtmlForPrompt}\n\`\`\`\n\n${prompt}`;
338
+ }
140
339
 
141
- if (model) opts.model = model;
142
- const tools = [];
143
- const systemPrompt = await GeneratePage.system_prompt();
144
- tools.push({
145
- type: "function",
146
- function: {
147
- name: GeneratePage.function_name,
148
- description: GeneratePage.description,
149
- parameters: await GeneratePage.json_schema(),
150
- },
151
- });
152
- const { llm_generate } = getState().functions;
153
- let chat;
154
- if (image_prompt) {
155
- const from_ctx = eval_expression(
340
+ const chatMessages = [];
341
+ if (mode === "workflow") {
342
+ const referenceImages = await gatherImagesFromExpression(
156
343
  image_prompt,
157
344
  row,
158
345
  user,
159
346
  "copilot_generate_page image prompt",
160
347
  );
161
-
162
- chat = [];
163
- for (const image of Array.isArray(from_ctx) ? from_ctx : [from_ctx]) {
164
- const file = await File.findOne({ name: image });
165
- const imageurl = await file.get_contents("base64");
166
-
167
- chat.push({
168
- role: "user",
169
- content: [
170
- {
171
- type: "image",
172
- image: `data:${file.mimetype};base64,${imageurl}`,
173
- },
174
- ],
175
- });
176
- }
348
+ chatMessages.push(
349
+ ...buildImageMessages(referenceImages, "Reference image"),
350
+ );
351
+ const designImages = await gatherImagesFromExpression(
352
+ design_image_expression,
353
+ row,
354
+ user,
355
+ "copilot_generate_page design image",
356
+ );
357
+ chatMessages.push(
358
+ ...buildImageMessages(designImages, "Design reference"),
359
+ );
360
+ const feedbackImages = await gatherImagesFromExpression(
361
+ feedback_image_expression,
362
+ row,
363
+ user,
364
+ "copilot_generate_page feedback image",
365
+ );
366
+ chatMessages.push(
367
+ ...buildImageMessages(feedbackImages, "Feedback reference"),
368
+ );
369
+ } else if (row) {
370
+ chatMessages.push(
371
+ ...buildImageMessages(
372
+ await gatherImageDataFromValue(row[design_image_field]),
373
+ "Design reference",
374
+ ),
375
+ );
376
+ chatMessages.push(
377
+ ...buildImageMessages(
378
+ await gatherImageDataFromValue(row[feedback_image_field]),
379
+ "Feedback reference",
380
+ ),
381
+ );
177
382
  }
178
- const initial_ans = await llm_generate.run(prompt, {
383
+ const chat = chatMessages.length ? chatMessages : undefined;
384
+
385
+ const systemPrompt = await GeneratePage.system_prompt();
386
+ const tools = [
387
+ {
388
+ type: "function",
389
+ function: {
390
+ name: GeneratePage.function_name,
391
+ description: GeneratePage.description,
392
+ parameters: await GeneratePage.json_schema(),
393
+ },
394
+ },
395
+ ];
396
+ const { llm_generate } = getState().functions;
397
+ const llmOptions = {
179
398
  tools,
180
399
  chat,
181
400
  systemPrompt,
182
- });
401
+ ...(model ? { model } : {}),
402
+ };
403
+ const initial_ans = await llm_generate.run(prompt, llmOptions);
183
404
  const initial_info =
184
405
  initial_ans.tool_calls[0].input ||
185
406
  JSON.parse(initial_ans.tool_calls[0].function.arguments);
@@ -195,55 +416,55 @@ module.exports = {
195
416
  and
196
417
 
197
418
  <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>`;
198
- const page_html = await getState().functions.llm_generate.run(
199
- `${prompt}.
200
-
201
- The page title is: ${initial_info.title}.
202
- Further page description: ${initial_info.description}.
203
-
204
- Generate the HTML for the web page using the Bootstrap 5 CSS framework.
205
-
206
- ${prompt_part_2}
207
-
208
- Just generate HTML code, do not wrap in markdown code tags`,
209
- {
210
- debugResult: true,
211
- chat,
212
- response_format: full.response_schema
213
- ? {
214
- type: "json_schema",
215
- json_schema: {
216
- name: "generate_page",
217
- schema: full.response_schema,
218
- },
219
- }
220
- : undefined,
221
- },
419
+ const generationPrompt = `${prompt}
420
+
421
+ The page title is: ${initial_info.title}.
422
+ Further page description: ${initial_info.description}.
423
+
424
+ Generate the HTML for the web page using the Bootstrap 5 CSS framework.
425
+
426
+ ${prompt_part_2}
427
+
428
+ Just generate HTML code, do not wrap in markdown code tags`;
429
+ const page_html = await llm_generate.run(generationPrompt, {
430
+ debugResult: true,
431
+ chat,
432
+ ...(model ? { model } : {}),
433
+ response_format: full.response_schema
434
+ ? {
435
+ type: "json_schema",
436
+ json_schema: {
437
+ name: "generate_page",
438
+ schema: full.response_schema,
439
+ },
440
+ }
441
+ : undefined,
442
+ });
443
+
444
+ const requestedName = page_name ? interpolate(page_name, row, user) : "";
445
+ const targetPageName = normalizeName(
446
+ requestedName || resolvedExistingName || "",
222
447
  );
448
+ const updatingExisting =
449
+ targetPageName &&
450
+ resolvedExistingName &&
451
+ targetPageName === resolvedExistingName &&
452
+ existingAssets.page;
223
453
 
224
- const use_page_name = page_name ? interpolate(page_name, row, user) : "";
225
- if (use_page_name) {
454
+ if (targetPageName) {
226
455
  let layout;
227
456
  if (convert_to_saltcorn) {
228
457
  layout = parseHTML(page_html, true);
229
- //console.log("got layout", JSON.stringify(layout, null, 2));
230
- const file = await File.from_contents(
231
- `${use_page_name}.html`,
232
- "text/html",
458
+ await upsertHtmlPreviewPage(
459
+ `${targetPageName}_html`,
233
460
  wrapExample(page_html),
234
- user.id,
235
- 100,
461
+ initial_info.title,
462
+ initial_info.description,
463
+ user,
236
464
  );
237
- await Page.create({
238
- name: use_page_name + "_html",
239
- title: initial_info.title,
240
- description: initial_info.description,
241
- min_role: 100,
242
- layout: { html_file: file.path_to_serve },
243
- });
244
465
  } else {
245
466
  const file = await File.from_contents(
246
- `${use_page_name}.html`,
467
+ `${targetPageName}.html`,
247
468
  "text/html",
248
469
  page_html,
249
470
  user.id,
@@ -251,18 +472,26 @@ module.exports = {
251
472
  );
252
473
  layout = { html_file: file.path_to_serve };
253
474
  }
254
- //save to a file
255
475
 
256
- //create page
257
- await Page.create({
258
- name: use_page_name,
476
+ const pagePayload = {
259
477
  title: initial_info.title,
260
478
  description: initial_info.description,
261
- min_role: 100,
262
479
  layout,
263
- });
264
- setTimeout(() => getState().refresh_pages(), 200);
480
+ };
481
+
482
+ if (updatingExisting) {
483
+ await existingAssets.page.update(pagePayload);
484
+ refreshPagesSoon();
485
+ } else {
486
+ await Page.create({
487
+ name: targetPageName,
488
+ min_role: 100,
489
+ ...pagePayload,
490
+ });
491
+ refreshPagesSoon();
492
+ }
265
493
  }
494
+
266
495
  const upd = answer_field ? { [answer_field]: page_html } : {};
267
496
  if (mode === "workflow") return upd;
268
497
  else if (answer_field) await table.updateRow(upd, row[table.pk_name]);