@saltcorn/copilot 0.7.1 → 0.7.3

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.
@@ -7,6 +7,122 @@ 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 }) => {
@@ -36,6 +152,30 @@ module.exports = {
36
152
  class: "validate-expression",
37
153
  type: "String",
38
154
  },
155
+ {
156
+ name: "design_image_expression",
157
+ label: "Design image expression",
158
+ sublabel:
159
+ "Optional expression returning a file, file id, data URL, or array with reference design images to guide generation",
160
+ class: "validate-expression",
161
+ type: "String",
162
+ },
163
+ {
164
+ name: "feedback_image_expression",
165
+ label: "Feedback image expression",
166
+ sublabel:
167
+ "Optional expression returning a file, file id, data URL, or array with annotated feedback screenshots",
168
+ class: "validate-expression",
169
+ type: "String",
170
+ },
171
+ {
172
+ name: "existing_page_name",
173
+ label: "Existing page name",
174
+ sublabel:
175
+ "Optional expression evaluating to the name of the page to use as a baseline. If provided, its HTML will be included automatically",
176
+ class: "validate-expression",
177
+ type: "String",
178
+ },
39
179
  {
40
180
  name: "existing_page_html",
41
181
  label: "Existing page HTML",
@@ -69,6 +209,9 @@ module.exports = {
69
209
  const textFields = table.fields
70
210
  .filter((f) => f.type?.sql_name === "text")
71
211
  .map((f) => f.name);
212
+ const fileFields = table.fields
213
+ .filter((f) => ["File", "Image"].includes(f.type?.name))
214
+ .map((f) => f.name);
72
215
 
73
216
  return [
74
217
  {
@@ -93,6 +236,30 @@ module.exports = {
93
236
  required: true,
94
237
  attributes: { options: textFields },
95
238
  },
239
+ {
240
+ name: "existing_page_field",
241
+ label: "Existing page name field",
242
+ sublabel:
243
+ "Optional text field storing the name of the page to update",
244
+ type: "String",
245
+ attributes: { options: textFields },
246
+ },
247
+ {
248
+ name: "design_image_field",
249
+ label: "Design image field",
250
+ sublabel:
251
+ "Optional file/image field containing a reference design supplied by the user",
252
+ type: "String",
253
+ attributes: { options: fileFields },
254
+ },
255
+ {
256
+ name: "feedback_image_field",
257
+ label: "Feedback image field",
258
+ sublabel:
259
+ "Optional file/image field with annotated feedback screenshots to consider during updates",
260
+ type: "String",
261
+ attributes: { options: fileFields },
262
+ },
96
263
  // ...override_fields,
97
264
  ];
98
265
  }
@@ -109,6 +276,12 @@ module.exports = {
109
276
  prompt_template,
110
277
  answer_field,
111
278
  image_prompt,
279
+ design_image_expression,
280
+ feedback_image_expression,
281
+ design_image_field,
282
+ feedback_image_field,
283
+ existing_page_field,
284
+ existing_page_name,
112
285
  existing_page_html,
113
286
  chat_history_field,
114
287
  convert_to_saltcorn,
@@ -117,7 +290,7 @@ module.exports = {
117
290
  }) => {
118
291
  let prompt;
119
292
  if (mode === "workflow") prompt = interpolate(prompt_template, row, user);
120
- else if (prompt_field === "Formula" || mode === "workflow")
293
+ else if (prompt_field === "Formula")
121
294
  prompt = eval_expression(
122
295
  prompt_formula,
123
296
  row,
@@ -125,61 +298,104 @@ module.exports = {
125
298
  "copilot_generate_page prompt formula",
126
299
  );
127
300
  else prompt = row[prompt_field];
128
-
129
- if(existing_page_html) {
130
- exist_html = eval_expression(
301
+
302
+ const resolvedExistingName = normalizeName(
303
+ mode === "workflow"
304
+ ? existing_page_name
305
+ ? eval_expression(
306
+ existing_page_name,
307
+ row,
308
+ user,
309
+ "copilot_generate_page existing page name",
310
+ )
311
+ : null
312
+ : existing_page_field
313
+ ? row?.[existing_page_field]
314
+ : null,
315
+ );
316
+ const existingAssets = resolvedExistingName
317
+ ? await loadExistingPageAssets(resolvedExistingName)
318
+ : { page: null, html: null };
319
+
320
+ let manualExistingHtml;
321
+ if (existing_page_html) {
322
+ manualExistingHtml = eval_expression(
131
323
  existing_page_html,
132
324
  row,
133
325
  user,
134
326
  "copilot_generate_page existing page html",
135
327
  );
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
328
  }
138
-
139
- const opts = {};
329
+ const existingHtmlForPrompt = manualExistingHtml || existingAssets.html;
330
+ if (existingHtmlForPrompt) {
331
+ const label = resolvedExistingName ? ` ${resolvedExistingName}` : "";
332
+ prompt = `This is the HTML code for the existing page${label} you should edit:\n\n\`\`\`html\n${existingHtmlForPrompt}\n\`\`\`\n\n${prompt}`;
333
+ }
140
334
 
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(
335
+ const chatMessages = [];
336
+ if (mode === "workflow") {
337
+ const referenceImages = await gatherImagesFromExpression(
156
338
  image_prompt,
157
339
  row,
158
340
  user,
159
341
  "copilot_generate_page image prompt",
160
342
  );
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
- }
343
+ chatMessages.push(
344
+ ...buildImageMessages(referenceImages, "Reference image"),
345
+ );
346
+ const designImages = await gatherImagesFromExpression(
347
+ design_image_expression,
348
+ row,
349
+ user,
350
+ "copilot_generate_page design image",
351
+ );
352
+ chatMessages.push(
353
+ ...buildImageMessages(designImages, "Design reference"),
354
+ );
355
+ const feedbackImages = await gatherImagesFromExpression(
356
+ feedback_image_expression,
357
+ row,
358
+ user,
359
+ "copilot_generate_page feedback image",
360
+ );
361
+ chatMessages.push(
362
+ ...buildImageMessages(feedbackImages, "Feedback reference"),
363
+ );
364
+ } else if (row) {
365
+ chatMessages.push(
366
+ ...buildImageMessages(
367
+ await gatherImageDataFromValue(row[design_image_field]),
368
+ "Design reference",
369
+ ),
370
+ );
371
+ chatMessages.push(
372
+ ...buildImageMessages(
373
+ await gatherImageDataFromValue(row[feedback_image_field]),
374
+ "Feedback reference",
375
+ ),
376
+ );
177
377
  }
178
- const initial_ans = await llm_generate.run(prompt, {
378
+ const chat = chatMessages.length ? chatMessages : undefined;
379
+
380
+ const systemPrompt = await GeneratePage.system_prompt();
381
+ const tools = [
382
+ {
383
+ type: "function",
384
+ function: {
385
+ name: GeneratePage.function_name,
386
+ description: GeneratePage.description,
387
+ parameters: await GeneratePage.json_schema(),
388
+ },
389
+ },
390
+ ];
391
+ const { llm_generate } = getState().functions;
392
+ const llmOptions = {
179
393
  tools,
180
394
  chat,
181
395
  systemPrompt,
182
- });
396
+ ...(model ? { model } : {}),
397
+ };
398
+ const initial_ans = await llm_generate.run(prompt, llmOptions);
183
399
  const initial_info =
184
400
  initial_ans.tool_calls[0].input ||
185
401
  JSON.parse(initial_ans.tool_calls[0].function.arguments);
@@ -195,55 +411,55 @@ module.exports = {
195
411
  and
196
412
 
197
413
  <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
- },
414
+ const generationPrompt = `${prompt}
415
+
416
+ The page title is: ${initial_info.title}.
417
+ Further page description: ${initial_info.description}.
418
+
419
+ Generate the HTML for the web page using the Bootstrap 5 CSS framework.
420
+
421
+ ${prompt_part_2}
422
+
423
+ Just generate HTML code, do not wrap in markdown code tags`;
424
+ const page_html = await llm_generate.run(generationPrompt, {
425
+ debugResult: true,
426
+ chat,
427
+ ...(model ? { model } : {}),
428
+ response_format: full.response_schema
429
+ ? {
430
+ type: "json_schema",
431
+ json_schema: {
432
+ name: "generate_page",
433
+ schema: full.response_schema,
434
+ },
435
+ }
436
+ : undefined,
437
+ });
438
+
439
+ const requestedName = page_name ? interpolate(page_name, row, user) : "";
440
+ const targetPageName = normalizeName(
441
+ requestedName || resolvedExistingName || "",
222
442
  );
443
+ const updatingExisting =
444
+ targetPageName &&
445
+ resolvedExistingName &&
446
+ targetPageName === resolvedExistingName &&
447
+ existingAssets.page;
223
448
 
224
- const use_page_name = page_name ? interpolate(page_name, row, user) : "";
225
- if (use_page_name) {
449
+ if (targetPageName) {
226
450
  let layout;
227
451
  if (convert_to_saltcorn) {
228
452
  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",
453
+ await upsertHtmlPreviewPage(
454
+ `${targetPageName}_html`,
233
455
  wrapExample(page_html),
234
- user.id,
235
- 100,
456
+ initial_info.title,
457
+ initial_info.description,
458
+ user,
236
459
  );
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
460
  } else {
245
461
  const file = await File.from_contents(
246
- `${use_page_name}.html`,
462
+ `${targetPageName}.html`,
247
463
  "text/html",
248
464
  page_html,
249
465
  user.id,
@@ -251,18 +467,26 @@ module.exports = {
251
467
  );
252
468
  layout = { html_file: file.path_to_serve };
253
469
  }
254
- //save to a file
255
470
 
256
- //create page
257
- await Page.create({
258
- name: use_page_name,
471
+ const pagePayload = {
259
472
  title: initial_info.title,
260
473
  description: initial_info.description,
261
- min_role: 100,
262
474
  layout,
263
- });
264
- setTimeout(() => getState().refresh_pages(), 200);
475
+ };
476
+
477
+ if (updatingExisting) {
478
+ await existingAssets.page.update(pagePayload);
479
+ refreshPagesSoon();
480
+ } else {
481
+ await Page.create({
482
+ name: targetPageName,
483
+ min_role: 100,
484
+ ...pagePayload,
485
+ });
486
+ refreshPagesSoon();
487
+ }
265
488
  }
489
+
266
490
  const upd = answer_field ? { [answer_field]: page_html } : {};
267
491
  if (mode === "workflow") return upd;
268
492
  else if (answer_field) await table.updateRow(upd, row[table.pk_name]);