@alfacapital-tech/c4builder 0.9.0-rc.1 → 0.9.0-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/agent.js +21 -0
- package/dist/cli/commands/feedback.js +45 -0
- package/dist/cli/commands/site.comments.js +278 -71
- package/dist/cli/commands/site.js +151 -8
- package/dist/cli/dispatch.js +12 -4
- package/dist/core/feedback.js +59 -0
- package/package.json +1 -1
- package/template/AGENTS.md +7 -1
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
const AGENT_VARS = [
|
|
2
|
+
['CLAUDECODE', 'claude-code'],
|
|
3
|
+
['CODEX_THREAD_ID', 'codex'],
|
|
4
|
+
['GEMINI_CLI', 'gemini-cli'],
|
|
5
|
+
['OPENCODE', 'opencode'],
|
|
6
|
+
['COPILOT_CLI', 'copilot-cli']
|
|
7
|
+
];
|
|
8
|
+
const fromEnv = (env) => {
|
|
9
|
+
const own = env.C4BUILDER_AGENT;
|
|
10
|
+
if (own)
|
|
11
|
+
return { on: own !== '0', name: own, explicit: true };
|
|
12
|
+
const known = AGENT_VARS.find(([key]) => env[key]);
|
|
13
|
+
const name = known ? known[1] : env.AI_AGENT;
|
|
14
|
+
return { on: !!name, name: name ?? '', explicit: false };
|
|
15
|
+
};
|
|
16
|
+
export const detectAgent = (env, flags) => {
|
|
17
|
+
const found = fromEnv(env);
|
|
18
|
+
if (flags.agent === undefined)
|
|
19
|
+
return found;
|
|
20
|
+
return { on: flags.agent, name: found.on ? found.name : 'agent', explicit: true };
|
|
21
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import { parseConfig } from "../../config/options.js";
|
|
4
|
+
import { FEEDBACK_HEADER, FEEDBACK_PATH } from "./site.comments.js";
|
|
5
|
+
// `c4builder feedback` (change site-agent-feedback, design D9): забирает пачку замечаний у `site -w`.
|
|
6
|
+
// Конфиг читается как JSON без Configstore — тот создал бы `.c4builder` в любом каталоге. Ошибки —
|
|
7
|
+
// через exitCode, а не process.exit: напечатанная в pipe пачка должна успеть уйти целиком.
|
|
8
|
+
export default async (configPath, opts) => {
|
|
9
|
+
let raw;
|
|
10
|
+
try {
|
|
11
|
+
raw = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
12
|
+
}
|
|
13
|
+
catch { }
|
|
14
|
+
const config = parseConfig(raw && typeof raw === 'object' ? raw : {});
|
|
15
|
+
const port = opts.port ?? (config.ok ? config.value : config.salvaged).webPort;
|
|
16
|
+
const shown = `http://localhost:${port}`;
|
|
17
|
+
const fail = (message) => {
|
|
18
|
+
console.error(chalk.red(message));
|
|
19
|
+
process.exitCode = 1;
|
|
20
|
+
};
|
|
21
|
+
// 127.0.0.1, а не localhost: сервер слушает только IPv4, а `localhost` → `::1` на части машин
|
|
22
|
+
// подвешивает соединение.
|
|
23
|
+
const url = `http://127.0.0.1:${port}${FEEDBACK_PATH}/next${opts.wait ? '' : '?wait=0'}`;
|
|
24
|
+
for (;;) {
|
|
25
|
+
let res;
|
|
26
|
+
try {
|
|
27
|
+
res = await fetch(url, { headers: { [FEEDBACK_HEADER]: '1' } });
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return fail(`no c4builder site -w on ${shown} — start it first`);
|
|
31
|
+
}
|
|
32
|
+
if (res.status === 200) {
|
|
33
|
+
process.stdout.write(await res.text());
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (res.status === 204) {
|
|
37
|
+
if (opts.wait)
|
|
38
|
+
continue;
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (res.status === 404)
|
|
42
|
+
return fail(`agent feedback is disabled on ${shown} — the server runs without -w or with --no-agent`);
|
|
43
|
+
return fail(`unexpected response ${res.status} from ${shown}`);
|
|
44
|
+
}
|
|
45
|
+
};
|
|
@@ -1,17 +1,32 @@
|
|
|
1
1
|
export const COMMENTS_PATH = '/__c4builder/comments';
|
|
2
|
+
export const FEEDBACK_PATH = '/__c4builder/feedback';
|
|
3
|
+
export const FEEDBACK_HEADER = 'X-C4builder-Feedback';
|
|
2
4
|
const CSS = [
|
|
3
|
-
'.c4b-
|
|
5
|
+
'.c4b-cbtns{position:absolute;z-index:1001;white-space:nowrap}',
|
|
6
|
+
'.c4b-cbtns button{margin-right:4px;padding:4px 10px;border:1px solid #42b983;border-radius:4px;background:#fff;color:#2c3e50;font:13px/1.4 sans-serif;cursor:pointer;box-shadow:0 2px 8px rgba(0,0,0,.15)}',
|
|
7
|
+
'.c4b-cbtns button.c4b-fb{border-color:#3b82f6}',
|
|
4
8
|
'.c4b-cpop{position:absolute;z-index:1002;width:380px;max-width:calc(100vw - 32px);padding:10px;border:1px solid #ddd;border-radius:6px;background:#fff;color:#2c3e50;font:13px/1.4 sans-serif;box-shadow:0 4px 16px rgba(0,0,0,.2)}',
|
|
5
9
|
'.c4b-cpop blockquote{margin:0 0 8px;padding:4px 8px;border-left:3px solid #42b983;background:#f8f8f8;max-height:80px;overflow:auto;font-size:12px;white-space:pre-wrap}',
|
|
6
|
-
'.c4b-cpop textarea{width:100%;box-sizing:border-box;min-height:70px;padding:6px;border:1px solid #ccc;border-radius:4px;font:13px/1.4 sans-serif;resize:vertical}',
|
|
10
|
+
'.c4b-cpop textarea,.c4b-review textarea{width:100%;box-sizing:border-box;min-height:70px;padding:6px;border:1px solid #ccc;border-radius:4px;font:13px/1.4 sans-serif;resize:vertical}',
|
|
7
11
|
'.c4b-cpop .c4b-row{display:flex;justify-content:space-between;align-items:center;gap:8px;margin-top:8px}',
|
|
8
12
|
'.c4b-cpop small{color:#888;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
|
9
|
-
'.c4b-cpop button{padding:4px 12px;border:1px solid #ccc;border-radius:4px;background:#fff;color:#2c3e50;cursor:pointer;font:13px sans-serif}',
|
|
10
|
-
'.c4b-cpop button.c4b-primary{border-color:#42b983;background:#42b983;color:#fff}',
|
|
11
|
-
'.c4b-cpop button:disabled{opacity:.6;cursor:default}',
|
|
13
|
+
'.c4b-cpop button,.c4b-review button{padding:4px 12px;border:1px solid #ccc;border-radius:4px;background:#fff;color:#2c3e50;cursor:pointer;font:13px sans-serif}',
|
|
14
|
+
'.c4b-cpop button.c4b-primary,.c4b-review button.c4b-primary{border-color:#42b983;background:#42b983;color:#fff}',
|
|
15
|
+
'.c4b-cpop button:disabled,.c4b-review button:disabled{opacity:.6;cursor:default}',
|
|
16
|
+
// Панель review — ниже кнопок у выделения и поповера: она прибита к низу экрана и иначе
|
|
17
|
+
// перекрывает их у выделения в нижних строках.
|
|
18
|
+
'.c4b-review{position:fixed;left:50%;bottom:16px;transform:translateX(-50%);z-index:1000;width:520px;max-width:calc(100vw - 32px);box-sizing:border-box;padding:8px 12px;border:1px solid #3b82f6;border-radius:6px;background:#fff;color:#2c3e50;font:13px/1.4 sans-serif;box-shadow:0 2px 8px rgba(0,0,0,.2)}',
|
|
19
|
+
'.c4b-review .c4b-row{display:flex;flex-wrap:wrap;align-items:center;gap:6px}',
|
|
20
|
+
'.c4b-review .c4b-row small{flex:1;color:#888;white-space:nowrap}',
|
|
21
|
+
'.c4b-review button.c4b-link{padding:0;border:0;background:none;color:#2c3e50;font-weight:700;text-decoration:underline dotted}',
|
|
22
|
+
'.c4b-review ul{margin:6px 0;padding:0;list-style:none;max-height:40vh;overflow:auto}',
|
|
23
|
+
'.c4b-review li{padding:3px 0;border-top:1px solid #eee;overflow-wrap:anywhere}',
|
|
24
|
+
'.c4b-review textarea{margin-top:6px}',
|
|
25
|
+
'.c4b-review .c4b-del{opacity:1}',
|
|
12
26
|
'.c4b-ctoast{position:fixed;left:16px;bottom:16px;z-index:1003;max-width:420px;padding:8px 14px;border-radius:6px;background:#2c3e50;color:#fff;font:13px/1.4 sans-serif;box-shadow:0 2px 8px rgba(0,0,0,.3)}',
|
|
13
27
|
'.c4b-ctoast.c4b-err{background:#c0392b}',
|
|
14
28
|
'mark.c4b-hl{background:#fff3a0;color:inherit;padding:0;border-radius:2px}',
|
|
29
|
+
'mark.c4b-dhl{background:#cfe3ff;color:inherit;padding:0;border-radius:2px}',
|
|
15
30
|
'sup.c4b-ref{font-size:.7em;line-height:0;margin-left:1px;user-select:none}',
|
|
16
31
|
'sup.c4b-ref a{color:#b58900;text-decoration:none;font-weight:700;cursor:pointer}',
|
|
17
32
|
'.c4b-qrow{transition:background .3s}',
|
|
@@ -24,14 +39,19 @@ const CSS = [
|
|
|
24
39
|
const jsString = (s) => JSON.stringify(s)
|
|
25
40
|
.replace(/</g, '\\u003c')
|
|
26
41
|
.replace(/[\u0080-]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`);
|
|
27
|
-
|
|
42
|
+
// Начальное состояние агентского режима и ключ проекта вставляет сервер, дальше его обновляют
|
|
43
|
+
// события `c4builder:agent` от сниппета livereload (change site-agent-feedback, design D4, D8).
|
|
44
|
+
export const commentsSnippet = (heading, { agent = false, listening = false, project = '' } = {}) => `
|
|
28
45
|
<script>
|
|
29
46
|
(function () {
|
|
30
47
|
if (window.__c4builderComments) return;
|
|
31
48
|
window.__c4builderComments = true;
|
|
32
49
|
var ENDPOINT = '${COMMENTS_PATH}';
|
|
50
|
+
var FEEDBACK = '${FEEDBACK_PATH}';
|
|
33
51
|
var HEADING = ${jsString(heading)};
|
|
34
52
|
var TOAST_KEY = '__c4builderCommentToast';
|
|
53
|
+
var DRAFT_KEY = '__c4builderReview:' + ${jsString(project)};
|
|
54
|
+
var AGENT = { on: ${agent}, listening: ${listening} };
|
|
35
55
|
var BLOCKS = 'p,li,td,th,h1,h2,h3,h4,h5,h6,pre,blockquote,dd,dt,div,section,details,summary';
|
|
36
56
|
var style = document.createElement('style');
|
|
37
57
|
style.textContent = ${jsString(CSS)};
|
|
@@ -109,7 +129,7 @@ export const commentsSnippet = (heading) => `
|
|
|
109
129
|
for (var r = 0; r < roots.length; r++) walk(roots[r]);
|
|
110
130
|
return { text: chars.join(''), map: map };
|
|
111
131
|
};
|
|
112
|
-
var wrapRange = function (idx, from, to, title) {
|
|
132
|
+
var wrapRange = function (idx, from, to, title, cls) {
|
|
113
133
|
var first = null, last = null, i;
|
|
114
134
|
for (i = from; i < to && !first; i++) first = idx.map[i];
|
|
115
135
|
for (i = to - 1; i >= from && !last; i--) last = idx.map[i];
|
|
@@ -125,9 +145,10 @@ export const commentsSnippet = (heading) => `
|
|
|
125
145
|
var end = n === last.node ? last.offset + 1 : n.nodeValue.length;
|
|
126
146
|
if (end < n.nodeValue.length) n.splitText(end);
|
|
127
147
|
var mid = start > 0 ? n.splitText(start) : n;
|
|
128
|
-
|
|
148
|
+
// Overlapping quotes: the number link of a question is not part of the text.
|
|
149
|
+
if (!mid.nodeValue.trim() || mid.parentNode.closest('sup.c4b-ref')) continue;
|
|
129
150
|
var mark = document.createElement('mark');
|
|
130
|
-
mark.className =
|
|
151
|
+
mark.className = cls;
|
|
131
152
|
mark.title = title;
|
|
132
153
|
mid.parentNode.insertBefore(mark, mid);
|
|
133
154
|
mark.appendChild(mid);
|
|
@@ -135,6 +156,20 @@ export const commentsSnippet = (heading) => `
|
|
|
135
156
|
}
|
|
136
157
|
return marks;
|
|
137
158
|
};
|
|
159
|
+
var tryIn = function (idx, quote, cls, title) {
|
|
160
|
+
var at = idx.text.indexOf(quote);
|
|
161
|
+
var marks = at < 0 ? null : wrapRange(idx, at, at + quote.length, title, cls);
|
|
162
|
+
return marks && marks.length ? marks : null;
|
|
163
|
+
};
|
|
164
|
+
// The quote is searched in its own section first: the same words may repeat on the page.
|
|
165
|
+
var locate = function (root, q, skip, cls, title) {
|
|
166
|
+
var marks = null;
|
|
167
|
+
if (q.section) {
|
|
168
|
+
var sh = findHeading(root, q.section);
|
|
169
|
+
if (sh && skip.indexOf(sh) < 0) marks = tryIn(textIndex(sectionOf(sh), skip), q.quote, cls, title);
|
|
170
|
+
}
|
|
171
|
+
return marks || tryIn(textIndex([root], skip), q.quote, cls, title);
|
|
172
|
+
};
|
|
138
173
|
var flash = function (row) {
|
|
139
174
|
row.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
|
140
175
|
row.classList.add('c4b-qflash');
|
|
@@ -185,11 +220,6 @@ export const commentsSnippet = (heading) => `
|
|
|
185
220
|
item.cell.appendChild(del);
|
|
186
221
|
};
|
|
187
222
|
var skip = [h].concat(section);
|
|
188
|
-
var tryIn = function (idx, q) {
|
|
189
|
-
var at = idx.text.indexOf(q.quote);
|
|
190
|
-
var marks = at < 0 ? null : wrapRange(idx, at, at + q.quote.length, q.comment);
|
|
191
|
-
return marks && marks.length ? marks : null;
|
|
192
|
-
};
|
|
193
223
|
items.forEach(function (item) {
|
|
194
224
|
item.row.classList.add('c4b-qrow');
|
|
195
225
|
item.row.id = item.row.id || 'c4b-q-' + item.number;
|
|
@@ -197,11 +227,7 @@ export const commentsSnippet = (heading) => `
|
|
|
197
227
|
// Index is rebuilt per try: wrapping splits text nodes and stales the previous map.
|
|
198
228
|
var q = null, marks = null;
|
|
199
229
|
parseQuestions(item.text).some(function (c) {
|
|
200
|
-
|
|
201
|
-
var sh = findHeading(root, c.section);
|
|
202
|
-
if (sh && skip.indexOf(sh) < 0) marks = tryIn(textIndex(sectionOf(sh), skip), c);
|
|
203
|
-
}
|
|
204
|
-
marks = marks || tryIn(textIndex([root], skip), c);
|
|
230
|
+
marks = locate(root, c, skip, 'c4b-hl', c.comment);
|
|
205
231
|
q = marks ? c : null;
|
|
206
232
|
return !!marks;
|
|
207
233
|
});
|
|
@@ -219,7 +245,12 @@ export const commentsSnippet = (heading) => `
|
|
|
219
245
|
lastMark.parentNode.insertBefore(sup, lastMark.nextSibling);
|
|
220
246
|
});
|
|
221
247
|
};
|
|
222
|
-
var schedule = function () {
|
|
248
|
+
var schedule = function () {
|
|
249
|
+
setTimeout(function () {
|
|
250
|
+
try { highlight(); } catch (e) {}
|
|
251
|
+
try { highlightDraft(); } catch (e) {}
|
|
252
|
+
}, 50);
|
|
253
|
+
};
|
|
223
254
|
var docsifyVm = null;
|
|
224
255
|
if (window.$docsify) {
|
|
225
256
|
window.$docsify.plugins = [].concat(window.$docsify.plugins || [], function (hook, vm) {
|
|
@@ -230,12 +261,24 @@ export const commentsSnippet = (heading) => `
|
|
|
230
261
|
window.addEventListener('hashchange', function () { setTimeout(schedule, 300); });
|
|
231
262
|
window.addEventListener('load', function () { setTimeout(schedule, 500); });
|
|
232
263
|
|
|
233
|
-
var
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
264
|
+
var button = function (label, onClick, cls) {
|
|
265
|
+
var b = document.createElement('button');
|
|
266
|
+
b.type = 'button';
|
|
267
|
+
b.textContent = label;
|
|
268
|
+
if (cls) b.className = cls;
|
|
269
|
+
if (onClick) b.addEventListener('click', onClick);
|
|
270
|
+
return b;
|
|
271
|
+
};
|
|
272
|
+
var btns = document.createElement('div');
|
|
273
|
+
btns.className = 'c4b-cbtns';
|
|
274
|
+
btns.hidden = true;
|
|
275
|
+
var fbBtn = button('Feedback', null, 'c4b-fb');
|
|
276
|
+
fbBtn.title = 'Tell the agent what to change';
|
|
277
|
+
var qBtn = button('Question');
|
|
278
|
+
qBtn.title = 'Add a question to the ' + HEADING + ' section of the source file';
|
|
279
|
+
btns.appendChild(fbBtn);
|
|
280
|
+
btns.appendChild(qBtn);
|
|
281
|
+
document.body.appendChild(btns);
|
|
239
282
|
var pop = null;
|
|
240
283
|
|
|
241
284
|
var selection = function () {
|
|
@@ -273,9 +316,9 @@ export const commentsSnippet = (heading) => `
|
|
|
273
316
|
var update = function () {
|
|
274
317
|
if (pop) return;
|
|
275
318
|
var s = selection();
|
|
276
|
-
if (!s) {
|
|
277
|
-
|
|
278
|
-
place(
|
|
319
|
+
if (!s) { btns.hidden = true; return; }
|
|
320
|
+
btns.hidden = false;
|
|
321
|
+
place(btns, anchorRect(s.range));
|
|
279
322
|
};
|
|
280
323
|
document.addEventListener('selectionchange', function () { setTimeout(update, 0); });
|
|
281
324
|
|
|
@@ -292,8 +335,8 @@ export const commentsSnippet = (heading) => `
|
|
|
292
335
|
if (!p) return (window.$docsify && window.$docsify.homepage) || 'README.md';
|
|
293
336
|
return /\\.md$/i.test(p) ? p : p + '.md';
|
|
294
337
|
};
|
|
295
|
-
var send = function (body, method) {
|
|
296
|
-
return fetch(ENDPOINT, {
|
|
338
|
+
var send = function (body, method, url) {
|
|
339
|
+
return fetch(url || ENDPOINT, {
|
|
297
340
|
method: method || 'POST',
|
|
298
341
|
headers: { 'Content-Type': 'application/json' },
|
|
299
342
|
body: JSON.stringify(body)
|
|
@@ -304,75 +347,239 @@ export const commentsSnippet = (heading) => `
|
|
|
304
347
|
});
|
|
305
348
|
});
|
|
306
349
|
};
|
|
350
|
+
var failure = function (what) {
|
|
351
|
+
return function (err) { toast(what + ' failed: ' + (err && err.message ? err.message : err), true); };
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
// Feedback to the agent: "Delivered" only after the server handed the batch to
|
|
355
|
+
// "c4builder feedback"; the event may outrun the POST response, hence both maps.
|
|
356
|
+
var delivered = {}, pending = {};
|
|
357
|
+
var sendBatch = function (decision, comments, general) {
|
|
358
|
+
return send({ decision: decision, comments: comments, general: general }, 'POST', FEEDBACK).then(function (r) {
|
|
359
|
+
if (delivered[r.id]) return toast('Delivered to the agent');
|
|
360
|
+
pending[r.id] = true;
|
|
361
|
+
toast(r.queued ? 'Queued \\u2014 agent is not listening' : 'Sent to the agent');
|
|
362
|
+
});
|
|
363
|
+
};
|
|
364
|
+
window.addEventListener('c4builder:delivered', function (e) {
|
|
365
|
+
var id = e.detail && e.detail.id;
|
|
366
|
+
delivered[id] = true;
|
|
367
|
+
if (pending[id]) { delete pending[id]; toast('Delivered to the agent'); }
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
// Review draft lives in localStorage per project: it survives livereload and a closed tab;
|
|
371
|
+
// without storage it lives until the page reloads.
|
|
372
|
+
var memDraft = [];
|
|
373
|
+
var draft = function () {
|
|
374
|
+
try {
|
|
375
|
+
var v = JSON.parse(localStorage.getItem(DRAFT_KEY) || '[]');
|
|
376
|
+
return Array.isArray(v) ? v : [];
|
|
377
|
+
} catch (e) { return memDraft; }
|
|
378
|
+
};
|
|
379
|
+
var saveDraft = function (items) {
|
|
380
|
+
memDraft = items;
|
|
381
|
+
try {
|
|
382
|
+
if (items.length) localStorage.setItem(DRAFT_KEY, JSON.stringify(items));
|
|
383
|
+
else localStorage.removeItem(DRAFT_KEY);
|
|
384
|
+
} catch (e) {}
|
|
385
|
+
renderPanel();
|
|
386
|
+
highlightDraft();
|
|
387
|
+
};
|
|
388
|
+
var highlightDraft = function () {
|
|
389
|
+
var root = content();
|
|
390
|
+
if (!root) return;
|
|
391
|
+
var marks = root.querySelectorAll('mark.c4b-dhl');
|
|
392
|
+
for (var i = 0; i < marks.length; i++) {
|
|
393
|
+
var m = marks[i], parent = m.parentNode;
|
|
394
|
+
while (m.firstChild) parent.insertBefore(m.firstChild, m);
|
|
395
|
+
parent.removeChild(m);
|
|
396
|
+
parent.normalize();
|
|
397
|
+
}
|
|
398
|
+
var page = pageFile();
|
|
399
|
+
var h = findHeading(root, HEADING);
|
|
400
|
+
var skip = h ? [h].concat(sectionOf(h)) : [];
|
|
401
|
+
draft().forEach(function (it) {
|
|
402
|
+
if (it.page === page) locate(root, { quote: collapse(it.quote), section: it.section }, skip, 'c4b-dhl', it.comment);
|
|
403
|
+
});
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
var panel = document.createElement('div');
|
|
407
|
+
panel.className = 'c4b-review';
|
|
408
|
+
var expanded = false, busy = false;
|
|
409
|
+
var count = button('', function () { expanded = !expanded; renderPanel(); }, 'c4b-link');
|
|
410
|
+
var listen = document.createElement('small');
|
|
411
|
+
listen.className = 'c4b-listen';
|
|
412
|
+
var list = document.createElement('ul');
|
|
413
|
+
var general = document.createElement('textarea');
|
|
414
|
+
general.placeholder = 'Overall comment (optional, Ctrl+Enter to submit, Esc to hide)';
|
|
415
|
+
general.hidden = true;
|
|
416
|
+
var finish = function (decision) {
|
|
417
|
+
var items = draft(), ids = items.map(function (it) { return it.id; });
|
|
418
|
+
busy = true;
|
|
419
|
+
renderPanel();
|
|
420
|
+
sendBatch(decision, items, general.hidden ? '' : general.value.trim()).then(function () {
|
|
421
|
+
general.value = '';
|
|
422
|
+
general.hidden = true;
|
|
423
|
+
expanded = false;
|
|
424
|
+
saveDraft(draft().filter(function (it) { return ids.indexOf(it.id) < 0; }));
|
|
425
|
+
}, failure('Review')).then(function () { busy = false; renderPanel(); });
|
|
426
|
+
};
|
|
427
|
+
var discard = button('Discard', function () {
|
|
428
|
+
var n = draft().length;
|
|
429
|
+
if (n && !window.confirm('Discard ' + n + ' pending comment' + (n === 1 ? '' : 's') + '?')) return;
|
|
430
|
+
general.value = '';
|
|
431
|
+
general.hidden = true;
|
|
432
|
+
saveDraft([]);
|
|
433
|
+
});
|
|
434
|
+
var approve = button('Approve', function () { finish('approved'); });
|
|
435
|
+
approve.title = 'Tell the agent there is nothing more to change (pending comments go along)';
|
|
436
|
+
var submit = button('Submit review', function () {
|
|
437
|
+
if (!general.hidden) return finish('submitted');
|
|
438
|
+
general.hidden = false;
|
|
439
|
+
general.focus();
|
|
440
|
+
}, 'c4b-primary');
|
|
441
|
+
general.addEventListener('keydown', function (e) {
|
|
442
|
+
if (e.key === 'Escape') { e.preventDefault(); general.hidden = true; }
|
|
443
|
+
else if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); submit.click(); }
|
|
444
|
+
});
|
|
445
|
+
var head = document.createElement('div');
|
|
446
|
+
head.className = 'c4b-row';
|
|
447
|
+
[count, listen, discard, approve, submit].forEach(function (el) { head.appendChild(el); });
|
|
448
|
+
panel.appendChild(head);
|
|
449
|
+
panel.appendChild(list);
|
|
450
|
+
panel.appendChild(general);
|
|
451
|
+
document.body.appendChild(panel);
|
|
452
|
+
var renderPanel = function () {
|
|
453
|
+
panel.hidden = !AGENT.on;
|
|
454
|
+
var items = draft(), pages = {};
|
|
455
|
+
items.forEach(function (it) { pages[it.page] = true; });
|
|
456
|
+
var n = Object.keys(pages).length;
|
|
457
|
+
count.textContent = items.length
|
|
458
|
+
? 'Review: ' + items.length + ' pending (' + n + (n === 1 ? ' page)' : ' pages)')
|
|
459
|
+
: 'Review: nothing pending';
|
|
460
|
+
count.disabled = !items.length;
|
|
461
|
+
list.textContent = '';
|
|
462
|
+
items.forEach(function (it) {
|
|
463
|
+
var li = document.createElement('li');
|
|
464
|
+
var q = collapse(it.quote);
|
|
465
|
+
li.textContent = it.page + ' \\u2014 \\u00ab' + (q.length > 60 ? q.slice(0, 59) + '\\u2026' : q) + '\\u00bb: ' + it.comment;
|
|
466
|
+
var del = button('\\u00d7', function () {
|
|
467
|
+
saveDraft(draft().filter(function (x) { return x.id !== it.id; }));
|
|
468
|
+
}, 'c4b-del');
|
|
469
|
+
del.title = 'Remove from the review';
|
|
470
|
+
li.appendChild(del);
|
|
471
|
+
list.appendChild(li);
|
|
472
|
+
});
|
|
473
|
+
list.hidden = !expanded || !items.length;
|
|
474
|
+
discard.disabled = busy;
|
|
475
|
+
approve.disabled = busy;
|
|
476
|
+
submit.disabled = busy || !items.length;
|
|
477
|
+
};
|
|
478
|
+
var renderAgent = function () {
|
|
479
|
+
fbBtn.hidden = !AGENT.on;
|
|
480
|
+
renderPanel();
|
|
481
|
+
var text = 'agent: ' + (AGENT.listening ? 'listening \\u25cf' : 'not listening \\u25cb');
|
|
482
|
+
var els = document.querySelectorAll('.c4b-listen');
|
|
483
|
+
for (var i = 0; i < els.length; i++) { els[i].textContent = text; els[i].title = text; }
|
|
484
|
+
};
|
|
485
|
+
window.addEventListener('c4builder:agent', function (e) {
|
|
486
|
+
var d = e.detail || {};
|
|
487
|
+
AGENT.on = !!d.on;
|
|
488
|
+
AGENT.listening = !!d.listening;
|
|
489
|
+
renderAgent();
|
|
490
|
+
});
|
|
491
|
+
window.addEventListener('storage', function (e) {
|
|
492
|
+
if (e.key === DRAFT_KEY) { renderPanel(); highlightDraft(); }
|
|
493
|
+
});
|
|
307
494
|
|
|
308
495
|
var close = function () {
|
|
309
496
|
if (pop) { pop.remove(); pop = null; }
|
|
310
|
-
|
|
497
|
+
btns.hidden = true;
|
|
311
498
|
};
|
|
312
|
-
var open = function (data, rect) {
|
|
499
|
+
var open = function (data, rect, feedback) {
|
|
313
500
|
close();
|
|
314
501
|
pop = document.createElement('div');
|
|
315
502
|
pop.className = 'c4b-cpop';
|
|
316
503
|
var quote = document.createElement('blockquote');
|
|
317
504
|
quote.textContent = data.quote;
|
|
318
505
|
var ta = document.createElement('textarea');
|
|
319
|
-
ta.placeholder = 'Your question
|
|
506
|
+
ta.placeholder = (feedback ? 'What should the agent change?' : 'Your question') + ' (Ctrl+Enter to send, Esc to cancel)';
|
|
320
507
|
var row = document.createElement('div');
|
|
321
508
|
row.className = 'c4b-row';
|
|
322
509
|
var hint = document.createElement('small');
|
|
323
|
-
|
|
324
|
-
hint.
|
|
510
|
+
if (feedback) hint.className = 'c4b-listen';
|
|
511
|
+
else hint.textContent = hint.title = 'To: ' + HEADING + (data.section ? ' / ' + data.section : '');
|
|
325
512
|
var actions = document.createElement('span');
|
|
326
|
-
var
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
513
|
+
var buttons = [button('Cancel', close)];
|
|
514
|
+
var run = function (action) {
|
|
515
|
+
return function () {
|
|
516
|
+
var comment = ta.value.trim();
|
|
517
|
+
if (!comment) { ta.focus(); return; }
|
|
518
|
+
buttons.forEach(function (b) { b.disabled = true; });
|
|
519
|
+
action(comment).then(close, function (err) {
|
|
520
|
+
buttons.forEach(function (b) { b.disabled = false; });
|
|
521
|
+
failure(feedback ? 'Feedback' : 'Question')(err);
|
|
522
|
+
});
|
|
523
|
+
};
|
|
524
|
+
};
|
|
525
|
+
var item = function (comment) {
|
|
526
|
+
return { page: pageFile(), quote: data.quote, section: data.section, comment: comment };
|
|
527
|
+
};
|
|
528
|
+
var primary;
|
|
529
|
+
if (feedback) {
|
|
530
|
+
var add = button('Add to review', run(function (c) {
|
|
531
|
+
var it = item(c);
|
|
532
|
+
it.id = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
|
533
|
+
saveDraft(draft().concat([it]));
|
|
534
|
+
return Promise.resolve();
|
|
535
|
+
}));
|
|
536
|
+
var now = button('Send now', run(function (c) { return sendBatch('submitted', [item(c)], ''); }));
|
|
537
|
+
buttons.push(add, now);
|
|
538
|
+
// As in a GitLab merge request: once a review is started, comments go into it.
|
|
539
|
+
primary = draft().length ? add : now;
|
|
540
|
+
} else {
|
|
541
|
+
primary = button('Add question', run(function (c) {
|
|
542
|
+
return send(item(c)).then(function (r) {
|
|
543
|
+
var msg = 'Added to ' + r.file + (r.number ? ' as #' + r.number : '');
|
|
544
|
+
try { sessionStorage.setItem(TOAST_KEY, msg); } catch (e) {}
|
|
545
|
+
toast(msg);
|
|
546
|
+
});
|
|
547
|
+
}));
|
|
548
|
+
buttons.push(primary);
|
|
549
|
+
}
|
|
550
|
+
primary.className = 'c4b-primary';
|
|
551
|
+
buttons.forEach(function (b) {
|
|
552
|
+
actions.appendChild(b);
|
|
553
|
+
actions.appendChild(document.createTextNode(' '));
|
|
554
|
+
});
|
|
336
555
|
row.appendChild(hint);
|
|
337
556
|
row.appendChild(actions);
|
|
338
557
|
pop.appendChild(quote);
|
|
339
558
|
pop.appendChild(ta);
|
|
340
559
|
pop.appendChild(row);
|
|
341
560
|
document.body.appendChild(pop);
|
|
561
|
+
renderAgent();
|
|
342
562
|
place(pop, rect);
|
|
343
563
|
ta.focus();
|
|
344
|
-
var submit = function () {
|
|
345
|
-
var comment = ta.value.trim();
|
|
346
|
-
if (!comment) { ta.focus(); return; }
|
|
347
|
-
ok.disabled = true;
|
|
348
|
-
cancel.disabled = true;
|
|
349
|
-
send({ page: pageFile(), quote: data.quote, section: data.section, comment: comment }).then(function (r) {
|
|
350
|
-
var msg = 'Added to ' + r.file + (r.number ? ' as #' + r.number : '');
|
|
351
|
-
try { sessionStorage.setItem(TOAST_KEY, msg); } catch (e) {}
|
|
352
|
-
close();
|
|
353
|
-
toast(msg);
|
|
354
|
-
}, function (err) {
|
|
355
|
-
ok.disabled = false;
|
|
356
|
-
cancel.disabled = false;
|
|
357
|
-
toast('Comment failed: ' + (err && err.message ? err.message : err), true);
|
|
358
|
-
});
|
|
359
|
-
};
|
|
360
|
-
ok.addEventListener('click', submit);
|
|
361
|
-
cancel.addEventListener('click', close);
|
|
362
564
|
ta.addEventListener('keydown', function (e) {
|
|
363
565
|
if (e.key === 'Escape') { e.preventDefault(); close(); }
|
|
364
|
-
else if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault();
|
|
566
|
+
else if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); primary.click(); }
|
|
365
567
|
});
|
|
366
568
|
};
|
|
367
569
|
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
570
|
+
var openFor = function (feedback) {
|
|
571
|
+
return function () {
|
|
572
|
+
var s = selection();
|
|
573
|
+
if (s) open({ quote: s.text, section: headingBefore(s.range) }, anchorRect(s.range), feedback);
|
|
574
|
+
};
|
|
575
|
+
};
|
|
576
|
+
btns.addEventListener('mousedown', function (e) { e.preventDefault(); });
|
|
577
|
+
fbBtn.addEventListener('click', openFor(true));
|
|
578
|
+
qBtn.addEventListener('click', openFor(false));
|
|
373
579
|
document.addEventListener('mousedown', function (e) {
|
|
374
|
-
if (pop && !pop.contains(e.target) && e.target
|
|
580
|
+
if (pop && !pop.contains(e.target) && !btns.contains(e.target)) close();
|
|
375
581
|
});
|
|
376
582
|
window.addEventListener('hashchange', close);
|
|
583
|
+
renderAgent();
|
|
377
584
|
})();
|
|
378
585
|
</script>`;
|
|
@@ -1,17 +1,21 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
import fs from 'node:fs';
|
|
4
5
|
import express from 'express';
|
|
5
6
|
import open from 'open';
|
|
7
|
+
import { batchSchema, formatBatch } from "../../core/feedback.js";
|
|
6
8
|
import { deleteQuestion, formatQuestion, pickSource, writeQuestion } from "../../core/questions.js";
|
|
7
9
|
import { packageJson as pkg } from "../../util/paths.js";
|
|
8
10
|
import { pendingUpdate } from "../update.js";
|
|
9
|
-
import { COMMENTS_PATH, commentsSnippet } from "./site.comments.js";
|
|
11
|
+
import { COMMENTS_PATH, commentsSnippet, FEEDBACK_HEADER, FEEDBACK_PATH } from "./site.comments.js";
|
|
10
12
|
const COMMENT_MAX = 2000;
|
|
11
13
|
const LIVERELOAD_PATH = '/__livereload';
|
|
12
14
|
// Восстановление скролла после reload: docsify рендерит контент асинхронно после fetch md,
|
|
13
15
|
// высота страницы доступна не сразу — поллим до достижения нужной y или таймаута.
|
|
14
16
|
// Комментарии держим здесь, а не в сниппете: он уходит в браузер пользователя.
|
|
17
|
+
// События агента (design D4) идут по тому же потоку и пересылаются сниппету комментариев
|
|
18
|
+
// DOM-событиями `c4builder:*` — второй EventSource странице не нужен.
|
|
15
19
|
const LIVERELOAD_SNIPPET = `
|
|
16
20
|
<script>
|
|
17
21
|
(function () {
|
|
@@ -48,6 +52,13 @@ const LIVERELOAD_SNIPPET = `
|
|
|
48
52
|
es.close();
|
|
49
53
|
location.reload();
|
|
50
54
|
});
|
|
55
|
+
['agent', 'delivered'].forEach(function (name) {
|
|
56
|
+
es.addEventListener(name, function (e) {
|
|
57
|
+
try {
|
|
58
|
+
window.dispatchEvent(new CustomEvent('c4builder:' + name, { detail: JSON.parse(e.data) }));
|
|
59
|
+
} catch (err) {}
|
|
60
|
+
});
|
|
61
|
+
});
|
|
51
62
|
es.onerror = function () {
|
|
52
63
|
es.close();
|
|
53
64
|
setTimeout(connect, delay);
|
|
@@ -88,12 +99,24 @@ pages = () => new Map()) => {
|
|
|
88
99
|
const port = program.port || currentConfiguration.WEB_PORT;
|
|
89
100
|
const distFolder = path.resolve(currentConfiguration.DIST_FOLDER);
|
|
90
101
|
const liveReloadEnabled = !!(program.watch && reloadEmitter);
|
|
102
|
+
const agent = { ...(program.agent ?? { on: false, name: '', explicit: false }) };
|
|
91
103
|
// Кеш index.html — отдаём его из памяти, если на диске его временно нет
|
|
92
104
|
// (в окне build.js → emptyDir(DIST_FOLDER) перед записью).
|
|
93
105
|
let lastIndexHtml = null;
|
|
94
106
|
// reloadEmitter в условии — сужает тип до EventEmitter внутри блока (liveReloadEnabled
|
|
95
107
|
// истинно ⟺ reloadEmitter задан), поэтому дальше без non-null assertions.
|
|
96
108
|
if (liveReloadEnabled && reloadEmitter) {
|
|
109
|
+
// Состояние слушателя `c4builder feedback` (design D4): подключён, пока открыт long-poll
|
|
110
|
+
// или с конца последнего прошло меньше idleMs — зазор между повторами команды.
|
|
111
|
+
let listening = false;
|
|
112
|
+
const agentState = () => ({ on: agent.on, listening });
|
|
113
|
+
const setAgent = (on, listen) => {
|
|
114
|
+
if (on === agent.on && listen === listening)
|
|
115
|
+
return;
|
|
116
|
+
agent.on = on;
|
|
117
|
+
listening = listen;
|
|
118
|
+
reloadEmitter.emit('agent', agentState());
|
|
119
|
+
};
|
|
97
120
|
app.get(LIVERELOAD_PATH, (req, res) => {
|
|
98
121
|
res.set({
|
|
99
122
|
'Content-Type': 'text/event-stream',
|
|
@@ -104,21 +127,35 @@ pages = () => new Map()) => {
|
|
|
104
127
|
res.flushHeaders();
|
|
105
128
|
res.write('retry: 1000\n\n');
|
|
106
129
|
const onReload = () => res.write('event: reload\ndata: {}\n\n');
|
|
130
|
+
const onAgent = (data) => res.write(`event: agent\ndata: ${JSON.stringify(data)}\n\n`);
|
|
131
|
+
const onDelivered = (data) => res.write(`event: delivered\ndata: ${JSON.stringify(data)}\n\n`);
|
|
107
132
|
const heartbeat = setInterval(() => res.write(': ping\n\n'), 15000);
|
|
108
133
|
reloadEmitter.on('reload', onReload); // liveReloadEnabled ⇒ reloadEmitter задан
|
|
134
|
+
reloadEmitter.on('agent', onAgent);
|
|
135
|
+
reloadEmitter.on('delivered', onDelivered);
|
|
136
|
+
onAgent(agentState());
|
|
109
137
|
console.log(chalk.gray(`livereload: client connected (${reloadEmitter.listenerCount('reload')} total)`));
|
|
110
138
|
req.on('close', () => {
|
|
111
139
|
clearInterval(heartbeat);
|
|
112
140
|
reloadEmitter.off('reload', onReload);
|
|
141
|
+
reloadEmitter.off('agent', onAgent);
|
|
142
|
+
reloadEmitter.off('delivered', onDelivered);
|
|
113
143
|
console.log(chalk.gray(`livereload: client disconnected (${reloadEmitter.listenerCount('reload')} left)`));
|
|
114
144
|
});
|
|
115
145
|
});
|
|
146
|
+
// Ключ черновиков review в localStorage (design D5): страницы разных проектов на одном
|
|
147
|
+
// localhost:<port> не должны видеть чужой черновик.
|
|
148
|
+
const projectKey = createHash('sha1').update(process.cwd()).digest('hex').slice(0, 8);
|
|
116
149
|
// Кэш проверки обновлений читается на каждую страницу: результат фоновой проверки,
|
|
117
150
|
// запущенной этим же `site -w`, виден со следующей перезагрузки без рестарта сервера.
|
|
118
151
|
const inject = (html) => {
|
|
119
152
|
const next = pendingUpdate();
|
|
120
153
|
const snippet = LIVERELOAD_SNIPPET +
|
|
121
|
-
commentsSnippet(currentConfiguration.QUESTIONS_HEADING
|
|
154
|
+
commentsSnippet(currentConfiguration.QUESTIONS_HEADING, {
|
|
155
|
+
agent: agent.on,
|
|
156
|
+
listening,
|
|
157
|
+
project: projectKey
|
|
158
|
+
}) +
|
|
122
159
|
(next ? updateBanner(next) : '');
|
|
123
160
|
if (html.includes('</body>'))
|
|
124
161
|
return html.replace('</body>', `${snippet}</body>`);
|
|
@@ -129,15 +166,21 @@ pages = () => new Map()) => {
|
|
|
129
166
|
// любого сайта в браузере может слать POST на localhost — требуем Origin самого сайта.
|
|
130
167
|
const str = (v) => (typeof v === 'string' ? v : '');
|
|
131
168
|
const heading = currentConfiguration.QUESTIONS_HEADING;
|
|
169
|
+
// Host — только локальный: при DNS rebinding чужой домен резолвится в 127.0.0.1 и
|
|
170
|
+
// присылает согласованные Host и Origin, сверка Origin с одним Host это пропускает.
|
|
171
|
+
const localHost = (req) => {
|
|
172
|
+
const host = req.get('host');
|
|
173
|
+
const local = [`127.0.0.1:${req.socket.localPort}`, `localhost:${req.socket.localPort}`];
|
|
174
|
+
return host && local.includes(host) ? host : undefined;
|
|
175
|
+
};
|
|
176
|
+
const fromSite = (req) => {
|
|
177
|
+
const host = localHost(req);
|
|
178
|
+
return !!host && req.get('origin') === `http://${host}`;
|
|
179
|
+
};
|
|
132
180
|
// Общая часть POST/DELETE: проверка Origin и исходный файл страницы из тела запроса.
|
|
133
181
|
// Ошибка уже отправлена клиенту, когда возвращается undefined.
|
|
134
182
|
const resolveTarget = (req, res) => {
|
|
135
|
-
|
|
136
|
-
const host = req.get('host');
|
|
137
|
-
// Host — только локальный: при DNS rebinding чужой домен резолвится в 127.0.0.1 и
|
|
138
|
-
// присылает согласованные Host и Origin, сверка Origin с одним Host это пропускает.
|
|
139
|
-
const local = [`127.0.0.1:${req.socket.localPort}`, `localhost:${req.socket.localPort}`];
|
|
140
|
-
if (!origin || !host || !local.includes(host) || origin !== `http://${host}`) {
|
|
183
|
+
if (!fromSite(req)) {
|
|
141
184
|
res.status(403).json({ error: 'comments are accepted only from the site itself' });
|
|
142
185
|
return undefined;
|
|
143
186
|
}
|
|
@@ -214,6 +257,104 @@ pages = () => new Map()) => {
|
|
|
214
257
|
res.status(500).json({ error: `could not remove the comment: ${e.message}` });
|
|
215
258
|
}
|
|
216
259
|
});
|
|
260
|
+
// Замечания агенту (change site-agent-feedback): FIFO пачек в памяти (design D3), страница
|
|
261
|
+
// шлёт POST, `c4builder feedback` забирает long-poll'ом (D1). Пачка снимается с очереди,
|
|
262
|
+
// когда ответ записан; обрыв до этого возвращает её в голову очереди.
|
|
263
|
+
const { pollMs = 25_000, idleMs = 30_000 } = program;
|
|
264
|
+
const queue = [];
|
|
265
|
+
const waiters = [];
|
|
266
|
+
let polls = 0;
|
|
267
|
+
let idleTimer;
|
|
268
|
+
const disabled = () => agent.explicit && !agent.on;
|
|
269
|
+
const drop = (res) => {
|
|
270
|
+
const i = waiters.indexOf(res);
|
|
271
|
+
if (i >= 0)
|
|
272
|
+
waiters.splice(i, 1);
|
|
273
|
+
};
|
|
274
|
+
const pump = () => {
|
|
275
|
+
while (queue.length && waiters.length) {
|
|
276
|
+
const res = waiters.shift();
|
|
277
|
+
const item = queue.shift();
|
|
278
|
+
if (res && item)
|
|
279
|
+
deliver(res, item);
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
const deliver = (res, item) => {
|
|
283
|
+
res.on('close', () => {
|
|
284
|
+
if (!res.writableFinished) {
|
|
285
|
+
queue.unshift(item);
|
|
286
|
+
pump();
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
console.log(chalk.gray(`feedback: batch delivered to the agent (${item.batch.decision})`));
|
|
290
|
+
reloadEmitter.emit('delivered', { id: item.id });
|
|
291
|
+
});
|
|
292
|
+
res.type('text/markdown').send(formatBatch(item.batch, pages(), process.cwd()));
|
|
293
|
+
};
|
|
294
|
+
app.post(FEEDBACK_PATH, express.json({ limit: '4mb' }), (req, res) => {
|
|
295
|
+
if (!fromSite(req)) {
|
|
296
|
+
res.status(403).json({ error: 'feedback is accepted only from the site itself' });
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
if (disabled()) {
|
|
300
|
+
res.status(404).json({ error: 'agent feedback is disabled (--no-agent)' });
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
const parsed = batchSchema.safeParse(req.body);
|
|
304
|
+
if (!parsed.success) {
|
|
305
|
+
const issue = parsed.error.issues[0];
|
|
306
|
+
res.status(400).json({
|
|
307
|
+
error: `invalid feedback: ${issue?.path.join('.')} ${issue?.message}`
|
|
308
|
+
});
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
const batch = parsed.data;
|
|
312
|
+
if (batch.decision === 'submitted' && !batch.comments.length && !batch.general) {
|
|
313
|
+
res.status(400).json({ error: 'nothing to send' });
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
const id = randomUUID();
|
|
317
|
+
const queued = !waiters.length;
|
|
318
|
+
queue.push({ id, batch });
|
|
319
|
+
pump();
|
|
320
|
+
console.log(chalk.gray(`feedback: ${batch.decision}, ${batch.comments.length} comment(s)${queued ? ' — queued, no agent listening' : ''}`));
|
|
321
|
+
res.json({ id, queued });
|
|
322
|
+
});
|
|
323
|
+
// Нестандартный заголовок вынуждает браузер к preflight, на который сервер не отвечает:
|
|
324
|
+
// чужая страница не может ни прочитать, ни «съесть» очередь простым запросом (design D6).
|
|
325
|
+
app.get(`${FEEDBACK_PATH}/next`, (req, res) => {
|
|
326
|
+
if (!localHost(req) || req.get('origin') || req.get(FEEDBACK_HEADER) !== '1') {
|
|
327
|
+
res.status(403).json({ error: 'feedback is served only to the c4builder feedback command' });
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
if (disabled()) {
|
|
331
|
+
res.status(404).json({ error: 'agent feedback is disabled (--no-agent)' });
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
if (!agent.on)
|
|
335
|
+
console.log(chalk.gray('agent mode on: "c4builder feedback" connected'));
|
|
336
|
+
polls++;
|
|
337
|
+
clearTimeout(idleTimer);
|
|
338
|
+
setAgent(true, true);
|
|
339
|
+
let timer;
|
|
340
|
+
res.on('close', () => {
|
|
341
|
+
clearTimeout(timer);
|
|
342
|
+
drop(res);
|
|
343
|
+
if (--polls === 0)
|
|
344
|
+
idleTimer = setTimeout(() => setAgent(agent.on, false), idleMs);
|
|
345
|
+
});
|
|
346
|
+
waiters.push(res);
|
|
347
|
+
pump();
|
|
348
|
+
if (!waiters.includes(res))
|
|
349
|
+
return;
|
|
350
|
+
timer = setTimeout(() => {
|
|
351
|
+
// Пачку успели отдать этому запросу — ответ уже пишется, 204 не нужен.
|
|
352
|
+
if (!waiters.includes(res))
|
|
353
|
+
return;
|
|
354
|
+
drop(res);
|
|
355
|
+
res.status(204).end();
|
|
356
|
+
}, req.query.wait === '0' ? 0 : pollMs);
|
|
357
|
+
});
|
|
217
358
|
app.use((req, res, next) => {
|
|
218
359
|
if (req.method !== 'GET')
|
|
219
360
|
return next();
|
|
@@ -264,6 +405,8 @@ pages = () => new Map()) => {
|
|
|
264
405
|
console.log(`go to ${chalk.green(url)}`);
|
|
265
406
|
if (liveReloadEnabled)
|
|
266
407
|
console.log(chalk.gray('livereload enabled (browser auto-reload on rebuild)'));
|
|
408
|
+
if (liveReloadEnabled && agent.on)
|
|
409
|
+
console.log(`agent mode (${agent.name}): page feedback goes to "c4builder feedback --wait${program.port ? ` -p ${port}` : ''}" — run it in the background and again after each batch`);
|
|
267
410
|
// --open: открываем браузер один раз при старте; BROWSER=none отключает (CI/headless)
|
|
268
411
|
if (program.open && process.env.BROWSER !== 'none')
|
|
269
412
|
open(url).catch(() => console.log(chalk.gray('could not open the browser automatically')));
|
package/dist/cli/dispatch.js
CHANGED
|
@@ -9,6 +9,8 @@ import cmdJre from "./commands/jre.js";
|
|
|
9
9
|
import cmdCheck from "./commands/check.js";
|
|
10
10
|
import cmdList from "./commands/list.js";
|
|
11
11
|
import cmdSite from "./commands/site.js";
|
|
12
|
+
import cmdFeedback from "./commands/feedback.js";
|
|
13
|
+
import { detectAgent } from "./agent.js";
|
|
12
14
|
import { cmdUpdate, pendingUpdate, startUpdateCheck } from "./update.js";
|
|
13
15
|
import cmdCollect from "./wizard/collect.js";
|
|
14
16
|
import { build } from "../core/build.js";
|
|
@@ -27,7 +29,7 @@ import { packageJson as pkg } from "../util/paths.js";
|
|
|
27
29
|
import { outputDirs, parseConfig } from "../config/options.js";
|
|
28
30
|
import { isValidPort } from "../config/schema.js";
|
|
29
31
|
// Позиционные подкоманды: allowExcessArguments пропускает любые, список — единственный фильтр.
|
|
30
|
-
const COMMANDS = ['check', 'jre', 'site', 'new', 'config', 'update'];
|
|
32
|
+
const COMMANDS = ['check', 'jre', 'site', 'new', 'config', 'update', 'feedback'];
|
|
31
33
|
const intro = () => {
|
|
32
34
|
console.log(chalk.blue(figlet.textSync('c4builder')));
|
|
33
35
|
console.log(chalk.gray('Blow up your software documentation writing skills'));
|
|
@@ -120,6 +122,9 @@ export default async () => {
|
|
|
120
122
|
.option('--site', 'serve the generated site')
|
|
121
123
|
.option('-w, --watch', 'watch for changes and rebuild')
|
|
122
124
|
.option('-o, --open', 'open the generated site in the browser (with --site)')
|
|
125
|
+
.option('--agent', 'with --site -w: accept page feedback for a coding agent (c4builder feedback)')
|
|
126
|
+
.option('--no-agent', 'with --site -w: no agent feedback even when run by a coding agent')
|
|
127
|
+
.option('--wait', 'for `feedback`: wait for the next batch of page feedback')
|
|
123
128
|
.option('--docs', 'a brief explanation for the available configuration options')
|
|
124
129
|
.option('--system-fonts', 'render with system fonts instead of the bundled one (not deterministic)')
|
|
125
130
|
.option('-p, --port <n>', 'port used for serving the generated site', (v) => {
|
|
@@ -140,8 +145,8 @@ export default async () => {
|
|
|
140
145
|
process.exit(1);
|
|
141
146
|
}
|
|
142
147
|
// --version/--help уже вышли в parse. Уведомление — по кэшу прошлой проверки, до её
|
|
143
|
-
// перезапуска; exit покрывает и выходы через process.exit.
|
|
144
|
-
if (cmd !== 'update') {
|
|
148
|
+
// перезапуска; exit покрывает и выходы через process.exit. Вывод `feedback` читает агент.
|
|
149
|
+
if (cmd !== 'update' && cmd !== 'feedback') {
|
|
145
150
|
const next = pendingUpdate();
|
|
146
151
|
if (next)
|
|
147
152
|
process.once('exit', () => process.stderr.write(`c4builder ${next} is available (current ${pkg.version}). Run: c4builder update\n`));
|
|
@@ -156,6 +161,9 @@ export default async () => {
|
|
|
156
161
|
// Обновление CLI — до Configstore, чтобы не создавать .c4builder в любом каталоге.
|
|
157
162
|
if (cmd === 'update')
|
|
158
163
|
return cmdUpdate();
|
|
164
|
+
// Замечания со страницы `site -w` — тоже до Configstore: команду запускают в любом каталоге.
|
|
165
|
+
if (cmd === 'feedback')
|
|
166
|
+
return cmdFeedback(path.join(process.cwd(), opts.configFile ?? '.c4builder'), opts);
|
|
159
167
|
// `site`/`new`/`config` — позиционные синонимы одноимённых флагов: эту форму печатают
|
|
160
168
|
// подсказки CLI и учат README, шаблон и скиллы.
|
|
161
169
|
if (cmd)
|
|
@@ -348,7 +356,7 @@ export default async () => {
|
|
|
348
356
|
}
|
|
349
357
|
isBuilding = false;
|
|
350
358
|
if (opts.site)
|
|
351
|
-
return await cmdSite(options, opts, reloadEmitter, () => pages);
|
|
359
|
+
return await cmdSite(options, { ...opts, agent: detectAgent(process.env, opts) }, reloadEmitter, () => pages);
|
|
352
360
|
if (options.GENERATE_WEBSITE && !opts.watch) {
|
|
353
361
|
console.log(chalk.gray('\nto view the generated website run'));
|
|
354
362
|
console.log(`> c4builder site`);
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Пачка замечаний агенту со страницы `site -w` (change site-agent-feedback, design D6–D7):
|
|
2
|
+
// схема тела запроса страницы и markdown, который печатает `c4builder feedback`.
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
const TEXT_MAX = 2000;
|
|
6
|
+
const text = z.string().max(TEXT_MAX);
|
|
7
|
+
const collapse = (s) => s.replace(/\s+/g, ' ').trim();
|
|
8
|
+
// Цитата — только ориентир для агента: длинное выделение обрезается, а не валит всю пачку.
|
|
9
|
+
const clip = (s) => {
|
|
10
|
+
const q = collapse(s);
|
|
11
|
+
return q.length > TEXT_MAX ? `${q.slice(0, TEXT_MAX - 1).trimEnd()}…` : q;
|
|
12
|
+
};
|
|
13
|
+
export const batchSchema = z.object({
|
|
14
|
+
decision: z.enum(['submitted', 'approved']),
|
|
15
|
+
comments: z
|
|
16
|
+
.array(z.object({
|
|
17
|
+
// Ключ карты «страница → исходники»: тот же вид, что у вопросов (без ведущего «/»).
|
|
18
|
+
page: z
|
|
19
|
+
.string()
|
|
20
|
+
.max(1000)
|
|
21
|
+
.transform((p) => path.posix.normalize(p.replace(/^\/+/, ''))),
|
|
22
|
+
quote: z.string().transform(clip),
|
|
23
|
+
section: text.default(''),
|
|
24
|
+
comment: text.trim().min(1)
|
|
25
|
+
}))
|
|
26
|
+
.max(200),
|
|
27
|
+
general: text.trim().default('')
|
|
28
|
+
});
|
|
29
|
+
const quoted = (s) => s
|
|
30
|
+
.split('\n')
|
|
31
|
+
.map((l) => (l.trim() ? `> ${l}` : '>'))
|
|
32
|
+
.join('\n');
|
|
33
|
+
const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
|
|
34
|
+
// Исходники — относительно cwd: агент правит их из каталога проекта.
|
|
35
|
+
export const formatBatch = (batch, pages, cwd) => {
|
|
36
|
+
const { comments, general } = batch;
|
|
37
|
+
const pageCount = new Set(comments.map((c) => c.page)).size;
|
|
38
|
+
const out = [
|
|
39
|
+
'# Page feedback (c4builder)',
|
|
40
|
+
'',
|
|
41
|
+
`Decision: ${batch.decision}`,
|
|
42
|
+
`Comments: ${comments.length}${comments.length ? ` on ${plural(pageCount, 'page')}` : ''}`
|
|
43
|
+
];
|
|
44
|
+
comments.forEach((c, i) => {
|
|
45
|
+
const section = collapse(c.section);
|
|
46
|
+
const sources = (pages.get(c.page) ?? []).map((s) => path.relative(cwd, s).split(path.sep).join('/'));
|
|
47
|
+
// Страница схлопывается только в заголовке: перевод строки в ней сломал бы формат пачки,
|
|
48
|
+
// а ключ карты исходников остаётся как есть.
|
|
49
|
+
out.push('', `## ${i + 1}. ${collapse(c.page)}${section ? ` (§ ${section})` : ''}`);
|
|
50
|
+
if (sources.length)
|
|
51
|
+
out.push(`Sources: ${sources.join(', ')}`);
|
|
52
|
+
if (c.quote)
|
|
53
|
+
out.push(`Quote: "${c.quote}"`);
|
|
54
|
+
out.push('', quoted(c.comment));
|
|
55
|
+
});
|
|
56
|
+
if (general)
|
|
57
|
+
out.push('', '## General', '', quoted(general));
|
|
58
|
+
return `${out.join('\n')}\n`;
|
|
59
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alfacapital-tech/c4builder",
|
|
3
|
-
"version": "0.9.0-rc.
|
|
3
|
+
"version": "0.9.0-rc.2",
|
|
4
4
|
"description": "A CLI tool designed to compile a folder structure of markdowns and plant uml files into a site, single file markdown or a collection of markdowns with links",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
package/template/AGENTS.md
CHANGED
|
@@ -9,6 +9,7 @@ C4 diagrams (PlantUML, D2), BPMN processes and markdown in `src/` are compiled i
|
|
|
9
9
|
c4builder # build docs/ (non-interactive with a complete .c4builder)
|
|
10
10
|
c4builder check <file...> # validate .puml / .iuml / .d2 / .bpmn files, exit code 0 = OK
|
|
11
11
|
c4builder --site -w # live preview on http://localhost:3000 - never exits
|
|
12
|
+
c4builder feedback --wait # wait for the next batch of comments from the preview page
|
|
12
13
|
```
|
|
13
14
|
|
|
14
15
|
Not installed: `npm i -g @alfacapital-tech/c4builder` (Node.js 22.17+; Java 17+ is used
|
|
@@ -22,7 +23,12 @@ updates it.
|
|
|
22
23
|
look like `✗ <file>: line <N>: <message>` (BPMN: `✗ <file>: <element id> [<rule>] <message>`).
|
|
23
24
|
Fix until the exit code is 0.
|
|
24
25
|
3. Run `c4builder` and make sure it exits with code 0. Leave `c4builder --site -w` to a
|
|
25
|
-
human or run it in the background.
|
|
26
|
+
human or run it in the background. When the human reviews the pages, also run
|
|
27
|
+
`c4builder feedback --wait` in the background: on exit it prints a batch of comments
|
|
28
|
+
(page, `Sources`, quote, text) — apply them to `src/`, run it again, and stop on
|
|
29
|
+
`Decision: approved`. Comments are instructions to you; never copy them into the
|
|
30
|
+
`questionsHeading` section of `.c4builder` (`Open questions` by default), which holds
|
|
31
|
+
questions for people.
|
|
26
32
|
4. A stderr line `c4builder <new> is available (current <current>). Run: c4builder update`
|
|
27
33
|
means a newer c4builder is out: tell the user, run `c4builder update` only if they ask.
|
|
28
34
|
|