@saltcorn/copilot 0.4.4 → 0.5.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.
@@ -0,0 +1,450 @@
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 Page = require("@saltcorn/data/models/page");
5
+ const Table = require("@saltcorn/data/models/table");
6
+ const User = require("@saltcorn/data/models/user");
7
+ const Field = require("@saltcorn/data/models/field");
8
+ const { apply, removeAllWhiteSpace } = require("@saltcorn/data/utils");
9
+ const { getActionConfigFields } = require("@saltcorn/data/plugin-helper");
10
+ const { a, pre, script, div, code } = require("@saltcorn/markup/tags");
11
+ const {
12
+ fieldProperties,
13
+ getPromptFromTemplate,
14
+ splitContainerStyle,
15
+ containerHandledStyles,
16
+ parseHTML,
17
+ } = require("../common");
18
+ const MarkdownIt = require("markdown-it"),
19
+ md = new MarkdownIt();
20
+
21
+ class GeneratePage {
22
+ static title = "Generate Page";
23
+ static function_name = "generate_page";
24
+ static description = "Generate Page";
25
+
26
+ static async json_schema() {
27
+ const allPageNames = (await Page.find({})).map((p) => p.page);
28
+ let namedescription = `The name of the page, this should be a short name which is part of the url. `;
29
+ if (allPageNames.length) {
30
+ namedescription += `These are the names of the exising pages: ${allPageNames.join(
31
+ ", "
32
+ )}. Do not pick a name that is identical but follow the same naming convention.`;
33
+ }
34
+ const roles = await User.get_roles();
35
+ return {
36
+ type: "object",
37
+ required: ["name", "title", "min_role", "page_type"],
38
+ properties: {
39
+ name: {
40
+ description: namedescription,
41
+ type: "string",
42
+ },
43
+ title: {
44
+ description: "Page title, this is in the <title> tag.",
45
+ type: "string",
46
+ },
47
+ description: {
48
+ description:
49
+ "A longer description that is not visible but appears in the page header and is indexed by search engines",
50
+ type: "string",
51
+ },
52
+ min_role: {
53
+ description:
54
+ "The minimum role needed to access the page. For pages accessible only by admin, use 'admin', pages with min_role 'public' is publicly accessible and also available to all users",
55
+ type: "string",
56
+ enum: roles.map((r) => r.role),
57
+ },
58
+ page_type: {
59
+ description:
60
+ "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",
61
+ type: "string",
62
+ enum: ["Marketing page", "Application page"],
63
+ },
64
+ },
65
+ };
66
+ }
67
+ static async system_prompt() {
68
+ return `Use the generate_page to generate a page.`;
69
+ }
70
+ static async follow_on_generate({ name, page_type }) {
71
+ if (page_type === "Marketing page") {
72
+ return {
73
+ prompt:
74
+ "Generate the HTML for the web page using the Bootstrap 5 CSS framework.",
75
+ };
76
+ }
77
+ const prompt = `Now generate the contents of the ${name} page`;
78
+ const response_schema = {
79
+ type: "object",
80
+ properties: {
81
+ element: {
82
+ anyOf: [
83
+ {
84
+ type: "object",
85
+ description: "Position items next to each other in grid columns",
86
+ //required: ["name", "title", "min_role"],
87
+ properties: {
88
+ besides: {
89
+ type: "array",
90
+ items: {
91
+ type: "object",
92
+ $ref: "#",
93
+ },
94
+ },
95
+ widths: {
96
+ type: "array",
97
+ items: {
98
+ type: "integer",
99
+ description:
100
+ "The width of each column 1-12. The sum of all columns must equal 12",
101
+ },
102
+ },
103
+ },
104
+ },
105
+ {
106
+ type: "object",
107
+ //required: ["name", "title", "min_role"],
108
+ description: "Position items vertically, each above the next",
109
+ properties: {
110
+ above: {
111
+ type: "array",
112
+ items: {
113
+ type: "object",
114
+ $ref: "#",
115
+ },
116
+ },
117
+ },
118
+ },
119
+ {
120
+ type: "object",
121
+ required: ["type", "isHTML", "contents"],
122
+ description: "An element containing HTML",
123
+ properties: {
124
+ type: { const: "blank" },
125
+ isHTML: { const: true },
126
+ contents: {
127
+ type: "string",
128
+ description: "The HTML contents of this element",
129
+ },
130
+ },
131
+ },
132
+ {
133
+ type: "object",
134
+ required: ["type", "contents"],
135
+ description: "An element containing text",
136
+ properties: {
137
+ type: { const: "blank" },
138
+ contents: {
139
+ type: "string",
140
+ description: "The plain text contents of this element",
141
+ },
142
+ style: {
143
+ type: "object",
144
+ description:
145
+ "Some CSS properties that can be applied to the text element",
146
+ properties: {
147
+ "font-size": {
148
+ type: "string",
149
+ description:
150
+ "CSS size identifier, for example 12px or 2rem",
151
+ },
152
+ color: {
153
+ type: "string",
154
+ description:
155
+ "CSS color specifier, for example #15d48a or rgb(0, 255, 0)",
156
+ },
157
+ },
158
+ },
159
+ textStyle: {
160
+ type: "array",
161
+ description: "The style to apply to the text",
162
+ items: {
163
+ type: "string",
164
+ description:
165
+ "h1-h6 to put in a header element. fst-italic for italic, text-muted for muted color, fw-bold for bold, text-underline for underline, small for smaller size, font-monospace for monospace font",
166
+ enum: [
167
+ "h1",
168
+ "h2",
169
+ "h3",
170
+ "h4",
171
+ "h5",
172
+ "h6",
173
+ "fst-italic",
174
+ "text-muted",
175
+ "fw-bold",
176
+ "text-underline",
177
+ "small",
178
+ "font-monospace",
179
+ ],
180
+ },
181
+ },
182
+ },
183
+ },
184
+ {
185
+ type: "object",
186
+ required: ["type", "contents"],
187
+ description: "An container element that can set various styles",
188
+ properties: {
189
+ type: { const: "container" },
190
+ contents: {
191
+ type: "object",
192
+ $ref: "#",
193
+ },
194
+ style: {
195
+ type: "string",
196
+ description:
197
+ "CSS properties to set on the container formatted as the html style attribute, with no CSS selector and separated by semi-colons. Example: color: #00ff00; margin-top: 5px",
198
+ },
199
+ customClass: {
200
+ type: "string",
201
+ description:
202
+ "Custom class to set. You can use bootstrap 5 utility classes here as bootstrap 5 is loaded",
203
+ },
204
+ htmlElement: {
205
+ type: "string",
206
+ description: "The HTML element to use for the container",
207
+ enum: [
208
+ "div",
209
+ "span",
210
+ "article",
211
+ "section",
212
+ "header",
213
+ "nav",
214
+ "main",
215
+ "aside",
216
+ "footer",
217
+ ],
218
+ },
219
+ },
220
+ },
221
+ {
222
+ type: "object",
223
+ description: "An image",
224
+ properties: {
225
+ type: { const: "image" },
226
+ description: {
227
+ type: "string",
228
+ description: "A description of the contents of the image",
229
+ },
230
+ width: {
231
+ type: "integer",
232
+ description: "The width of the image in px",
233
+ },
234
+ height: {
235
+ type: "integer",
236
+ description: "The height of the image in px",
237
+ },
238
+ },
239
+ },
240
+ ],
241
+ },
242
+ },
243
+ };
244
+ return { response_schema, prompt };
245
+ }
246
+
247
+ static walk_response(segment) {
248
+ let go = GeneratePage.walk_response;
249
+ if (!segment) return segment;
250
+ if (typeof segment === "string") return segment;
251
+ if (segment.element) return go(segment.element);
252
+ if (Array.isArray(segment)) {
253
+ return segment.map(go);
254
+ }
255
+ if (typeof segment.contents === "string") {
256
+ return { ...segment, contents: md.render(segment.contents) };
257
+ }
258
+ if (segment.type === "image") {
259
+ return {
260
+ type: "container",
261
+ style: {
262
+ height: `${segment.height}px`,
263
+ width: `${segment.width}px`,
264
+ "border-style": "solid",
265
+ "border-color": "#808080",
266
+ "border-width": "3px",
267
+ vAlign: "middle",
268
+ hAlign: "center",
269
+ },
270
+ contents: segment.description,
271
+ };
272
+ }
273
+ if (segment.type === "container") {
274
+ const { customStyle, style, display, overflow } = splitContainerStyle(
275
+ segment.style
276
+ );
277
+ return {
278
+ ...segment,
279
+ customStyle,
280
+ display,
281
+ overflow,
282
+ style,
283
+ contents: go(segment.contents),
284
+ };
285
+ }
286
+ if (segment.contents) {
287
+ return { ...segment, contents: go(segment.contents) };
288
+ }
289
+ if (segment.above) {
290
+ return { ...segment, above: go(segment.above) };
291
+ }
292
+ if (segment.besides) {
293
+ return { ...segment, besides: go(segment.besides) };
294
+ }
295
+ }
296
+
297
+ static render_html(attrs, contents) {
298
+ if (attrs.page_type === "Marketing page") {
299
+ return (
300
+ pre(code(JSON.stringify(attrs, null, 2))) +
301
+ pre(code(escapeHtml(contents || "")))
302
+ );
303
+ }
304
+
305
+ return (
306
+ pre(code(JSON.stringify(attrs, null, 2))) +
307
+ pre(
308
+ code(
309
+ escapeHtml(
310
+ JSON.stringify(
311
+ GeneratePage.walk_response(JSON.parse(contents)),
312
+ null,
313
+ 2
314
+ )
315
+ )
316
+ )
317
+ )
318
+ );
319
+ }
320
+ static async execute(
321
+ { name, title, description, min_role, page_type },
322
+ req,
323
+ contents
324
+ ) {
325
+ console.log("execute", name, contents);
326
+ const roles = await User.get_roles();
327
+ const min_role_id = roles.find((r) => r.role === min_role).id;
328
+ let layout;
329
+ if (page_type === "Marketing page") {
330
+ layout = parseHTML(contents);
331
+ } else layout = GeneratePage.walk_response(JSON.parse(contents));
332
+ await Page.create({
333
+ name,
334
+ title,
335
+ description,
336
+ min_role: min_role_id,
337
+ layout,
338
+ });
339
+ return {
340
+ postExec:
341
+ "Page created. " +
342
+ a(
343
+ { target: "_blank", href: `/page/${name}`, class: "me-1" },
344
+ "Go to page"
345
+ ) +
346
+ " | " +
347
+ a(
348
+ { target: "_blank", href: `/pageedit/edit/${name}`, class: "ms-1" },
349
+ "Configure page"
350
+ ),
351
+ };
352
+ }
353
+ }
354
+
355
+ function escapeHtml(unsafe) {
356
+ return unsafe
357
+ .replace(/&/g, "&amp;")
358
+ .replace(/</g, "&lt;")
359
+ .replace(/>/g, "&gt;")
360
+ .replace(/"/g, "&quot;")
361
+ .replace(/'/g, "&#039;");
362
+ }
363
+
364
+ parseHTML(`<!DOCTYPE html>
365
+ <html lang="en">
366
+ <head>
367
+ <meta charset="UTF-8">
368
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
369
+ <title>Popcorn Beer - Unleash the Flavor Explosion!</title>
370
+ <link href="https://stackpath.bootstrapcdn.com/bootstrap/5.3.0/css/bootstrap.min.css" rel="stylesheet">
371
+ </head>
372
+ <body>
373
+
374
+ <header class="bg-warning text-dark py-5">
375
+ <div class="container text-center">
376
+ <h1 class="display-4">Popcorn Beer</h1>
377
+ <p class="lead">Unleash the Flavor Explosion!</p>
378
+ </div>
379
+ </header>
380
+
381
+ <section class="py-5">
382
+ <div class="container">
383
+ <div class="row align-items-center">
384
+ <div class="col-md-6">
385
+ <img src="your-image-url.jpg" alt="Popcorn Beer" class="img-fluid rounded">
386
+ </div>
387
+ <div class="col-md-6">
388
+ <h2>Discover the Unique Taste</h2>
389
+ <p>Introducing our popcorn-flavored beer, a delightful fusion of classic beer with a twist of popcorn essence. Experience the taste sensation that will keep you coming back for more.</p>
390
+ <a href="#purchase" class="btn btn-warning btn-lg mt-3">Buy Now</a>
391
+ </div>
392
+ </div>
393
+ </div>
394
+ </section>
395
+
396
+ <section class="bg-light py-5">
397
+ <div class="container">
398
+ <h2 class="text-center mb-4">Why Choose Popcorn Beer?</h2>
399
+ <div class="row">
400
+ <div class="col-lg-4">
401
+ <div class="card mb-4">
402
+ <div class="card-body text-center">
403
+ <h5 class="card-title">Unique Flavor</h5>
404
+ <p class="card-text">A perfect blend of beer and popcorn that tantalizes your taste buds.</p>
405
+ </div>
406
+ </div>
407
+ </div>
408
+ <div class="col-lg-4">
409
+ <div class="card mb-4">
410
+ <div class="card-body text-center">
411
+ <h5 class="card-title">Premium Ingredients</h5>
412
+ <p class="card-text">Crafted with the finest ingredients for an unparalleled taste.</p>
413
+ </div>
414
+ </div>
415
+ </div>
416
+ <div class="col-lg-4">
417
+ <div class="card mb-4">
418
+ <div class="card-body text-center">
419
+ <h5 class="card-title">Perfect for Any Occasion</h5>
420
+ <p class="card-text">Whether it's a movie night or a party, Popcorn Beer is your go-to drink.</p>
421
+ </div>
422
+ </div>
423
+ </div>
424
+ </div>
425
+ </div>
426
+ </section>
427
+
428
+ <section id="purchase" class="py-5">
429
+ <div class="container text-center">
430
+ <h2>Get Your Popcorn Beer Today!</h2>
431
+ <p class="mb-4">Available for purchase online and in select stores near you.</p>
432
+ <a href="shop.html" class="btn btn-warning btn-lg">Shop Now</a>
433
+ </div>
434
+ </section>
435
+
436
+ <footer class="bg-dark text-white py-4">
437
+ <div class="container text-center">
438
+ <p>&copy; 2023 Popcorn Beer. All rights reserved.</p>
439
+ <ul class="list-inline">
440
+ <li class="list-inline-item"><a href="#" class="text-white">Privacy Policy</a></li>
441
+ <li class="list-inline-item"><a href="#" class="text-white">Terms of Service</a></li>
442
+ <li class="list-inline-item"><a href="#" class="text-white">Contact Us</a></li>
443
+ </ul>
444
+ </div>
445
+ </footer>
446
+
447
+ <script src="https://stackpath.bootstrapcdn.com/bootstrap/5.3.0/js/bootstrap.bundle.min.js"></script>
448
+ </body>`);
449
+
450
+ module.exports = GeneratePage;
@@ -17,7 +17,7 @@ class GenerateTables {
17
17
  const types = Object.values(getState().types);
18
18
  const fieldTypeCfg = types.map((ty) => {
19
19
  const properties = {
20
- data_type: { const: ty.name },
20
+ data_type: { type: "string", enum: [ty.name] },
21
21
  };
22
22
  const attrs = apply(ty.attributes, {}) || [];
23
23
  if (Array.isArray(attrs))
@@ -40,7 +40,7 @@ class GenerateTables {
40
40
  description:
41
41
  "A foreign key to a different table. This will reference the primary key on another table.",
42
42
  properties: {
43
- data_type: { const: "ForeignKey" },
43
+ data_type: { type: "string", enum: ["ForeignKey"] },
44
44
  reference_table: {
45
45
  type: "string",
46
46
  description: "Name of the table being referenced",
@@ -52,7 +52,7 @@ class GenerateTables {
52
52
  description:
53
53
  "A reference (file path) to a file on disk. This can be used for example to hold images or documents",
54
54
  properties: {
55
- data_type: { const: "File" },
55
+ data_type: { type: "string", enum: ["File"] },
56
56
  },
57
57
  });
58
58
  return {
@@ -0,0 +1,299 @@
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 Page = require("@saltcorn/data/models/page");
5
+ const View = require("@saltcorn/data/models/view");
6
+ const Table = require("@saltcorn/data/models/table");
7
+ const User = require("@saltcorn/data/models/user");
8
+ const Field = require("@saltcorn/data/models/field");
9
+ const { apply, removeAllWhiteSpace } = require("@saltcorn/data/utils");
10
+ const { getActionConfigFields } = require("@saltcorn/data/plugin-helper");
11
+ const { a, pre, script, div, code } = require("@saltcorn/markup/tags");
12
+ const {
13
+ fieldProperties,
14
+ getPromptFromTemplate,
15
+ splitContainerStyle,
16
+ containerHandledStyles,
17
+ walk_response,
18
+ } = require("../common");
19
+
20
+ const get_rndid = () => {
21
+ let characters = "0123456789abcdef";
22
+ let str = "";
23
+ for (let i = 0; i < 6; i++) {
24
+ str += characters[Math.floor(Math.random() * 16)];
25
+ }
26
+ return str;
27
+ };
28
+
29
+ const snakeToPascal = (str) => {
30
+ const snakeToCamel = (str) =>
31
+ str.replace(/([-_]\w)/g, (g) => g[1].toUpperCase());
32
+ let camelCase = snakeToCamel(str);
33
+ let pascalCase = camelCase[0].toUpperCase() + camelCase.substr(1);
34
+ return pascalCase;
35
+ };
36
+
37
+ const col2layoutSegment = (table, col) => {
38
+ switch (col.coltype) {
39
+ case "Field":
40
+ return {
41
+ type: "field",
42
+ field_name: col.fieldSpec.fieldname,
43
+ fieldview: col.fieldSpec.fieldview,
44
+ };
45
+ case "Action":
46
+ return {
47
+ type: "action",
48
+ action_name: col.action.action_name,
49
+ action_style: col.style,
50
+ action_label: col.label,
51
+ rndid: get_rndid(),
52
+ };
53
+ case "View link":
54
+ return {
55
+ type: "view_link",
56
+ view: col.view,
57
+ relation: `.${table.name}`,
58
+ link_style: col.style === "Link" ? "" : col.style,
59
+ view_label: col.label,
60
+ };
61
+
62
+ default:
63
+ break;
64
+ }
65
+ return {
66
+ type: col.coltype.toLowerCase(),
67
+ };
68
+ };
69
+
70
+ const segment2column = (seg) => {
71
+ return {
72
+ ...seg,
73
+ type: snakeToPascal(seg.type),
74
+ };
75
+ };
76
+
77
+ class GenerateView {
78
+ static title = "Generate View";
79
+ static function_name = "generate_view";
80
+ static description = "Generate view";
81
+
82
+ static async json_schema() {
83
+ const tables = await Table.find({});
84
+ //const viewtypes = getState().
85
+ const roles = await User.get_roles();
86
+ return {
87
+ type: "object",
88
+ required: ["name", "viewpattern", "table"],
89
+ properties: {
90
+ name: {
91
+ description: `The name of the view, this should be a short name which is part of the url. `,
92
+ type: "string",
93
+ },
94
+ viewpattern: {
95
+ description: `The type of view to generate. Show for a read-only view of a single table row,
96
+ List for a tabular grid view of many rows, Edit for forms for editing a single row, Feed to repeatedly show
97
+ another view (typically a Show view), Filter for selecting a subset of rows based on field values
98
+ to be shown in another view (typically List or Feed views) on the same page.`,
99
+ type: "string",
100
+ enum: ["List"], //, "Show", "Edit", "Filter", "Feed"],
101
+ },
102
+ table: {
103
+ description: "Which table is this a view on",
104
+ type: "string",
105
+ enum: tables.map((t) => t.name),
106
+ },
107
+ min_role: {
108
+ description:
109
+ "The minimum role needed to access the view. For vies accessible only by admin, use 'admin', pages with min_role 'public' is publicly accessible and also available to all users",
110
+ type: "string",
111
+ enum: roles.map((r) => r.role),
112
+ },
113
+ },
114
+ };
115
+ }
116
+ static async system_prompt() {
117
+ return `Use the generate_view tool to generate a view.`;
118
+ }
119
+
120
+ static async follow_on_generate_list({ name, viewpattern, table }) {
121
+ const tbl = Table.findOne({ name: table });
122
+ const triggers = Trigger.find({
123
+ when_trigger: { or: ["API call", "Never"] },
124
+ }).filter(
125
+ (tr) =>
126
+ tr.description && tr.name && (!tr.table_id || tr.table_id === tbl.id)
127
+ );
128
+ const own_show_views = await View.find_table_views_where(
129
+ tbl.id,
130
+ ({ state_fields, viewtemplate, viewrow }) =>
131
+ state_fields.some((sf) => sf.name === "id")
132
+ );
133
+ const prompt = `Now generate the structure of the ${name} ${viewpattern} view on the ${table} table`;
134
+ const response_schema = {
135
+ type: "object",
136
+ properties: {
137
+ columns: {
138
+ type: "array",
139
+ items: {
140
+ anyOf: [
141
+ {
142
+ type: "object",
143
+ description: "Show a field value",
144
+ //required: ["name", "title", "min_role"],
145
+ properties: {
146
+ coltype: { const: "Field" },
147
+ fieldSpec: {
148
+ anyOf: tbl.fields.map((f) => ({
149
+ type: "object",
150
+ properties: {
151
+ fieldname: { const: f.name },
152
+ fieldview: {
153
+ type: "string",
154
+ enum: Object.keys(f.type?.fieldviews || {}),
155
+ },
156
+ },
157
+ })),
158
+ },
159
+ },
160
+ },
161
+ {
162
+ type: "object",
163
+ description: "Action button",
164
+ //required: ["name", "title", "min_role"],
165
+ properties: {
166
+ coltype: { const: "Action" },
167
+ label: { type: "string", description: "The button label" },
168
+ style: {
169
+ type: "string",
170
+ description: "The bootstrap button class",
171
+ enum: [
172
+ "btn-primary",
173
+ "btn-secondary",
174
+ "btn-outline-primary",
175
+ "btn-outline-secondary",
176
+ ],
177
+ },
178
+ action: {
179
+ anyOf: [
180
+ {
181
+ type: "object",
182
+ description: "Delete this row form the database table",
183
+ properties: {
184
+ action_name: { const: "Delete" },
185
+ },
186
+ },
187
+ ...triggers.map((tr) => ({
188
+ type: "object",
189
+ description: tr.description,
190
+ properties: {
191
+ action_name: { const: tr.name },
192
+ },
193
+ })),
194
+ ],
195
+ },
196
+ },
197
+ },
198
+ {
199
+ type: "object",
200
+ description: "a link to a different view on the same table",
201
+ properties: {
202
+ coltype: { const: "View link" },
203
+ label: { type: "string", description: "The view label" },
204
+ style: {
205
+ type: "string",
206
+ description:
207
+ "Link for a normal link, or for a button, the bootstrap button class",
208
+ enum: [
209
+ "Link",
210
+ "btn-primary",
211
+ "btn-secondary",
212
+ "btn-outline-primary",
213
+ "btn-outline-secondary",
214
+ ],
215
+ },
216
+ view: {
217
+ type: "string",
218
+ description: "the view to link to",
219
+ enum: own_show_views.map((v) => v.name),
220
+ },
221
+ },
222
+ },
223
+ ],
224
+ },
225
+ },
226
+ },
227
+ };
228
+ return { response_schema, prompt };
229
+ }
230
+
231
+ static async follow_on_generate(properties) {
232
+ switch (properties.viewpattern) {
233
+ case "List":
234
+ default:
235
+ return GenerateView.follow_on_generate_list(properties);
236
+ }
237
+ }
238
+
239
+ static render_html(attrs, contents) {
240
+ console.log(contents);
241
+
242
+ return (
243
+ pre(code(JSON.stringify(attrs, null, 2))) +
244
+ (contents ? pre(code(JSON.stringify(JSON.parse(contents), null, 2))) : "")
245
+ );
246
+ }
247
+ static async execute({ name, table, viewpattern, min_role }, req, contents) {
248
+ console.log("execute", name, contents);
249
+ const roles = await User.get_roles();
250
+ const min_role_id = min_role
251
+ ? roles.find((r) => r.role === min_role).id
252
+ : 100;
253
+ const tbl = Table.findOne({ name: table });
254
+ const viewCfg = {
255
+ table_id: tbl.id,
256
+ name,
257
+ viewtemplate: viewpattern,
258
+ min_role: min_role_id,
259
+ configuration: {},
260
+ };
261
+ switch (viewpattern) {
262
+ case "List":
263
+ const conts = JSON.parse(contents);
264
+ const cols = conts.columns;
265
+ const segments = cols.map((c) => col2layoutSegment(tbl, c));
266
+ viewCfg.configuration.layout = {
267
+ besides: segments.map((s) => ({ contents: s })),
268
+ };
269
+ viewCfg.configuration.columns = segments.map(segment2column);
270
+
271
+ break;
272
+
273
+ default:
274
+ break;
275
+ }
276
+ console.log(viewCfg);
277
+
278
+ await View.create(viewCfg);
279
+ return {
280
+ postExec:
281
+ "View created. " +
282
+ a(
283
+ { target: "_blank", href: `/view/${name}`, class: "me-1" },
284
+ "Go to view"
285
+ ) +
286
+ " | " +
287
+ a(
288
+ {
289
+ target: "_blank",
290
+ href: `/viewedit/config/${name}`,
291
+ class: "ms-1",
292
+ },
293
+ "Configure view"
294
+ ),
295
+ };
296
+ }
297
+ }
298
+
299
+ module.exports = GenerateView;
@@ -16,7 +16,9 @@ const steps = async () => {
16
16
  );
17
17
 
18
18
  const stepTypeAndCfg = Object.keys(actionExplainers).map((actionName) => {
19
- const properties = { step_type: { const: actionName } };
19
+ const properties = {
20
+ step_type: { type: "string", enum: [actionName] }
21
+ };
20
22
  const myFields = actionFields.filter(
21
23
  (f) => f.showIf?.wf_action_name === actionName
22
24
  );
@@ -37,7 +39,9 @@ const steps = async () => {
37
39
  });
38
40
  for (const [actionName, action] of stateActionList) {
39
41
  try {
40
- const properties = { step_type: { const: actionName } };
42
+ const properties = {
43
+ step_type: { type: "string", enum: [actionName] }
44
+ };
41
45
  const cfgFields = await getActionConfigFields(action, null, {
42
46
  mode: "workflow",
43
47
  copilot: true,
@@ -69,7 +73,10 @@ const steps = async () => {
69
73
  //TODO workflows
70
74
  for (const trigger of triggers) {
71
75
  const properties = {
72
- step_type: { const: trigger.name },
76
+ step_type: {
77
+ type: "string",
78
+ enum: [trigger.name],
79
+ },
73
80
  };
74
81
  if (trigger.table_id) {
75
82
  const table = Table.findOne({ id: trigger.table_id });
package/chat-copilot.js CHANGED
@@ -142,7 +142,9 @@ const run = async (table_id, viewname, cfg, state, { res, req }) => {
142
142
  i(
143
143
  small(
144
144
  "Skills you can request: " +
145
- actionClasses.map((ac) => ac.title).join(", ")
145
+ classesWithSkills()
146
+ .map((ac) => ac.title)
147
+ .join(", ")
146
148
  )
147
149
  )
148
150
  );
@@ -292,12 +294,20 @@ const actionClasses = [
292
294
  require("./actions/generate-workflow"),
293
295
  require("./actions/generate-tables"),
294
296
  require("./actions/generate-js-action"),
297
+ require("./actions/generate-page"),
298
+ require("./actions/generate-view"),
295
299
  ];
296
300
 
301
+ const classesWithSkills = () => {
302
+ const state = getState();
303
+ const skills = state.copilot_skills || [];
304
+ return [...actionClasses, ...skills];
305
+ };
306
+
297
307
  const getCompletionArguments = async () => {
298
308
  const tools = [];
299
309
  const sysPrompts = [];
300
- for (const actionClass of actionClasses) {
310
+ for (const actionClass of classesWithSkills()) {
301
311
  tools.push({
302
312
  type: "function",
303
313
  function: {
@@ -326,10 +336,25 @@ const execute = async (table_id, viewname, config, body, { req }) => {
326
336
  const run = await WorkflowRun.findOne({ id: +run_id });
327
337
 
328
338
  const fcall = run.context.funcalls[fcall_id];
329
- const actionClass = actionClasses.find(
339
+ const actionClass = classesWithSkills().find(
330
340
  (ac) => ac.function_name === fcall.name
331
341
  );
332
- const result = await actionClass.execute(JSON.parse(fcall.arguments), req);
342
+ let result;
343
+ if (actionClass.follow_on_generate) {
344
+ const toolCallIndex = run.context.interactions.findIndex(
345
+ (i) => i.tool_call_id === fcall_id
346
+ );
347
+ const follow_on_gen = run.context.interactions.find(
348
+ (i, ix) => i.role === "assistant" && ix > toolCallIndex
349
+ );
350
+ result = await actionClass.execute(
351
+ JSON.parse(fcall.arguments),
352
+ req,
353
+ follow_on_gen.content
354
+ );
355
+ } else {
356
+ result = await actionClass.execute(JSON.parse(fcall.arguments), req);
357
+ }
333
358
  await addToContext(run, { implemented_fcall_ids: [fcall_id] });
334
359
  return { json: { success: "ok", fcall_id, ...(result || {}) } };
335
360
  };
@@ -387,9 +412,50 @@ const interact = async (table_id, viewname, config, body, { req }) => {
387
412
  await addToContext(run, {
388
413
  funcalls: { [tool_call.id]: tool_call.function },
389
414
  });
390
- const markup = await renderToolcall(tool_call, viewname, false, run);
391
415
 
392
- actions.push(markup);
416
+ const followOnGen = await getFollowOnGeneration(tool_call);
417
+ if (followOnGen) {
418
+ const { response_schema, prompt } = followOnGen;
419
+ const follow_on_answer = await getState().functions.llm_generate.run(
420
+ prompt,
421
+ {
422
+ debugResult: true,
423
+ chat: run.context.interactions,
424
+ response_format: response_schema
425
+ ? {
426
+ type: "json_schema",
427
+ json_schema: {
428
+ name: "generate_page",
429
+ schema: response_schema,
430
+ },
431
+ }
432
+ : undefined,
433
+ }
434
+ );
435
+
436
+ console.log("follow on answer", follow_on_answer);
437
+
438
+ await addToContext(run, {
439
+ interactions: [
440
+ { role: "user", content: prompt },
441
+ { role: "assistant", content: follow_on_answer },
442
+ ],
443
+ });
444
+
445
+ const markup = await renderToolcall(
446
+ tool_call,
447
+ viewname,
448
+ false,
449
+ run,
450
+ follow_on_answer
451
+ );
452
+
453
+ actions.push(markup);
454
+ } else {
455
+ const markup = await renderToolcall(tool_call, viewname, false, run);
456
+
457
+ actions.push(markup);
458
+ }
393
459
  }
394
460
  return { json: { success: "ok", actions, run_id: run.id } };
395
461
  } else
@@ -398,12 +464,32 @@ const interact = async (table_id, viewname, config, body, { req }) => {
398
464
  };
399
465
  };
400
466
 
401
- const renderToolcall = async (tool_call, viewname, implemented, run) => {
467
+ const getFollowOnGeneration = async (tool_call) => {
468
+ const fname = tool_call.function.name;
469
+ const actionClass = classesWithSkills().find(
470
+ (ac) => ac.function_name === fname
471
+ );
472
+ const args = JSON.parse(tool_call.function.arguments);
473
+
474
+ if (actionClass.follow_on_generate) {
475
+ return await actionClass.follow_on_generate(args);
476
+ } else return null;
477
+ };
478
+
479
+ const renderToolcall = async (
480
+ tool_call,
481
+ viewname,
482
+ implemented,
483
+ run,
484
+ follow_on_answer
485
+ ) => {
402
486
  const fname = tool_call.function.name;
403
- const actionClass = actionClasses.find((ac) => ac.function_name === fname);
487
+ const actionClass = classesWithSkills().find(
488
+ (ac) => ac.function_name === fname
489
+ );
404
490
  const args = JSON.parse(tool_call.function.arguments);
405
491
 
406
- const inner_markup = await actionClass.render_html(args);
492
+ const inner_markup = await actionClass.render_html(args, follow_on_answer);
407
493
  return wrapAction(
408
494
  inner_markup,
409
495
  viewname,
package/common.js CHANGED
@@ -8,6 +8,79 @@ const View = require("@saltcorn/data/models/view");
8
8
  const Trigger = require("@saltcorn/data/models/trigger");
9
9
  const { getState } = require("@saltcorn/data/db/state");
10
10
 
11
+ const parseCSS = require("style-to-object").default;
12
+ const MarkdownIt = require("markdown-it"),
13
+ md = new MarkdownIt();
14
+ const HTMLParser = require("node-html-parser");
15
+
16
+ const boxHandledStyles = new Set([
17
+ "margin",
18
+ "margin-top",
19
+ "margin-bottom",
20
+ "margin-right",
21
+ "margin-left",
22
+ "padding",
23
+ "padding-top",
24
+ "padding-bottom",
25
+ "padding-right",
26
+ "padding-left",
27
+ "border-color",
28
+ "border-color",
29
+ "border-width",
30
+ "border-radius",
31
+ "height",
32
+ "min-height",
33
+ "max-height",
34
+ "width",
35
+ "min-width",
36
+ "max-width",
37
+ ]);
38
+
39
+ const containerHandledStyles = new Set([
40
+ ...boxHandledStyles,
41
+ "opacity",
42
+ "position",
43
+ "top",
44
+ "right",
45
+ "bottom",
46
+ "left",
47
+ "font-family",
48
+ "font-size",
49
+ "font-weight",
50
+ "line-height",
51
+ "flex-grow",
52
+ "flex-shrink",
53
+ "flex-direction",
54
+ "flex-wrap",
55
+ "justify-content",
56
+ "align-items",
57
+ "align-content",
58
+ "display",
59
+ "overflow",
60
+ ]);
61
+
62
+ const splitContainerStyle = (styleStr) => {
63
+ const style = parseCSS(styleStr);
64
+ const customStyles = [];
65
+ Object.keys(style || {}).forEach((k) => {
66
+ if (containerHandledStyles.has(k)) {
67
+ customStyles.push(`${k}: ${style[k]}`);
68
+ delete style[k];
69
+ }
70
+ });
71
+ const ns = { style, customStyle: customStyles.join("; ") };
72
+ if (ns.style.display) {
73
+ ns.display = ns.style.display;
74
+ delete ns.style.display;
75
+ }
76
+ if (ns.style.overflow) {
77
+ ns.overflow = ns.style.overflow;
78
+ delete ns.style.overflow;
79
+ }
80
+
81
+ return ns;
82
+ };
83
+
11
84
  const getPromptFromTemplate = async (tmplName, userPrompt, extraCtx = {}) => {
12
85
  const tables = await Table.find({});
13
86
  const context = {
@@ -99,7 +172,146 @@ const fieldProperties = (field) => {
99
172
  if (field.options) props.enum = toArrayOfStrings(field.options);
100
173
  break;
101
174
  }
175
+ if (!props.type) {
176
+ switch (field.input_type) {
177
+ case "code":
178
+ props.type = "string";
179
+ break;
180
+ }
181
+ }
102
182
  return props;
103
183
  };
104
184
 
105
- module.exports = { getCompletion, getPromptFromTemplate, incompleteCfgMsg, fieldProperties };
185
+ function walk_response(segment) {
186
+ let go = walk_response;
187
+ if (!segment) return segment;
188
+ if (typeof segment === "string") return segment;
189
+ if (segment.element) return go(segment.element);
190
+ if (Array.isArray(segment)) {
191
+ return segment.map(go);
192
+ }
193
+ if (typeof segment.contents === "string") {
194
+ return { ...segment, contents: md.render(segment.contents) };
195
+ }
196
+ if (segment.type === "image") {
197
+ return {
198
+ type: "container",
199
+ style: {
200
+ height: `${segment.height}px`,
201
+ width: `${segment.width}px`,
202
+ "border-style": "solid",
203
+ "border-color": "#808080",
204
+ "border-width": "3px",
205
+ vAlign: "middle",
206
+ hAlign: "center",
207
+ },
208
+ contents: segment.description,
209
+ };
210
+ }
211
+ if (segment.type === "container") {
212
+ const { customStyle, style, display, overflow } = splitContainerStyle(
213
+ segment.style
214
+ );
215
+ return {
216
+ ...segment,
217
+ customStyle,
218
+ display,
219
+ overflow,
220
+ style,
221
+ contents: go(segment.contents),
222
+ };
223
+ }
224
+ if (segment.contents) {
225
+ return { ...segment, contents: go(segment.contents) };
226
+ }
227
+ if (segment.above) {
228
+ return { ...segment, above: go(segment.above) };
229
+ }
230
+ if (segment.besides) {
231
+ return { ...segment, besides: go(segment.besides) };
232
+ }
233
+ }
234
+
235
+ function parseHTML(str) {
236
+ const strHtml = str.includes("```html")
237
+ ? str.split("```html")[1].split("```")[0]
238
+ : str;
239
+ const body = HTMLParser.parse(strHtml).querySelector("body");
240
+
241
+ const go = (node) => {
242
+ if (node.constructor.name === "HTMLElement") {
243
+ switch (node.rawTagName) {
244
+ case "body":
245
+ return { above: node.childNodes.map(go).filter(Boolean) };
246
+ case "p":
247
+ return {
248
+ type: "blank",
249
+ contents: node.childNodes.map((n) => n.toString()).join(""),
250
+ customClass: (node.classList.value || []).join(" "),
251
+ };
252
+ case "script":
253
+ return null;
254
+ case "a":
255
+ return {
256
+ type: "link",
257
+ url: node.getAttribute("href"),
258
+ text: node.childNodes.map((n) => n.toString()).join(""),
259
+ link_class: (node.classList.value || []).join(" "),
260
+ link_src: "URL",
261
+ };
262
+ case "img":
263
+ return {
264
+ type: "container",
265
+ style: {
266
+ "border-color": "#808080",
267
+ "border-style": "solid",
268
+ "border-width": "2px",
269
+ },
270
+ contents: {
271
+ type: "blank",
272
+ contents: "Image: " + node.getAttribute("alt"),
273
+ },
274
+ };
275
+
276
+ case "h1":
277
+ case "h2":
278
+ case "h3":
279
+ case "h4":
280
+ case "h5":
281
+ case "h6":
282
+ return {
283
+ type: "blank",
284
+ contents: node.childNodes.map((n) => n.toString()).join(""),
285
+ customClass: (node.classList.value || []).join(" "),
286
+ textStyle: [node.rawTagName],
287
+ };
288
+ default:
289
+ return {
290
+ type: "container",
291
+ htmlElement: node.rawTagName,
292
+ customClass: (node.classList.value || []).join(" "),
293
+ contents: node.childNodes.map(go).filter(Boolean),
294
+ };
295
+ }
296
+ } else if (node.constructor.name === "TextNode") {
297
+ if (!node._rawText || !node._rawText.trim()) return null;
298
+ else return { type: "blank", contents: node._rawText };
299
+ }
300
+ };
301
+ //console.log(body.constructor.name);
302
+
303
+ //console.log(JSON.stringify(go(body.childNodes[3]), null, 2));
304
+ return go(body);
305
+ }
306
+
307
+ module.exports = {
308
+ getCompletion,
309
+ getPromptFromTemplate,
310
+ incompleteCfgMsg,
311
+ fieldProperties,
312
+ boxHandledStyles,
313
+ containerHandledStyles,
314
+ splitContainerStyle,
315
+ walk_response,
316
+ parseHTML,
317
+ };
package/package.json CHANGED
@@ -1,13 +1,15 @@
1
1
  {
2
2
  "name": "@saltcorn/copilot",
3
- "version": "0.4.4",
3
+ "version": "0.5.0",
4
4
  "description": "AI assistant for building Saltcorn applications",
5
5
  "main": "index.js",
6
6
  "dependencies": {
7
7
  "@saltcorn/data": "^0.9.0",
8
8
  "underscore": "1.13.6",
9
9
  "node-sql-parser": "4.15.0",
10
- "markdown-it": "14.1.0"
10
+ "markdown-it": "14.1.0",
11
+ "style-to-object": "1.0.8",
12
+ "node-html-parser": "7.0.1"
11
13
  },
12
14
  "author": "Tom Nielsen",
13
15
  "license": "MIT",