@saltcorn/copilot 0.4.5 → 0.5.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.
@@ -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;
@@ -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;
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 = {
@@ -109,9 +182,136 @@ const fieldProperties = (field) => {
109
182
  return props;
110
183
  };
111
184
 
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
+
112
307
  module.exports = {
113
308
  getCompletion,
114
309
  getPromptFromTemplate,
115
310
  incompleteCfgMsg,
116
311
  fieldProperties,
312
+ boxHandledStyles,
313
+ containerHandledStyles,
314
+ splitContainerStyle,
315
+ walk_response,
316
+ parseHTML,
117
317
  };
package/package.json CHANGED
@@ -1,13 +1,15 @@
1
1
  {
2
2
  "name": "@saltcorn/copilot",
3
- "version": "0.4.5",
3
+ "version": "0.5.1",
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",
package/user-copilot.js CHANGED
@@ -466,39 +466,42 @@ const run = async (table_id, viewname, config, state, { res, req }) => {
466
466
  const getCompletionArguments = async (config) => {
467
467
  let tools = [];
468
468
  const sysPrompts = [];
469
- let properties = {};
470
469
 
471
- const tableNames = (config?.tables || []).map((t) => t.table_name);
472
- properties.table_name = {
473
- type: "string",
474
- enum: tableNames,
475
- description: `Which table is this query from. Every query has to select rows from one table, even if it is based on joins from different tables`,
476
- };
477
- properties.sql_id_query = {
478
- type: "string",
479
- description: `An SQL query for this table's primary keys. This must select only the primary keys (even if the user wants a count), for example SELECT ${
480
- tableNames[0][0]
481
- }."${Table.findOne(tableNames[0]).pk_name}" from "${tableNames[0]}" ${
482
- tableNames[0][0]
483
- } JOIN ... where... Use this to join other tables in the database.`,
484
- };
485
- properties.is_count = {
486
- type: "boolean",
487
- description: `Is the only desired output a count? Make this true if the user wants a count of rows`,
488
- };
470
+ if (config.tables.length) {
471
+ let properties = {};
472
+
473
+ const tableNames = (config?.tables || []).map((t) => t.table_name);
474
+ properties.table_name = {
475
+ type: "string",
476
+ enum: tableNames,
477
+ description: `Which table is this query from. Every query has to select rows from one table, even if it is based on joins from different tables`,
478
+ };
479
+ properties.sql_id_query = {
480
+ type: "string",
481
+ description: `An SQL query for this table's primary keys. This must select only the primary keys (even if the user wants a count), for example SELECT ${
482
+ tableNames[0][0]
483
+ }."${Table.findOne(tableNames[0]).pk_name}" from "${tableNames[0]}" ${
484
+ tableNames[0][0]
485
+ } JOIN ... where... Use this to join other tables in the database.`,
486
+ };
487
+ properties.is_count = {
488
+ type: "boolean",
489
+ description: `Is the only desired output a count? Make this true if the user wants a count of rows`,
490
+ };
489
491
 
490
- tools.push({
491
- type: "function",
492
- function: {
493
- name: "TableQuery",
494
- description: `Query a table and show the results to the user in a grid format`,
495
- parameters: {
496
- type: "object",
497
- required: ["table_name", "sql_id_query", "is_count"],
498
- properties,
492
+ tools.push({
493
+ type: "function",
494
+ function: {
495
+ name: "TableQuery",
496
+ description: `Query a table and show the results to the user in a grid format`,
497
+ parameters: {
498
+ type: "object",
499
+ required: ["table_name", "sql_id_query", "is_count"],
500
+ properties,
501
+ },
499
502
  },
500
- },
501
- });
503
+ });
504
+ }
502
505
 
503
506
  for (const action of config?.actions || []) {
504
507
  let properties = {};
@@ -536,18 +539,19 @@ const getCompletionArguments = async (config) => {
536
539
  const systemPrompt =
537
540
  "You are helping users retrieve information and perform actions on a relational database" +
538
541
  config.sys_prompt +
539
- `
540
- If you are generating SQL, Your database the following tables in PostgreSQL:
542
+ (config.tables.length
543
+ ? `
544
+ If you are generating SQL, Your database has the following tables in PostgreSQL:
541
545
 
542
546
  ` +
543
- tables
544
- .map(
545
- (t) => `CREATE TABLE "${t.name}" (${
546
- t.description
547
- ? `
547
+ tables
548
+ .map(
549
+ (t) => `CREATE TABLE "${t.name}" (${
550
+ t.description
551
+ ? `
548
552
  /* ${t.description} */`
549
- : ""
550
- }
553
+ : ""
554
+ }
551
555
  ${t.fields
552
556
  .map(
553
557
  (f) =>
@@ -559,15 +563,16 @@ ${t.fields
559
563
  )
560
564
  .join(",\n")}
561
565
  )`
562
- )
563
- .join(";\n\n") +
564
- `
566
+ )
567
+ .join(";\n\n") +
568
+ `
565
569
 
566
570
  Use the TableQuery tool if the user asks to see, find or count or otherwise access rows from a table that matches what the user is looking for, or if
567
571
  the user is asking for a summary or inference from such rows. The TableQuery query is parametrised by a SQL SELECT query which
568
572
  selects primary key values from the specified table. You can join other tables or use complex logic in the WHERE clause, but you must
569
573
  always return porimary key values from the specified table.
570
- `;
574
+ `
575
+ : "");
571
576
  //console.log("sysprompt", systemPrompt);
572
577
 
573
578
  if (tools.length === 0) tools = undefined;
@@ -600,7 +605,7 @@ const interact = async (table_id, viewname, config, body, { req, res }) => {
600
605
  interactions: [{ role: "user", content: userinput }],
601
606
  });
602
607
  }
603
- return await process_interaction(run, userinput, config, req);
608
+ return await process_interaction(run, config, req);
604
609
  };
605
610
 
606
611
  const renderQueryInteraction = async (table, result, config, req) => {
@@ -653,20 +658,13 @@ const renderQueryInteraction = async (table, result, config, req) => {
653
658
  );
654
659
  };
655
660
 
656
- const process_interaction = async (
657
- run,
658
- input,
659
- config,
660
- req,
661
- prevResponses = []
662
- ) => {
661
+ const process_interaction = async (run, config, req, prevResponses = []) => {
663
662
  const complArgs = await getCompletionArguments(config);
664
663
  complArgs.chat = run.context.interactions;
665
664
  //complArgs.debugResult = true;
666
- //console.log(complArgs);
667
- console.log("complArgs", JSON.stringify(complArgs, null, 2));
665
+ //console.log("complArgs", JSON.stringify(complArgs, null, 2));
668
666
 
669
- const answer = await getState().functions.llm_generate.run(input, complArgs);
667
+ const answer = await getState().functions.llm_generate.run("", complArgs);
670
668
  console.log("answer", answer);
671
669
  await addToContext(run, {
672
670
  interactions:
@@ -820,7 +818,7 @@ const process_interaction = async (
820
818
  }
821
819
  }
822
820
  if (hasResult)
823
- return await process_interaction(run, "", config, req, [
821
+ return await process_interaction(run, config, req, [
824
822
  ...prevResponses,
825
823
  ...responses,
826
824
  ]);