@saltcorn/copilot 0.8.1 → 0.8.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.
- package/actions/generate-page.js +6 -2
- package/actions/generate-tables.js +70 -6
- package/actions/generate-workflow.js +54 -3
- package/agent-skills/pagegen.js +171 -59
- package/agent-skills/registry-editor.js +30 -2
- package/agent-skills/triggergen.js +1 -5
- package/agent-skills/viewgen.js +49 -7
- package/app-constructor/common.js +7 -1
- package/app-constructor/errors.js +749 -61
- package/app-constructor/feedback-action.js +62 -60
- package/app-constructor/feedback.js +1294 -67
- package/app-constructor/fixed-prompts.js +829 -0
- package/app-constructor/phases.js +1485 -0
- package/app-constructor/prompt-generator.js +587 -0
- package/app-constructor/requirements.js +171 -50
- package/app-constructor/research.js +350 -0
- package/app-constructor/run_task.js +234 -73
- package/app-constructor/schema.js +173 -169
- package/app-constructor/tasks.js +96 -537
- package/app-constructor/tools.js +17 -4
- package/app-constructor/view.js +314 -54
- package/builder-gen.js +90 -41
- package/builder-schema.js +6 -0
- package/copilot-as-agent.js +1 -0
- package/index.js +0 -1
- package/js-code-gen.js +1 -0
- package/package.json +1 -1
- package/relation-paths.js +73 -40
- package/standard-prompt.js +1 -0
- package/user-copilot.js +2 -0
- package/workflow-gen.js +1 -0
- package/app-constructor/prompts.js +0 -120
- package/chat-copilot.js +0 -770
|
@@ -1,112 +1,1339 @@
|
|
|
1
|
-
const
|
|
2
|
-
const Table = require("@saltcorn/data/models/table");
|
|
3
|
-
const Form = require("@saltcorn/data/models/form");
|
|
1
|
+
const feedbackAction = require("./feedback-action.js");
|
|
4
2
|
const MetaData = require("@saltcorn/data/models/metadata");
|
|
5
|
-
const View = require("@saltcorn/data/models/view");
|
|
6
|
-
const Trigger = require("@saltcorn/data/models/trigger");
|
|
7
|
-
const { findType } = require("@saltcorn/data/models/discovery");
|
|
8
3
|
const { save_menu_items } = require("@saltcorn/data/models/config");
|
|
9
|
-
const
|
|
10
|
-
const WorkflowRun = require("@saltcorn/data/models/workflow_run");
|
|
11
|
-
const {
|
|
12
|
-
localeDateTime,
|
|
13
|
-
renderForm,
|
|
14
|
-
mkTable,
|
|
15
|
-
post_delete_btn,
|
|
16
|
-
} = require("@saltcorn/markup");
|
|
4
|
+
const { mkTable } = require("@saltcorn/markup");
|
|
17
5
|
const {
|
|
18
6
|
div,
|
|
19
7
|
script,
|
|
20
8
|
domReady,
|
|
21
|
-
pre,
|
|
22
|
-
code,
|
|
23
|
-
input,
|
|
24
|
-
h4,
|
|
25
|
-
style,
|
|
26
9
|
h5,
|
|
27
10
|
button,
|
|
28
|
-
text_attr,
|
|
29
11
|
i,
|
|
30
12
|
p,
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
13
|
+
a,
|
|
14
|
+
hr,
|
|
15
|
+
input,
|
|
16
|
+
label,
|
|
34
17
|
textarea,
|
|
18
|
+
small,
|
|
19
|
+
span,
|
|
35
20
|
} = require("@saltcorn/markup/tags");
|
|
36
|
-
const { getState } = require("@saltcorn/data/db/state");
|
|
37
|
-
const renderLayout = require("@saltcorn/markup/layout");
|
|
21
|
+
const { getState, features } = require("@saltcorn/data/db/state");
|
|
38
22
|
const { viewname } = require("./common");
|
|
23
|
+
const { questions_tool } = require("./research");
|
|
24
|
+
const { PromptGenerator } = require("./prompt-generator");
|
|
39
25
|
|
|
40
|
-
|
|
41
|
-
|
|
26
|
+
/**
|
|
27
|
+
* Returns the Bootstrap modal HTML that prompts the user to analyse or skip.
|
|
28
|
+
*/
|
|
29
|
+
const feedbackClarifyModal = () =>
|
|
30
|
+
`<div class="modal fade" id="fb-clarify-modal" tabindex="-1" aria-hidden="true">
|
|
31
|
+
<div class="modal-dialog modal-dialog-centered">
|
|
32
|
+
<div class="modal-content">
|
|
33
|
+
<div class="modal-header border-0 pb-0">
|
|
34
|
+
<h5 class="modal-title fw-semibold">Analyse feedback?</h5>
|
|
35
|
+
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
36
|
+
</div>
|
|
37
|
+
<div class="modal-body pt-2">
|
|
38
|
+
<p class="text-muted small mb-0">Before saving, I can analyse your feedback and ask a few short questions to help clarify the requirements. Answering them produces more accurate tasks — but you can also save right away.</p>
|
|
39
|
+
</div>
|
|
40
|
+
<div class="modal-footer">
|
|
41
|
+
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal" onclick="fbSubmit()">Save without questions</button>
|
|
42
|
+
<button type="button" class="btn btn-primary" data-bs-dismiss="modal" onclick="fbAnalyseFeedback()">Analyse</button>
|
|
43
|
+
</div>
|
|
44
|
+
</div>
|
|
45
|
+
</div>
|
|
46
|
+
</div>`;
|
|
47
|
+
|
|
48
|
+
const scopeLabel = (scope, phases) => {
|
|
49
|
+
if (!scope || scope === "overall") return "Overall";
|
|
50
|
+
const m = scope.match(/^phase_(\d+)$/);
|
|
51
|
+
if (!m) return scope;
|
|
52
|
+
const idx = parseInt(m[1]);
|
|
53
|
+
const ph = phases[idx];
|
|
54
|
+
return ph ? `Phase ${idx + 1}: ${ph.name}` : `Phase ${idx + 1}`;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** Inner content of #feedback-views-area — data only, no JS. Swapped in on ajax refresh. */
|
|
58
|
+
const feedbackViewsContent = async () => {
|
|
59
|
+
const safeViewName = JSON.stringify(viewname);
|
|
60
|
+
const phasesMd = await MetaData.findOne({
|
|
42
61
|
type: "CopilotConstructMgr",
|
|
43
|
-
name: "
|
|
62
|
+
name: "phases",
|
|
63
|
+
});
|
|
64
|
+
const phases = phasesMd?.body?.phases || [];
|
|
65
|
+
|
|
66
|
+
const pendingMds = await MetaData.find(
|
|
67
|
+
{ type: "CopilotConstructMgr", name: "feedback_pending" },
|
|
68
|
+
{ orderBy: "id" }
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
const approvingIds = new Set(
|
|
72
|
+
(
|
|
73
|
+
await Promise.all(
|
|
74
|
+
pendingMds.map(async (r) => {
|
|
75
|
+
const md = await MetaData.findOne({
|
|
76
|
+
type: "CopilotConstructMgr",
|
|
77
|
+
name: `approving_feedback_${r.id}`,
|
|
78
|
+
});
|
|
79
|
+
return md ? r.id : null;
|
|
80
|
+
})
|
|
81
|
+
)
|
|
82
|
+
).filter(Boolean)
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const addButton = features.view_route_modal
|
|
86
|
+
? button(
|
|
87
|
+
{
|
|
88
|
+
class: "btn btn-outline-primary btn-sm mt-1",
|
|
89
|
+
title: "Submit feedback",
|
|
90
|
+
onclick: `ajax_modal('/view/${encodeURIComponent(
|
|
91
|
+
viewname
|
|
92
|
+
)}/get_feedback_form', {method:'POST'})`,
|
|
93
|
+
},
|
|
94
|
+
i({ class: "fas fa-plus me-1" }),
|
|
95
|
+
"Add feedback"
|
|
96
|
+
)
|
|
97
|
+
: small(
|
|
98
|
+
{ class: "text-muted mt-1 d-block" },
|
|
99
|
+
i({ class: "fas fa-info-circle me-1" }),
|
|
100
|
+
"Submitting feedback requires a newer version of Saltcorn."
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
let pendingSection;
|
|
104
|
+
if (!pendingMds.length) {
|
|
105
|
+
pendingSection =
|
|
106
|
+
h5({ class: "mb-2" }, "Pending feedback") +
|
|
107
|
+
p({ class: "text-muted mt-2" }, "No pending feedback submissions.") +
|
|
108
|
+
addButton;
|
|
109
|
+
} else {
|
|
110
|
+
const tableHtml = mkTable(
|
|
111
|
+
[
|
|
112
|
+
{ label: "Scope", key: (r) => scopeLabel(r.body.scope, phases) },
|
|
113
|
+
{ label: "Title", key: (r) => r.body.title },
|
|
114
|
+
{ label: "Description", key: (r) => r.body.description || "" },
|
|
115
|
+
{ label: "Status", key: (r) => r.body.status || "" },
|
|
116
|
+
{
|
|
117
|
+
label: "Actions",
|
|
118
|
+
key: (r) =>
|
|
119
|
+
a(
|
|
120
|
+
{
|
|
121
|
+
href: "#",
|
|
122
|
+
class: "btn btn-outline-primary btn-sm me-1",
|
|
123
|
+
onclick: `copilotOpenFeedbackEdit(${r.id});return false;`,
|
|
124
|
+
},
|
|
125
|
+
"Edit"
|
|
126
|
+
) +
|
|
127
|
+
(approvingIds.has(r.id)
|
|
128
|
+
? button(
|
|
129
|
+
{
|
|
130
|
+
class: "btn btn-success btn-sm me-1",
|
|
131
|
+
disabled: true,
|
|
132
|
+
"data-approving-id": r.id,
|
|
133
|
+
},
|
|
134
|
+
i({ class: "fas fa-spinner fa-spin" })
|
|
135
|
+
)
|
|
136
|
+
: button(
|
|
137
|
+
{
|
|
138
|
+
class: "btn btn-success btn-sm me-1",
|
|
139
|
+
id: `approve-btn-${r.id}`,
|
|
140
|
+
onclick: `copilotApprove(${r.id})`,
|
|
141
|
+
},
|
|
142
|
+
"Approve"
|
|
143
|
+
)) +
|
|
144
|
+
button(
|
|
145
|
+
{
|
|
146
|
+
class: "btn btn-outline-danger btn-sm",
|
|
147
|
+
onclick: `copilotDeleteFeedback(${r.id})`,
|
|
148
|
+
},
|
|
149
|
+
i({ class: "fas fa-trash-alt" })
|
|
150
|
+
),
|
|
151
|
+
},
|
|
152
|
+
],
|
|
153
|
+
pendingMds
|
|
154
|
+
);
|
|
155
|
+
pendingSection =
|
|
156
|
+
h5({ class: "mb-2" }, "Pending feedback") + tableHtml + addButton;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const processed = await MetaData.find(
|
|
160
|
+
{ type: "CopilotConstructMgr", name: "feedback" },
|
|
161
|
+
{ orderBy: "written_at" }
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
const allTasks = await MetaData.find({
|
|
165
|
+
type: "CopilotConstructMgr",
|
|
166
|
+
name: "task",
|
|
44
167
|
});
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
168
|
+
const feedbackTasks = allTasks.filter((t) => t.body?.source === "feedback");
|
|
169
|
+
const tasksByTitle = {};
|
|
170
|
+
for (const t of feedbackTasks) {
|
|
171
|
+
const key = t.body.feedback_title || "";
|
|
172
|
+
if (!tasksByTitle[key]) tasksByTitle[key] = [];
|
|
173
|
+
tasksByTitle[key].push(t);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const taskBadge = (t) => {
|
|
177
|
+
const status = t.body.status;
|
|
178
|
+
if (!status || status === "Pending")
|
|
179
|
+
return button(
|
|
180
|
+
{
|
|
181
|
+
class: "btn btn-outline-primary btn-sm",
|
|
182
|
+
id: `fb-task-run-btn-${t.id}`,
|
|
183
|
+
onclick: `copilotRunFeedbackTask(${t.id})`,
|
|
184
|
+
title: t.body.name,
|
|
185
|
+
},
|
|
186
|
+
i({ class: "fas fa-play me-1" }),
|
|
187
|
+
span({ class: "text-truncate d-inline-block", style: "max-width:120px;vertical-align:middle" }, t.body.name)
|
|
188
|
+
);
|
|
189
|
+
if (status === "Running")
|
|
190
|
+
return span(
|
|
191
|
+
{ class: "badge bg-warning text-dark", title: t.body.name },
|
|
192
|
+
i({ class: "fas fa-spinner fa-spin me-1" }),
|
|
193
|
+
"Running"
|
|
194
|
+
);
|
|
195
|
+
if (status === "Done")
|
|
196
|
+
return span(
|
|
197
|
+
{ class: "badge bg-success", title: t.body.name },
|
|
198
|
+
i({ class: "fas fa-check me-1" }),
|
|
199
|
+
"Done"
|
|
200
|
+
);
|
|
201
|
+
return span(
|
|
202
|
+
{ class: "badge bg-danger", title: t.body.name },
|
|
203
|
+
status
|
|
204
|
+
);
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const processedSection = div(
|
|
208
|
+
{ class: "mt-4" },
|
|
209
|
+
h5("Approved feedback"),
|
|
210
|
+
processed.length
|
|
211
|
+
? div(
|
|
212
|
+
mkTable(
|
|
213
|
+
[
|
|
214
|
+
{ label: "Scope", key: (m) => scopeLabel(m.body.scope, phases) },
|
|
215
|
+
{ label: "Title", key: (m) => m.body.title },
|
|
216
|
+
{ label: "Description", key: (m) => m.body.description },
|
|
217
|
+
{
|
|
218
|
+
label: "Tasks",
|
|
219
|
+
key: (m) => {
|
|
220
|
+
const tasks = tasksByTitle[m.body.title] || [];
|
|
221
|
+
if (!tasks.length) return span({ class: "text-muted small" }, "—");
|
|
222
|
+
return div(
|
|
223
|
+
{ class: "d-flex flex-column gap-1" },
|
|
224
|
+
...tasks.map(taskBadge)
|
|
225
|
+
);
|
|
226
|
+
},
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
label: "",
|
|
230
|
+
key: (r) =>
|
|
231
|
+
button(
|
|
232
|
+
{
|
|
233
|
+
class: "btn btn-outline-secondary btn-sm me-1",
|
|
234
|
+
onclick: `copilotShowProcessedFeedback(${r.id})`,
|
|
235
|
+
},
|
|
236
|
+
i({ class: "fas fa-eye" })
|
|
237
|
+
) +
|
|
238
|
+
button(
|
|
239
|
+
{
|
|
240
|
+
class: "btn btn-outline-danger btn-sm",
|
|
241
|
+
onclick: `view_post(${safeViewName}, "del_feedback", {id:${r.id}}, refreshFeedbackViews)`,
|
|
242
|
+
},
|
|
243
|
+
i({ class: "fas fa-trash-alt" })
|
|
244
|
+
),
|
|
245
|
+
},
|
|
246
|
+
],
|
|
247
|
+
processed
|
|
248
|
+
),
|
|
249
|
+
button(
|
|
250
|
+
{
|
|
251
|
+
class: "btn btn-outline-danger btn-sm",
|
|
252
|
+
onclick: `view_post(${safeViewName}, "del_all_feedback", {}, refreshFeedbackViews)`,
|
|
253
|
+
},
|
|
254
|
+
"Delete all"
|
|
255
|
+
)
|
|
256
|
+
)
|
|
257
|
+
: p({ class: "text-muted" }, "No processed feedback yet.")
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
return pendingSection + processedSection;
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
/** Shared form body — phases baked into the scope select. Used by both the inline modal and the POST route popup. */
|
|
264
|
+
const feedbackFormInner = (phases, preselectedScope = "") => {
|
|
265
|
+
const sel = (val) => (preselectedScope === val ? " selected" : "");
|
|
266
|
+
const scopeOptions = [
|
|
267
|
+
`<option value="overall"${sel("overall")}>Overall</option>`,
|
|
268
|
+
...phases.map(
|
|
269
|
+
(ph, idx) =>
|
|
270
|
+
`<option value="phase_${idx}"${sel(`phase_${idx}`)}>Phase ${idx + 1}: ${
|
|
271
|
+
ph.name
|
|
272
|
+
}</option>`
|
|
273
|
+
),
|
|
274
|
+
].join("");
|
|
275
|
+
|
|
276
|
+
return (
|
|
277
|
+
div(
|
|
278
|
+
{ id: "fb-step1" },
|
|
279
|
+
small(
|
|
280
|
+
{ class: "text-muted d-block mb-3" },
|
|
281
|
+
"Describe a feature request, bug, or improvement."
|
|
282
|
+
),
|
|
283
|
+
div(
|
|
284
|
+
{ id: "fb-fields" },
|
|
285
|
+
div(
|
|
286
|
+
{ class: "mb-3" },
|
|
287
|
+
label({ class: "form-label", for: "fb-title" }, "Title"),
|
|
288
|
+
input({ type: "text", class: "form-control", id: "fb-title" })
|
|
289
|
+
),
|
|
290
|
+
div(
|
|
291
|
+
{ class: "mb-3" },
|
|
292
|
+
label({ class: "form-label", for: "fb-desc" }, "Description"),
|
|
293
|
+
textarea({ class: "form-control", id: "fb-desc", rows: 3 }, "")
|
|
294
|
+
),
|
|
295
|
+
div(
|
|
296
|
+
{ class: "mb-3" },
|
|
297
|
+
label({ class: "form-label", for: "fb-scope" }, "Phase"),
|
|
298
|
+
`<select class="form-select" id="fb-scope">${scopeOptions}</select>`,
|
|
299
|
+
small(
|
|
300
|
+
{ class: "form-text text-muted" },
|
|
301
|
+
"Select a phase if this feedback applies to a specific part of the build, or leave as Overall."
|
|
302
|
+
)
|
|
303
|
+
),
|
|
304
|
+
div(
|
|
305
|
+
{ class: "mb-3" },
|
|
306
|
+
label({ class: "form-label", for: "fb-url" }, "Page URL"),
|
|
307
|
+
input({
|
|
308
|
+
type: "text",
|
|
309
|
+
class: "form-control",
|
|
310
|
+
id: "fb-url",
|
|
311
|
+
placeholder: "Optional — the page this feedback relates to",
|
|
312
|
+
})
|
|
313
|
+
)
|
|
314
|
+
),
|
|
315
|
+
div(
|
|
316
|
+
{ id: "fb-analyse-area", class: "mb-2" },
|
|
317
|
+
button(
|
|
50
318
|
{
|
|
51
|
-
|
|
52
|
-
|
|
319
|
+
type: "button",
|
|
320
|
+
class: "btn btn-outline-secondary btn-sm",
|
|
321
|
+
onclick: "fbAnalyseFeedback()",
|
|
53
322
|
},
|
|
323
|
+
i({ class: "fas fa-search me-1" }),
|
|
324
|
+
"Analyse feedback"
|
|
325
|
+
),
|
|
326
|
+
small(
|
|
327
|
+
{ class: "text-muted d-block mt-1" },
|
|
328
|
+
"Checks if anything needs clarifying before saving."
|
|
329
|
+
)
|
|
330
|
+
),
|
|
331
|
+
div(
|
|
332
|
+
{ id: "fb-regen-area", class: "mb-2", style: "display:none" },
|
|
333
|
+
button(
|
|
54
334
|
{
|
|
55
|
-
|
|
56
|
-
|
|
335
|
+
type: "button",
|
|
336
|
+
class: "btn btn-outline-secondary btn-sm me-2",
|
|
337
|
+
onclick: "fbAnalyseFeedback()",
|
|
57
338
|
},
|
|
58
|
-
|
|
339
|
+
i({ class: "fas fa-sync me-1" }),
|
|
340
|
+
"Regenerate"
|
|
341
|
+
),
|
|
342
|
+
button(
|
|
59
343
|
{
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
{
|
|
64
|
-
class: "btn btn-outline-danger btn-sm",
|
|
65
|
-
onclick: `view_post("${viewname}", "del_feedback", {id:${r.id}})`,
|
|
66
|
-
},
|
|
67
|
-
i({ class: "fas fa-trash-alt" }),
|
|
68
|
-
),
|
|
344
|
+
type: "button",
|
|
345
|
+
class: "btn btn-outline-danger btn-sm",
|
|
346
|
+
onclick: "fbDismissQuestions()",
|
|
69
347
|
},
|
|
70
|
-
|
|
71
|
-
|
|
348
|
+
"Dismiss"
|
|
349
|
+
)
|
|
72
350
|
),
|
|
73
|
-
|
|
351
|
+
div(
|
|
74
352
|
{
|
|
75
|
-
|
|
76
|
-
|
|
353
|
+
id: "fb-spinner",
|
|
354
|
+
class: "my-2 text-muted small",
|
|
355
|
+
style: "display:none",
|
|
77
356
|
},
|
|
78
|
-
"
|
|
357
|
+
i({ class: "fas fa-spinner fa-spin me-2" }),
|
|
358
|
+
"Analysing feedback..."
|
|
359
|
+
),
|
|
360
|
+
div({ id: "fb-questions-area" }),
|
|
361
|
+
div(
|
|
362
|
+
{
|
|
363
|
+
id: "fb-clear-area",
|
|
364
|
+
class: "alert alert-success py-2 mt-2",
|
|
365
|
+
style: "display:none",
|
|
366
|
+
},
|
|
367
|
+
i({ class: "fas fa-check-circle me-2" }),
|
|
368
|
+
"Feedback is clear — no questions needed."
|
|
369
|
+
),
|
|
370
|
+
div(
|
|
371
|
+
{ class: "mt-3" },
|
|
372
|
+
button(
|
|
373
|
+
{
|
|
374
|
+
type: "button",
|
|
375
|
+
class: "btn btn-primary",
|
|
376
|
+
onclick: "fbSaveOrPrompt()",
|
|
377
|
+
},
|
|
378
|
+
"Save feedback"
|
|
379
|
+
)
|
|
380
|
+
)
|
|
381
|
+
) +
|
|
382
|
+
div(
|
|
383
|
+
{ id: "fb-success", class: "text-success mt-3", style: "display:none" },
|
|
384
|
+
i({ class: "fas fa-check-circle me-2" }),
|
|
385
|
+
"Thank you! Your feedback has been submitted."
|
|
386
|
+
) +
|
|
387
|
+
feedbackClarifyModal()
|
|
388
|
+
);
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
/** Outer shell of the feedback tab — renders once, includes modals, JS, and #feedback-views-area. */
|
|
392
|
+
const feedbackList = async () => {
|
|
393
|
+
const phasesMd = await MetaData.findOne({
|
|
394
|
+
type: "CopilotConstructMgr",
|
|
395
|
+
name: "phases",
|
|
396
|
+
});
|
|
397
|
+
const phases = phasesMd?.body?.phases || [];
|
|
398
|
+
const safeViewName = JSON.stringify(viewname);
|
|
399
|
+
|
|
400
|
+
const topSection = div(
|
|
401
|
+
{ id: "feedback-views-area" },
|
|
402
|
+
await feedbackViewsContent()
|
|
403
|
+
);
|
|
404
|
+
|
|
405
|
+
const clientScript = script(
|
|
406
|
+
domReady(`
|
|
407
|
+
const safeViewName = ${safeViewName};
|
|
408
|
+
const _pollingIds = new Set();
|
|
409
|
+
function startApprovalPolling() {
|
|
410
|
+
document.querySelectorAll('[data-approving-id]').forEach(el => {
|
|
411
|
+
const id = parseInt(el.dataset.approvingId);
|
|
412
|
+
if (_pollingIds.has(id)) return;
|
|
413
|
+
_pollingIds.add(id);
|
|
414
|
+
const poll = () => {
|
|
415
|
+
view_post(safeViewName, 'approval_status', { id }, (resp) => {
|
|
416
|
+
if (resp && !resp.approving) {
|
|
417
|
+
_pollingIds.delete(id);
|
|
418
|
+
refreshFeedbackViews();
|
|
419
|
+
refreshReqTaskAreas();
|
|
420
|
+
}
|
|
421
|
+
else setTimeout(poll, 3000);
|
|
422
|
+
});
|
|
423
|
+
};
|
|
424
|
+
setTimeout(poll, 3000);
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
function refreshReqTaskAreas() {
|
|
428
|
+
view_post(safeViewName, 'req_list_html', {}, (r) => {
|
|
429
|
+
const el = document.getElementById('req-list-area');
|
|
430
|
+
if (r && r.html && el) el.innerHTML = r.html;
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
window.refreshFeedbackViews = () => {
|
|
434
|
+
view_post(safeViewName, 'feedback_views_html', {}, (r) => {
|
|
435
|
+
if (r && r.html) {
|
|
436
|
+
document.getElementById('feedback-views-area').innerHTML = r.html;
|
|
437
|
+
startApprovalPolling();
|
|
438
|
+
}
|
|
439
|
+
});
|
|
440
|
+
};
|
|
441
|
+
window.copilotAddFeedbackToMenu = () => {
|
|
442
|
+
view_post(safeViewName, 'add_feedback_to_menu', {}, (resp) => {
|
|
443
|
+
if (resp && !resp.error) location.reload();
|
|
444
|
+
});
|
|
445
|
+
};
|
|
446
|
+
window.copilotApprove = (id) => {
|
|
447
|
+
const btn = document.getElementById('approve-btn-' + id);
|
|
448
|
+
if (btn) { btn.disabled = true; btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i>'; }
|
|
449
|
+
_pollingIds.add(id);
|
|
450
|
+
view_post(safeViewName, 'start_approve_feedback', { id }, () => {
|
|
451
|
+
const poll = () => {
|
|
452
|
+
view_post(safeViewName, 'approval_status', { id }, (resp) => {
|
|
453
|
+
if (resp && !resp.approving) {
|
|
454
|
+
_pollingIds.delete(id);
|
|
455
|
+
refreshFeedbackViews();
|
|
456
|
+
refreshReqTaskAreas();
|
|
457
|
+
}
|
|
458
|
+
else setTimeout(poll, 3000);
|
|
459
|
+
});
|
|
460
|
+
};
|
|
461
|
+
setTimeout(poll, 3000);
|
|
462
|
+
});
|
|
463
|
+
};
|
|
464
|
+
window.copilotDeleteFeedback = (id) => {
|
|
465
|
+
view_post(safeViewName, 'delete_feedback_row', { id }, (resp) => {
|
|
466
|
+
if (resp && !resp.error) refreshFeedbackViews();
|
|
467
|
+
});
|
|
468
|
+
};
|
|
469
|
+
window.copilotOpenFeedbackEdit = (id) => {
|
|
470
|
+
view_post(safeViewName, 'get_feedback_edit_html', { id }, (resp) => {
|
|
471
|
+
if (!resp || !resp.html) return;
|
|
472
|
+
document.getElementById('fb-edit-modal-body').innerHTML = resp.html;
|
|
473
|
+
document.getElementById('fb-edit-modal').dataset.feedbackId = id;
|
|
474
|
+
new bootstrap.Modal(document.getElementById('fb-edit-modal')).show();
|
|
475
|
+
});
|
|
476
|
+
};
|
|
477
|
+
window.copilotSaveFeedbackEdit = () => {
|
|
478
|
+
const id = document.getElementById('fb-edit-modal').dataset.feedbackId;
|
|
479
|
+
const payload = {
|
|
480
|
+
id,
|
|
481
|
+
title: document.getElementById('fbed-title').value,
|
|
482
|
+
description: document.getElementById('fbed-desc').value,
|
|
483
|
+
url: document.getElementById('fbed-url').value,
|
|
484
|
+
};
|
|
485
|
+
document.querySelectorAll('.fbed-answer').forEach(el => {
|
|
486
|
+
payload[el.dataset.q] = el.value;
|
|
487
|
+
});
|
|
488
|
+
view_post(safeViewName, 'save_feedback_edit', payload, (resp) => {
|
|
489
|
+
if (resp && !resp.error) {
|
|
490
|
+
bootstrap.Modal.getInstance(document.getElementById('fb-edit-modal')).hide();
|
|
491
|
+
refreshFeedbackViews();
|
|
492
|
+
}
|
|
493
|
+
});
|
|
494
|
+
};
|
|
495
|
+
window.copilotRunFeedbackTask = (taskId) => {
|
|
496
|
+
const btn = document.getElementById('fb-task-run-btn-' + taskId);
|
|
497
|
+
if (btn) { btn.disabled = true; btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i>'; }
|
|
498
|
+
view_post(safeViewName, 'run_task', { id: taskId }, () => {
|
|
499
|
+
const poll = () => {
|
|
500
|
+
view_post(safeViewName, 'task_status', { ids: [String(taskId)] }, (resp) => {
|
|
501
|
+
if (resp && resp.any_done) refreshFeedbackViews();
|
|
502
|
+
else setTimeout(poll, 3000);
|
|
503
|
+
});
|
|
504
|
+
};
|
|
505
|
+
setTimeout(poll, 3000);
|
|
506
|
+
});
|
|
507
|
+
};
|
|
508
|
+
window.copilotShowProcessedFeedback = (id) => {
|
|
509
|
+
view_post(safeViewName, 'show_processed_feedback', { id }, (resp) => {
|
|
510
|
+
if (!resp || !resp.html) return;
|
|
511
|
+
document.getElementById('fb-details-modal-body').innerHTML = resp.html;
|
|
512
|
+
new bootstrap.Modal(document.getElementById('fb-details-modal')).show();
|
|
513
|
+
});
|
|
514
|
+
};
|
|
515
|
+
document.addEventListener('shown.bs.tab', () => startApprovalPolling());
|
|
516
|
+
startApprovalPolling();
|
|
517
|
+
`)
|
|
518
|
+
);
|
|
519
|
+
|
|
520
|
+
const editModal = div(
|
|
521
|
+
{
|
|
522
|
+
class: "modal fade",
|
|
523
|
+
id: "fb-edit-modal",
|
|
524
|
+
tabindex: "-1",
|
|
525
|
+
"aria-hidden": "true",
|
|
526
|
+
},
|
|
527
|
+
div(
|
|
528
|
+
{ class: "modal-dialog modal-lg" },
|
|
529
|
+
div(
|
|
530
|
+
{ class: "modal-content" },
|
|
531
|
+
div(
|
|
532
|
+
{ class: "modal-header" },
|
|
533
|
+
h5({ class: "modal-title" }, "Edit feedback"),
|
|
534
|
+
button({
|
|
535
|
+
type: "button",
|
|
536
|
+
class: "btn-close",
|
|
537
|
+
"data-bs-dismiss": "modal",
|
|
538
|
+
"aria-label": "Close",
|
|
539
|
+
})
|
|
540
|
+
),
|
|
541
|
+
div({ class: "modal-body", id: "fb-edit-modal-body" }),
|
|
542
|
+
div(
|
|
543
|
+
{ class: "modal-footer" },
|
|
544
|
+
button(
|
|
545
|
+
{
|
|
546
|
+
type: "button",
|
|
547
|
+
class: "btn btn-outline-secondary",
|
|
548
|
+
"data-bs-dismiss": "modal",
|
|
549
|
+
},
|
|
550
|
+
"Cancel"
|
|
551
|
+
),
|
|
552
|
+
button(
|
|
553
|
+
{
|
|
554
|
+
type: "button",
|
|
555
|
+
class: "btn btn-primary",
|
|
556
|
+
onclick: "copilotSaveFeedbackEdit()",
|
|
557
|
+
},
|
|
558
|
+
"Save"
|
|
559
|
+
)
|
|
560
|
+
)
|
|
561
|
+
)
|
|
562
|
+
)
|
|
563
|
+
);
|
|
564
|
+
|
|
565
|
+
const detailsModal = div(
|
|
566
|
+
{
|
|
567
|
+
class: "modal fade",
|
|
568
|
+
id: "fb-details-modal",
|
|
569
|
+
tabindex: "-1",
|
|
570
|
+
"aria-hidden": "true",
|
|
571
|
+
},
|
|
572
|
+
div(
|
|
573
|
+
{ class: "modal-dialog modal-lg" },
|
|
574
|
+
div(
|
|
575
|
+
{ class: "modal-content" },
|
|
576
|
+
div(
|
|
577
|
+
{ class: "modal-header" },
|
|
578
|
+
h5({ class: "modal-title" }, "Feedback details"),
|
|
579
|
+
button({
|
|
580
|
+
type: "button",
|
|
581
|
+
class: "btn-close",
|
|
582
|
+
"data-bs-dismiss": "modal",
|
|
583
|
+
"aria-label": "Close",
|
|
584
|
+
})
|
|
585
|
+
),
|
|
586
|
+
div({ class: "modal-body", id: "fb-details-modal-body" }),
|
|
587
|
+
div(
|
|
588
|
+
{ class: "modal-footer" },
|
|
589
|
+
button(
|
|
590
|
+
{
|
|
591
|
+
type: "button",
|
|
592
|
+
class: "btn btn-secondary",
|
|
593
|
+
"data-bs-dismiss": "modal",
|
|
594
|
+
},
|
|
595
|
+
"Close"
|
|
596
|
+
)
|
|
597
|
+
)
|
|
598
|
+
)
|
|
599
|
+
)
|
|
600
|
+
);
|
|
601
|
+
|
|
602
|
+
const menuItems = getState().getConfig("menu_items", []);
|
|
603
|
+
const feedbackInMenu = menuItems.some(
|
|
604
|
+
(mi) => mi.type === "Link" && mi.url?.includes("get_feedback_form")
|
|
605
|
+
);
|
|
606
|
+
const navbarBtn = !features.view_route_modal
|
|
607
|
+
? div(
|
|
608
|
+
{ class: "mt-3" },
|
|
609
|
+
small(
|
|
610
|
+
{ class: "text-muted" },
|
|
611
|
+
i({ class: "fas fa-info-circle me-1" }),
|
|
612
|
+
"Adding a feedback button to the navbar requires a newer version of Saltcorn."
|
|
613
|
+
)
|
|
614
|
+
)
|
|
615
|
+
: feedbackInMenu
|
|
616
|
+
? ""
|
|
617
|
+
: div(
|
|
618
|
+
{ class: "mt-3" },
|
|
619
|
+
button(
|
|
620
|
+
{
|
|
621
|
+
class: "btn btn-outline-secondary btn-sm",
|
|
622
|
+
onclick: "copilotAddFeedbackToMenu()",
|
|
623
|
+
title: "Add a Feedback button to the site navigation bar",
|
|
624
|
+
},
|
|
625
|
+
i({ class: "fas fa-bars me-1" }),
|
|
626
|
+
"Add feedback button to navbar"
|
|
627
|
+
)
|
|
628
|
+
);
|
|
629
|
+
|
|
630
|
+
return div(
|
|
631
|
+
{ class: "mt-2" },
|
|
632
|
+
topSection,
|
|
633
|
+
navbarBtn,
|
|
634
|
+
editModal,
|
|
635
|
+
detailsModal,
|
|
636
|
+
clientScript
|
|
637
|
+
);
|
|
638
|
+
};
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* Route: returns the rendered feedbackViewsContent HTML for in-place ajax refresh.
|
|
642
|
+
*/
|
|
643
|
+
const feedback_views_html = async (
|
|
644
|
+
table_id,
|
|
645
|
+
vn,
|
|
646
|
+
config,
|
|
647
|
+
body,
|
|
648
|
+
{ req, res }
|
|
649
|
+
) => {
|
|
650
|
+
const html = await feedbackViewsContent();
|
|
651
|
+
return { json: { html } };
|
|
652
|
+
};
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* Route: starts async approval of a pending feedback row.
|
|
656
|
+
* Runs feedbackAction in the background, then deletes the MetaData record and its research metadata.
|
|
657
|
+
*/
|
|
658
|
+
const start_approve_feedback = async (
|
|
659
|
+
table_id,
|
|
660
|
+
vn,
|
|
661
|
+
config,
|
|
662
|
+
body,
|
|
663
|
+
{ req, res }
|
|
664
|
+
) => {
|
|
665
|
+
const id = parseInt(body.id);
|
|
666
|
+
const mdRow = await MetaData.findOne({
|
|
667
|
+
id,
|
|
668
|
+
type: "CopilotConstructMgr",
|
|
669
|
+
name: "feedback_pending",
|
|
670
|
+
});
|
|
671
|
+
if (!mdRow) return { json: { error: "Not found" } };
|
|
672
|
+
const row = mdRow.body;
|
|
673
|
+
|
|
674
|
+
const mdName = `approving_feedback_${id}`;
|
|
675
|
+
await MetaData.create({
|
|
676
|
+
type: "CopilotConstructMgr",
|
|
677
|
+
name: mdName,
|
|
678
|
+
body: { id },
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
const researchMd = await MetaData.findOne({
|
|
682
|
+
type: "CopilotConstructMgr",
|
|
683
|
+
name: `feedback_research_${id}`,
|
|
684
|
+
});
|
|
685
|
+
let research_context = null;
|
|
686
|
+
if (researchMd) {
|
|
687
|
+
const questions = researchMd.body.questions || [];
|
|
688
|
+
const answers = researchMd.body.answers || {};
|
|
689
|
+
const pairs = questions.map((q, idx) => {
|
|
690
|
+
const a = answers[`q${idx + 1}`];
|
|
691
|
+
return `Q: ${q}\nA: ${a && a.trim() ? a.trim() : "(no answer)"}`;
|
|
692
|
+
});
|
|
693
|
+
if (pairs.length) research_context = pairs.join("\n\n");
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
if (row.phase_idx != null) {
|
|
697
|
+
{
|
|
698
|
+
const phIdx = row.phase_idx;
|
|
699
|
+
const phasesMd = await MetaData.findOne({
|
|
700
|
+
type: "CopilotConstructMgr",
|
|
701
|
+
name: "phases",
|
|
702
|
+
});
|
|
703
|
+
const ph = phasesMd?.body?.phases?.[phIdx];
|
|
704
|
+
if (ph) {
|
|
705
|
+
const allPhaseRecords = await MetaData.find({
|
|
706
|
+
type: "CopilotConstructMgr",
|
|
707
|
+
});
|
|
708
|
+
const forPhase = (name) =>
|
|
709
|
+
allPhaseRecords.filter(
|
|
710
|
+
(r) => r.name === name && r.body?.phase_idx === phIdx
|
|
711
|
+
);
|
|
712
|
+
|
|
713
|
+
const tableLines = forPhase("table_phase").map(
|
|
714
|
+
(r) => `- ${r.body.table_name}`
|
|
715
|
+
);
|
|
716
|
+
const pluginLines = forPhase("plugin_phase").map(
|
|
717
|
+
(r) => `- ${r.body.plugin_name}`
|
|
718
|
+
);
|
|
719
|
+
const viewLines = forPhase("view_phase").map((r) =>
|
|
720
|
+
r.body.viewtemplate === "page"
|
|
721
|
+
? `- page: ${r.body.view_name}`
|
|
722
|
+
: `- view: ${r.body.view_name} (${r.body.viewtemplate})`
|
|
723
|
+
);
|
|
724
|
+
|
|
725
|
+
const sections = [
|
|
726
|
+
pluginLines.length
|
|
727
|
+
? `Plugins installed in this phase:\n${pluginLines.join("\n")}`
|
|
728
|
+
: "",
|
|
729
|
+
tableLines.length
|
|
730
|
+
? `Tables created in this phase:\n${tableLines.join("\n")}`
|
|
731
|
+
: "",
|
|
732
|
+
viewLines.length
|
|
733
|
+
? `Views and pages created in this phase:\n${viewLines.join("\n")}`
|
|
734
|
+
: "",
|
|
735
|
+
].filter(Boolean);
|
|
736
|
+
|
|
737
|
+
const phaseNote = `This feedback is scoped to Phase ${phIdx + 1}: ${
|
|
738
|
+
ph.name
|
|
739
|
+
}. ${ph.description}${
|
|
740
|
+
sections.length ? "\n\n" + sections.join("\n\n") : ""
|
|
741
|
+
}`;
|
|
742
|
+
research_context = research_context
|
|
743
|
+
? phaseNote + "\n\n" + research_context
|
|
744
|
+
: phaseNote;
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
const existingTaskIds = new Set(
|
|
750
|
+
(await MetaData.find({ type: "CopilotConstructMgr", name: "task" })).map(
|
|
751
|
+
(t) => t.id
|
|
752
|
+
)
|
|
753
|
+
);
|
|
754
|
+
|
|
755
|
+
feedbackAction
|
|
756
|
+
.run({
|
|
757
|
+
row,
|
|
758
|
+
user: req.user,
|
|
759
|
+
mode: "table",
|
|
760
|
+
req,
|
|
761
|
+
configuration: {
|
|
762
|
+
title_field: "title",
|
|
763
|
+
description_field: "description",
|
|
764
|
+
url_field: "url",
|
|
765
|
+
research_context,
|
|
766
|
+
},
|
|
767
|
+
})
|
|
768
|
+
.then(async () => {
|
|
769
|
+
if (row.phase_idx != null) {
|
|
770
|
+
const phIdx = row.phase_idx;
|
|
771
|
+
const phasesMd = await MetaData.findOne({
|
|
772
|
+
type: "CopilotConstructMgr",
|
|
773
|
+
name: "phases",
|
|
774
|
+
});
|
|
775
|
+
const phaseName = phasesMd?.body?.phases?.[phIdx]?.name;
|
|
776
|
+
const allTasks = await MetaData.find({
|
|
777
|
+
type: "CopilotConstructMgr",
|
|
778
|
+
name: "task",
|
|
779
|
+
});
|
|
780
|
+
for (const t of allTasks.filter((t) => !existingTaskIds.has(t.id)))
|
|
781
|
+
await t.update({
|
|
782
|
+
body: { ...t.body, phase_idx: phIdx, phase_name: phaseName },
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
const md = await MetaData.findOne({
|
|
786
|
+
type: "CopilotConstructMgr",
|
|
787
|
+
name: mdName,
|
|
788
|
+
});
|
|
789
|
+
if (md) await md.delete();
|
|
790
|
+
await mdRow.delete();
|
|
791
|
+
const rmd = await MetaData.findOne({
|
|
792
|
+
type: "CopilotConstructMgr",
|
|
793
|
+
name: `feedback_research_${id}`,
|
|
794
|
+
});
|
|
795
|
+
if (rmd) await rmd.delete();
|
|
796
|
+
})
|
|
797
|
+
.catch(async (e) => {
|
|
798
|
+
console.error("approve_feedback error", e);
|
|
799
|
+
const md = await MetaData.findOne({
|
|
800
|
+
type: "CopilotConstructMgr",
|
|
801
|
+
name: mdName,
|
|
802
|
+
});
|
|
803
|
+
if (md) await md.delete();
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
return { json: { success: true } };
|
|
807
|
+
};
|
|
808
|
+
|
|
809
|
+
/**
|
|
810
|
+
* Route: polls whether a given feedback row is still being approved.
|
|
811
|
+
*/
|
|
812
|
+
const approval_status = async (table_id, vn, config, body, { req, res }) => {
|
|
813
|
+
const id = parseInt(body.id);
|
|
814
|
+
const md = await MetaData.findOne({
|
|
815
|
+
type: "CopilotConstructMgr",
|
|
816
|
+
name: `approving_feedback_${id}`,
|
|
817
|
+
});
|
|
818
|
+
return { json: { approving: !!md } };
|
|
819
|
+
};
|
|
820
|
+
|
|
821
|
+
/**
|
|
822
|
+
* Route: deletes a pending feedback MetaData record and its associated research metadata.
|
|
823
|
+
*/
|
|
824
|
+
const delete_feedback_row = async (
|
|
825
|
+
table_id,
|
|
826
|
+
vn,
|
|
827
|
+
config,
|
|
828
|
+
body,
|
|
829
|
+
{ req, res }
|
|
830
|
+
) => {
|
|
831
|
+
const id = parseInt(body.id);
|
|
832
|
+
const mdRow = await MetaData.findOne({
|
|
833
|
+
id,
|
|
834
|
+
type: "CopilotConstructMgr",
|
|
835
|
+
name: "feedback_pending",
|
|
836
|
+
});
|
|
837
|
+
if (mdRow) await mdRow.delete();
|
|
838
|
+
const rmd = await MetaData.findOne({
|
|
839
|
+
type: "CopilotConstructMgr",
|
|
840
|
+
name: `feedback_research_${id}`,
|
|
841
|
+
});
|
|
842
|
+
if (rmd) await rmd.delete();
|
|
843
|
+
return { json: { success: true } };
|
|
844
|
+
};
|
|
845
|
+
|
|
846
|
+
/** Route: returns the show HTML for a processed feedback entry, displayed in the details modal. */
|
|
847
|
+
const show_processed_feedback = async (
|
|
848
|
+
table_id,
|
|
849
|
+
vn,
|
|
850
|
+
config,
|
|
851
|
+
body,
|
|
852
|
+
{ req, res }
|
|
853
|
+
) => {
|
|
854
|
+
const id = parseInt(body.id);
|
|
855
|
+
const md = await MetaData.findOne({ id });
|
|
856
|
+
if (!md) return { json: { error: "Not found" } };
|
|
857
|
+
|
|
858
|
+
const { title, description, url, research_context } = md.body;
|
|
859
|
+
|
|
860
|
+
const field = (lbl, val) =>
|
|
861
|
+
val
|
|
862
|
+
? div(
|
|
863
|
+
{ class: "mb-3" },
|
|
864
|
+
small({ class: "text-muted fw-semibold d-block" }, lbl),
|
|
865
|
+
p({ class: "mb-0" }, val)
|
|
866
|
+
)
|
|
867
|
+
: "";
|
|
868
|
+
|
|
869
|
+
const qaHtml = research_context
|
|
870
|
+
? hr() +
|
|
871
|
+
p({ class: "fw-semibold mb-3" }, "Clarifying questions") +
|
|
872
|
+
research_context
|
|
873
|
+
.split("\n\n")
|
|
874
|
+
.map((pair) => {
|
|
875
|
+
const [qLine, aLine] = pair.split("\n");
|
|
876
|
+
const q = qLine?.replace(/^Q:\s*/, "") || "";
|
|
877
|
+
const a = aLine?.replace(/^A:\s*/, "") || "";
|
|
878
|
+
return div(
|
|
879
|
+
{ class: "mb-3" },
|
|
880
|
+
small({ class: "text-muted fw-semibold d-block" }, q),
|
|
881
|
+
p({ class: "mb-0" }, a || "—")
|
|
882
|
+
);
|
|
883
|
+
})
|
|
884
|
+
.join("")
|
|
885
|
+
: "";
|
|
886
|
+
|
|
887
|
+
return {
|
|
888
|
+
json: {
|
|
889
|
+
html:
|
|
890
|
+
field("Title", title) +
|
|
891
|
+
field("Description", description) +
|
|
892
|
+
field("URL", url) +
|
|
893
|
+
qaHtml,
|
|
894
|
+
},
|
|
895
|
+
};
|
|
896
|
+
};
|
|
897
|
+
|
|
898
|
+
/**
|
|
899
|
+
* Route: returns editable HTML for a pending feedback record including all fields
|
|
900
|
+
* and any associated Q&A answers, shown in the edit modal.
|
|
901
|
+
*/
|
|
902
|
+
const get_feedback_edit_html = async (
|
|
903
|
+
table_id,
|
|
904
|
+
vn,
|
|
905
|
+
config,
|
|
906
|
+
body,
|
|
907
|
+
{ req, res }
|
|
908
|
+
) => {
|
|
909
|
+
const id = parseInt(body.id);
|
|
910
|
+
const mdRow = await MetaData.findOne({
|
|
911
|
+
id,
|
|
912
|
+
type: "CopilotConstructMgr",
|
|
913
|
+
name: "feedback_pending",
|
|
914
|
+
});
|
|
915
|
+
if (!mdRow) return { json: { error: "Not found" } };
|
|
916
|
+
const row = mdRow.body;
|
|
917
|
+
|
|
918
|
+
const rmd = await MetaData.findOne({
|
|
919
|
+
type: "CopilotConstructMgr",
|
|
920
|
+
name: `feedback_research_${id}`,
|
|
921
|
+
});
|
|
922
|
+
const rmdValid = parseInt(rmd?.body?.feedback_id) === id;
|
|
923
|
+
const questions = rmdValid ? rmd.body.questions || [] : [];
|
|
924
|
+
const answers = rmdValid ? rmd.body.answers || {} : {};
|
|
925
|
+
|
|
926
|
+
const fieldsHtml =
|
|
927
|
+
div(
|
|
928
|
+
{ class: "mb-3" },
|
|
929
|
+
label({ class: "form-label fw-semibold", for: "fbed-title" }, "Title"),
|
|
930
|
+
input({
|
|
931
|
+
type: "text",
|
|
932
|
+
class: "form-control",
|
|
933
|
+
id: "fbed-title",
|
|
934
|
+
value: row.title || "",
|
|
935
|
+
})
|
|
936
|
+
) +
|
|
937
|
+
div(
|
|
938
|
+
{ class: "mb-3" },
|
|
939
|
+
label(
|
|
940
|
+
{ class: "form-label fw-semibold", for: "fbed-desc" },
|
|
941
|
+
"Description"
|
|
79
942
|
),
|
|
943
|
+
textarea(
|
|
944
|
+
{ class: "form-control", id: "fbed-desc", rows: 3 },
|
|
945
|
+
row.description || ""
|
|
946
|
+
)
|
|
947
|
+
) +
|
|
948
|
+
div(
|
|
949
|
+
{ class: "mb-3" },
|
|
950
|
+
label({ class: "form-label fw-semibold", for: "fbed-url" }, "URL"),
|
|
951
|
+
input({
|
|
952
|
+
type: "text",
|
|
953
|
+
class: "form-control",
|
|
954
|
+
id: "fbed-url",
|
|
955
|
+
value: row.url || "",
|
|
956
|
+
})
|
|
80
957
|
);
|
|
81
|
-
|
|
82
|
-
|
|
958
|
+
|
|
959
|
+
const questionsHtml = questions.length
|
|
960
|
+
? hr() +
|
|
961
|
+
p({ class: "fw-semibold mb-3" }, "Clarifying questions") +
|
|
962
|
+
questions
|
|
963
|
+
.map((q, idx) => {
|
|
964
|
+
const key = `q${idx + 1}`;
|
|
965
|
+
return div(
|
|
966
|
+
{ class: "mb-3" },
|
|
967
|
+
label({ class: "form-label small fw-semibold" }, q),
|
|
968
|
+
textarea(
|
|
969
|
+
{
|
|
970
|
+
class: "form-control form-control-sm fbed-answer",
|
|
971
|
+
"data-q": key,
|
|
972
|
+
rows: 2,
|
|
973
|
+
},
|
|
974
|
+
answers[key] || ""
|
|
975
|
+
)
|
|
976
|
+
);
|
|
977
|
+
})
|
|
978
|
+
.join("")
|
|
979
|
+
: "";
|
|
980
|
+
|
|
981
|
+
return { json: { html: fieldsHtml + questionsHtml } };
|
|
982
|
+
};
|
|
983
|
+
|
|
984
|
+
/**
|
|
985
|
+
* Route: saves edits to a pending feedback MetaData record and updates its Q&A answers metadata.
|
|
986
|
+
*/
|
|
987
|
+
const save_feedback_edit = async (table_id, vn, config, body, { req, res }) => {
|
|
988
|
+
const { _csrf, id: rawId, title, description, url, ...rest } = body;
|
|
989
|
+
const id = parseInt(rawId);
|
|
990
|
+
const mdRow = await MetaData.findOne({
|
|
991
|
+
id,
|
|
992
|
+
type: "CopilotConstructMgr",
|
|
993
|
+
name: "feedback_pending",
|
|
994
|
+
});
|
|
995
|
+
if (mdRow) {
|
|
996
|
+
await mdRow.update({ body: { ...mdRow.body, title, description, url } });
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
const rmd = await MetaData.findOne({
|
|
1000
|
+
type: "CopilotConstructMgr",
|
|
1001
|
+
name: `feedback_research_${id}`,
|
|
1002
|
+
});
|
|
1003
|
+
if (rmd) {
|
|
1004
|
+
const answers = { ...rmd.body.answers };
|
|
1005
|
+
for (const [k, v] of Object.entries(rest)) {
|
|
1006
|
+
if (/^q\d+$/.test(k)) answers[k] = v;
|
|
1007
|
+
}
|
|
1008
|
+
await rmd.update({ body: { ...rmd.body, answers } });
|
|
83
1009
|
}
|
|
1010
|
+
|
|
1011
|
+
return { json: { success: true, notify_success: "Feedback saved" } };
|
|
84
1012
|
};
|
|
85
1013
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
1014
|
+
/**
|
|
1015
|
+
* Route: adds a Feedback link to the navigation menu if not already present.
|
|
1016
|
+
*/
|
|
1017
|
+
const get_feedback_form = async (table_id, vn, config, body, { req, res }) => {
|
|
1018
|
+
const phasesMd = await MetaData.findOne({
|
|
1019
|
+
type: "CopilotConstructMgr",
|
|
1020
|
+
name: "phases",
|
|
89
1021
|
});
|
|
1022
|
+
const phases = phasesMd?.body?.phases || [];
|
|
1023
|
+
const preselectedScope = body.scope || req?.query?.scope || "";
|
|
1024
|
+
const safeViewNameJson = JSON.stringify(viewname);
|
|
90
1025
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
1026
|
+
const standaloneScript = `<script>
|
|
1027
|
+
(function(){
|
|
1028
|
+
const safeViewName = ${safeViewNameJson};
|
|
1029
|
+
let _fbQuestions;
|
|
1030
|
+
const _urlField = document.getElementById('fb-url');
|
|
1031
|
+
if (_urlField) {
|
|
1032
|
+
try {
|
|
1033
|
+
const _href = window.location.href;
|
|
1034
|
+
if (!decodeURIComponent(_href).includes('/view/' + safeViewName)) _urlField.value = _href;
|
|
1035
|
+
} catch (_e) { _urlField.value = window.location.href; }
|
|
1036
|
+
}
|
|
1037
|
+
function fbGetUrl() {
|
|
1038
|
+
const url = document.getElementById('fb-url')?.value?.trim() || '';
|
|
1039
|
+
if (!url) return '';
|
|
1040
|
+
try { if (decodeURIComponent(url).includes('/view/' + safeViewName)) return ''; } catch (_e) {}
|
|
1041
|
+
return url;
|
|
1042
|
+
}
|
|
1043
|
+
function fbPopulateQuestions(questions) {
|
|
1044
|
+
const area = document.getElementById('fb-questions-area');
|
|
1045
|
+
area.innerHTML = '';
|
|
1046
|
+
for (const [idx, q] of questions.entries()) {
|
|
1047
|
+
const wrapper = document.createElement('div');
|
|
1048
|
+
wrapper.className = 'mb-3';
|
|
1049
|
+
const lbl = document.createElement('label');
|
|
1050
|
+
lbl.className = 'form-label';
|
|
1051
|
+
lbl.textContent = q;
|
|
1052
|
+
lbl.htmlFor = 'fb-answer-' + idx;
|
|
1053
|
+
const ta = document.createElement('textarea');
|
|
1054
|
+
ta.className = 'form-control';
|
|
1055
|
+
ta.id = 'fb-answer-' + idx;
|
|
1056
|
+
ta.rows = 2;
|
|
1057
|
+
wrapper.appendChild(lbl);
|
|
1058
|
+
wrapper.appendChild(ta);
|
|
1059
|
+
area.appendChild(wrapper);
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
window.fbAnalyseFeedback = () => {
|
|
1063
|
+
const title = document.getElementById('fb-title').value.trim();
|
|
1064
|
+
if (!title) { document.getElementById('fb-title').classList.add('is-invalid'); return; }
|
|
1065
|
+
document.getElementById('fb-title').classList.remove('is-invalid');
|
|
1066
|
+
document.getElementById('fb-spinner').style.display = '';
|
|
1067
|
+
document.getElementById('fb-analyse-area').style.display = 'none';
|
|
1068
|
+
document.getElementById('fb-regen-area').style.display = 'none';
|
|
1069
|
+
view_post(safeViewName, 'analyse_feedback', { title, description: document.getElementById('fb-desc').value.trim(), url: fbGetUrl() }, (resp) => {
|
|
1070
|
+
document.getElementById('fb-spinner').style.display = 'none';
|
|
1071
|
+
if (!resp || resp.error) { document.getElementById('fb-analyse-area').style.display = ''; return; }
|
|
1072
|
+
const questions = resp.questions || [];
|
|
1073
|
+
_fbQuestions = questions;
|
|
1074
|
+
if (questions.length === 0) {
|
|
1075
|
+
document.getElementById('fb-clear-area').style.display = '';
|
|
1076
|
+
document.getElementById('fb-regen-area').style.display = '';
|
|
1077
|
+
return;
|
|
1078
|
+
}
|
|
1079
|
+
fbPopulateQuestions(questions);
|
|
1080
|
+
document.getElementById('fb-fields').style.display = 'none';
|
|
1081
|
+
document.getElementById('fb-regen-area').style.display = '';
|
|
1082
|
+
});
|
|
1083
|
+
};
|
|
1084
|
+
window.fbDismissQuestions = () => {
|
|
1085
|
+
_fbQuestions = undefined;
|
|
1086
|
+
document.getElementById('fb-questions-area').innerHTML = '';
|
|
1087
|
+
document.getElementById('fb-clear-area').style.display = 'none';
|
|
1088
|
+
document.getElementById('fb-fields').style.display = '';
|
|
1089
|
+
document.getElementById('fb-regen-area').style.display = 'none';
|
|
1090
|
+
document.getElementById('fb-analyse-area').style.display = '';
|
|
1091
|
+
};
|
|
1092
|
+
window.fbSaveOrPrompt = () => {
|
|
1093
|
+
const title = document.getElementById('fb-title').value.trim();
|
|
1094
|
+
if (!title) { document.getElementById('fb-title').classList.add('is-invalid'); return; }
|
|
1095
|
+
document.getElementById('fb-title').classList.remove('is-invalid');
|
|
1096
|
+
if (_fbQuestions !== undefined) { fbSubmit(); return; }
|
|
1097
|
+
new bootstrap.Modal(document.getElementById('fb-clarify-modal')).show();
|
|
1098
|
+
};
|
|
1099
|
+
window.fbSubmit = () => {
|
|
1100
|
+
const title = document.getElementById('fb-title').value.trim();
|
|
1101
|
+
if (!title) { document.getElementById('fb-title').classList.add('is-invalid'); return; }
|
|
1102
|
+
document.getElementById('fb-title').classList.remove('is-invalid');
|
|
1103
|
+
const scope = document.getElementById('fb-scope')?.value || 'overall';
|
|
1104
|
+
const payload = { title, description: document.getElementById('fb-desc').value.trim(), url: fbGetUrl(), scope };
|
|
1105
|
+
for (const [idx, q] of (_fbQuestions || []).entries()) {
|
|
1106
|
+
payload['question_' + (idx + 1)] = q;
|
|
1107
|
+
const ta = document.getElementById('fb-answer-' + idx);
|
|
1108
|
+
payload['a' + (idx + 1)] = ta ? ta.value : '';
|
|
1109
|
+
}
|
|
1110
|
+
view_post(safeViewName, 'submit_feedback_with_answers', payload, (resp) => {
|
|
1111
|
+
if (resp && !resp.error) {
|
|
1112
|
+
document.getElementById('fb-step1').style.display = 'none';
|
|
1113
|
+
document.getElementById('fb-success').style.display = '';
|
|
1114
|
+
if (typeof refreshFeedbackViews === 'function') refreshFeedbackViews();
|
|
1115
|
+
}
|
|
1116
|
+
});
|
|
1117
|
+
};
|
|
1118
|
+
})();
|
|
1119
|
+
</script>`;
|
|
1120
|
+
|
|
1121
|
+
return {
|
|
1122
|
+
html: feedbackFormInner(phases, preselectedScope) + standaloneScript,
|
|
1123
|
+
title: "Submit feedback",
|
|
1124
|
+
};
|
|
94
1125
|
};
|
|
95
|
-
|
|
1126
|
+
|
|
1127
|
+
const add_feedback_to_menu = async (
|
|
96
1128
|
table_id,
|
|
97
|
-
|
|
1129
|
+
vn,
|
|
98
1130
|
config,
|
|
99
1131
|
body,
|
|
100
|
-
{ req, res }
|
|
1132
|
+
{ req, res }
|
|
101
1133
|
) => {
|
|
1134
|
+
if (!features.view_route_modal) {
|
|
1135
|
+
return {
|
|
1136
|
+
json: {
|
|
1137
|
+
error: "requires_newer_saltcorn",
|
|
1138
|
+
notify_error:
|
|
1139
|
+
"Adding a navbar feedback button requires a newer version of Saltcorn.",
|
|
1140
|
+
},
|
|
1141
|
+
};
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
const menuUrl = `javascript:ajax_modal('/view/${encodeURIComponent(
|
|
1145
|
+
viewname
|
|
1146
|
+
)}/get_feedback_form', {method:'POST'})`;
|
|
1147
|
+
const current = getState().getConfig("menu_items", []);
|
|
1148
|
+
const alreadyAdded = current.some(
|
|
1149
|
+
(mi) => mi.type === "Link" && mi.url?.includes("get_feedback_form")
|
|
1150
|
+
);
|
|
1151
|
+
if (!alreadyAdded) {
|
|
1152
|
+
await save_menu_items([
|
|
1153
|
+
...current,
|
|
1154
|
+
{
|
|
1155
|
+
type: "Link",
|
|
1156
|
+
label: "Feedback",
|
|
1157
|
+
text: "Feedback",
|
|
1158
|
+
icon: "fas fa-comment-alt",
|
|
1159
|
+
url: menuUrl,
|
|
1160
|
+
min_role: 80,
|
|
1161
|
+
},
|
|
1162
|
+
]);
|
|
1163
|
+
}
|
|
1164
|
+
return { json: { success: true } };
|
|
1165
|
+
};
|
|
1166
|
+
|
|
1167
|
+
/**
|
|
1168
|
+
* Route: deletes a single processed feedback metadata entry.
|
|
1169
|
+
*/
|
|
1170
|
+
const del_feedback = async (table_id, vn, config, body, { req, res }) => {
|
|
1171
|
+
const r = await MetaData.findOne({ id: body.id });
|
|
1172
|
+
if (!r) throw new Error("Feedback not found");
|
|
1173
|
+
await r.delete();
|
|
1174
|
+
|
|
1175
|
+
// just to be sure
|
|
1176
|
+
// feedback_research_${body.id} should already be cleaned up
|
|
1177
|
+
const stale = await MetaData.findOne({
|
|
1178
|
+
type: "CopilotConstructMgr",
|
|
1179
|
+
name: `feedback_research_${body.id}`,
|
|
1180
|
+
});
|
|
1181
|
+
if (stale) {
|
|
1182
|
+
getState().log(
|
|
1183
|
+
5,
|
|
1184
|
+
`del_feedback: found stale feedback_research_${body.id}, deleting`
|
|
1185
|
+
);
|
|
1186
|
+
await stale.delete();
|
|
1187
|
+
}
|
|
1188
|
+
return { json: { success: true } };
|
|
1189
|
+
};
|
|
1190
|
+
|
|
1191
|
+
/**
|
|
1192
|
+
* Route: deletes all processed feedback metadata entries.
|
|
1193
|
+
*/
|
|
1194
|
+
const del_all_feedback = async (table_id, vn, config, body, { req, res }) => {
|
|
102
1195
|
const rs = await MetaData.find({
|
|
103
1196
|
type: "CopilotConstructMgr",
|
|
104
1197
|
name: "feedback",
|
|
105
1198
|
});
|
|
106
|
-
for (const r of rs)
|
|
107
|
-
|
|
1199
|
+
for (const r of rs) {
|
|
1200
|
+
await r.delete();
|
|
1201
|
+
const stale = await MetaData.findOne({
|
|
1202
|
+
type: "CopilotConstructMgr",
|
|
1203
|
+
name: `feedback_research_${r.id}`,
|
|
1204
|
+
});
|
|
1205
|
+
if (stale) {
|
|
1206
|
+
getState().log(
|
|
1207
|
+
5,
|
|
1208
|
+
`del_all_feedback: found stale feedback_research_${r.id}, deleting`
|
|
1209
|
+
);
|
|
1210
|
+
await stale.delete();
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
return { json: { success: true } };
|
|
108
1214
|
};
|
|
109
1215
|
|
|
110
|
-
|
|
1216
|
+
/**
|
|
1217
|
+
* Route: asks the LLM whether the feedback needs clarification.
|
|
1218
|
+
* Returns an array of questions, or an empty array if the feedback is clear.
|
|
1219
|
+
*/
|
|
1220
|
+
const analyse_feedback = async (table_id, vn, config, body, { req, res }) => {
|
|
1221
|
+
const { title, description, url = "" } = body;
|
|
1222
|
+
|
|
1223
|
+
let knownContext = null;
|
|
1224
|
+
if (url) {
|
|
1225
|
+
const mView = url.match(/\/view\/([^/?#]+)/);
|
|
1226
|
+
const mPage = url.match(/\/page\/([^/?#]+)/);
|
|
1227
|
+
const entityType = mView ? "view" : mPage ? "page" : null;
|
|
1228
|
+
const entityName = mView?.[1] ?? mPage?.[1] ?? null;
|
|
1229
|
+
if (entityType) {
|
|
1230
|
+
knownContext = {
|
|
1231
|
+
section:
|
|
1232
|
+
"Known context (do NOT ask about these — they are already established facts):\n" +
|
|
1233
|
+
`- The feedback targets the Saltcorn ${entityType} named "${entityName}"\n` +
|
|
1234
|
+
`- URL: ${url}\n`,
|
|
1235
|
+
doNotAsk:
|
|
1236
|
+
`- Which ${entityType}, screen, or part of the application this concerns` +
|
|
1237
|
+
` — it is the ${entityType} "${entityName}" as stated above`,
|
|
1238
|
+
};
|
|
1239
|
+
} else {
|
|
1240
|
+
knownContext = {
|
|
1241
|
+
section: `Known context:\n- URL: ${url}\n`,
|
|
1242
|
+
doNotAsk: null,
|
|
1243
|
+
};
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
const generator = await PromptGenerator.createInstance();
|
|
1248
|
+
|
|
1249
|
+
const answer = await getState().functions.llm_generate.run(
|
|
1250
|
+
generator.feedbackAnalysePrompt({ title, description, knownContext }),
|
|
1251
|
+
{
|
|
1252
|
+
tools: [questions_tool],
|
|
1253
|
+
systemPrompt:
|
|
1254
|
+
"You are a requirements analyst reviewing user feedback. " +
|
|
1255
|
+
"Your default is to ask NO questions — only use the tool when something\n" +
|
|
1256
|
+
"is genuinely too ambiguous to act on without clarification.\n" +
|
|
1257
|
+
"Never fish for detail that a competent developer could infer or decide themselves.",
|
|
1258
|
+
}
|
|
1259
|
+
);
|
|
1260
|
+
const tc =
|
|
1261
|
+
typeof answer.getToolCalls === "function"
|
|
1262
|
+
? answer.getToolCalls()[0]
|
|
1263
|
+
: undefined;
|
|
1264
|
+
return { json: { questions: tc?.input?.questions ?? [] } };
|
|
1265
|
+
};
|
|
1266
|
+
|
|
1267
|
+
/**
|
|
1268
|
+
* Route: creates a new pending feedback MetaData record and stores any Q&A answers
|
|
1269
|
+
* as a separate metadata record keyed by the record id.
|
|
1270
|
+
*/
|
|
1271
|
+
const submit_feedback_with_answers = async (
|
|
1272
|
+
table_id,
|
|
1273
|
+
vn,
|
|
1274
|
+
config,
|
|
1275
|
+
body,
|
|
1276
|
+
{ req, res }
|
|
1277
|
+
) => {
|
|
1278
|
+
const { _csrf, title, description, url, scope, ...rest } = body;
|
|
1279
|
+
|
|
1280
|
+
const phaseMatch = (scope || "").match(/^phase_(\d+)$/);
|
|
1281
|
+
const phase_idx = phaseMatch ? parseInt(phaseMatch[1]) : null;
|
|
1282
|
+
|
|
1283
|
+
const newMd = await MetaData.create({
|
|
1284
|
+
type: "CopilotConstructMgr",
|
|
1285
|
+
name: "feedback_pending",
|
|
1286
|
+
body: {
|
|
1287
|
+
title,
|
|
1288
|
+
description: description || null,
|
|
1289
|
+
url: url || null,
|
|
1290
|
+
scope: scope || "overall",
|
|
1291
|
+
phase_idx,
|
|
1292
|
+
status: "Pending",
|
|
1293
|
+
},
|
|
1294
|
+
user_id: req?.user?.id,
|
|
1295
|
+
});
|
|
1296
|
+
const rowId = newMd.id;
|
|
1297
|
+
|
|
1298
|
+
const questions = [];
|
|
1299
|
+
const answers = {};
|
|
1300
|
+
let idx = 1;
|
|
1301
|
+
while (rest[`question_${idx}`] !== undefined) {
|
|
1302
|
+
questions.push(rest[`question_${idx}`]);
|
|
1303
|
+
answers[`q${idx}`] = rest[`a${idx}`] || "";
|
|
1304
|
+
idx++;
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
if (questions.length) {
|
|
1308
|
+
await MetaData.create({
|
|
1309
|
+
type: "CopilotConstructMgr",
|
|
1310
|
+
name: `feedback_research_${rowId}`,
|
|
1311
|
+
body: {
|
|
1312
|
+
feedback_id: rowId,
|
|
1313
|
+
title,
|
|
1314
|
+
questions,
|
|
1315
|
+
answers,
|
|
1316
|
+
},
|
|
1317
|
+
});
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
return { json: { success: true } };
|
|
1321
|
+
};
|
|
1322
|
+
|
|
1323
|
+
const feedback_routes = {
|
|
1324
|
+
del_feedback,
|
|
1325
|
+
del_all_feedback,
|
|
1326
|
+
feedback_views_html,
|
|
1327
|
+
get_feedback_form,
|
|
1328
|
+
start_approve_feedback,
|
|
1329
|
+
approval_status,
|
|
1330
|
+
delete_feedback_row,
|
|
1331
|
+
analyse_feedback,
|
|
1332
|
+
submit_feedback_with_answers,
|
|
1333
|
+
get_feedback_edit_html,
|
|
1334
|
+
save_feedback_edit,
|
|
1335
|
+
show_processed_feedback,
|
|
1336
|
+
add_feedback_to_menu,
|
|
1337
|
+
};
|
|
111
1338
|
|
|
112
1339
|
module.exports = { feedbackList, feedback_routes };
|