@khanglvm/relay 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +83 -5
- package/docs/AGENT.md +117 -4
- package/docs/assets/annotations.png +0 -0
- package/docs/assets/board-dark.png +0 -0
- package/docs/assets/board-light.png +0 -0
- package/docs/assets/demo.gif +0 -0
- package/docs/assets/mobile.png +0 -0
- package/package.json +1 -1
- package/skills/relay/SKILL.md +79 -24
- package/skills/relay/examples/blocks-showcase.json +9 -0
- package/skills/relay/examples/diagram-coedit.json +15 -0
- package/src/cli.js +287 -15
- package/src/server.js +148 -11
- package/src/spec.js +37 -4
- package/src/ui/annotate.css +57 -5
- package/src/ui/annotate.js +130 -5
- package/src/ui/app.js +117 -2
- package/src/ui/blocks.css +97 -0
- package/src/ui/blocks.js +281 -19
- package/src/ui/style.css +9 -0
- package/vendor/VERSIONS.json +2 -1
- package/vendor/viz-standalone.js +9 -0
package/src/server.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import http from 'node:http';
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
4
6
|
import { fileURLToPath } from 'node:url';
|
|
5
7
|
import { loadBoard, saveBoard, saveRunning, removeRunning, loadPref, savePref } from './store.js';
|
|
6
8
|
import { openUrl } from './open.js';
|
|
@@ -46,7 +48,7 @@ function vendorPresent(file) {
|
|
|
46
48
|
}
|
|
47
49
|
}
|
|
48
50
|
|
|
49
|
-
function buildPage(record) {
|
|
51
|
+
function buildPage(record, rev) {
|
|
50
52
|
const html = fs.readFileSync(path.join(UI_DIR, 'index.html'), 'utf8');
|
|
51
53
|
const css = readUi('style.css');
|
|
52
54
|
const blocksCss = readUi('blocks.css');
|
|
@@ -69,6 +71,7 @@ function buildPage(record) {
|
|
|
69
71
|
const vendor = {
|
|
70
72
|
chart: specNeeds(spec, 'chart') && vendorPresent('chart.umd.js'),
|
|
71
73
|
mermaid: specNeeds(spec, 'mermaid') && vendorPresent('mermaid.min.js'),
|
|
74
|
+
viz: specNeeds(spec, 'graphviz') && vendorPresent('viz-standalone.js'),
|
|
72
75
|
};
|
|
73
76
|
// Prefill from the LIVE draft at request time (drafts autosave in real time),
|
|
74
77
|
// so a mid-fill page reload restores everything the user already entered.
|
|
@@ -78,9 +81,10 @@ function buildPage(record) {
|
|
|
78
81
|
comment: record.draft.comment || '',
|
|
79
82
|
notes: record.draft.notes || {},
|
|
80
83
|
annotations: record.draft.annotations || [],
|
|
84
|
+
blockEdits: record.draft.blockEdits || {},
|
|
81
85
|
}
|
|
82
86
|
: null;
|
|
83
|
-
const boot = { boardId: record.id, spec: clientSpec, prefill, pref: loadPref(), vendor };
|
|
87
|
+
const boot = { boardId: record.id, spec: clientSpec, prefill, pref: loadPref(), vendor, rev };
|
|
84
88
|
const json = JSON.stringify(boot).replace(/</g, '\\u003c');
|
|
85
89
|
return html
|
|
86
90
|
.split('__TITLE__').join(escapeHtml(spec.title))
|
|
@@ -93,8 +97,26 @@ function buildPage(record) {
|
|
|
93
97
|
.split('__BOOT_JSON__').join(json);
|
|
94
98
|
}
|
|
95
99
|
|
|
100
|
+
// Validates + sanitizes one annotation's threaded replies. Keeps only
|
|
101
|
+
// well-formed {author, text, createdAt} entries; caps at 50; coerces author.
|
|
102
|
+
function sanitizeReplies(value) {
|
|
103
|
+
if (!Array.isArray(value)) return [];
|
|
104
|
+
const out = [];
|
|
105
|
+
for (const r of value) {
|
|
106
|
+
if (out.length >= 50) break;
|
|
107
|
+
if (r === null || typeof r !== 'object' || Array.isArray(r)) continue;
|
|
108
|
+
if (typeof r.text !== 'string' || r.text.length > 5000) continue;
|
|
109
|
+
const author = r.author === 'agent' ? 'agent' : 'user';
|
|
110
|
+
const createdAt = typeof r.createdAt === 'string' ? r.createdAt : new Date().toISOString();
|
|
111
|
+
out.push({ author, text: r.text, createdAt });
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
|
|
96
116
|
// Validates + sanitizes an incoming annotations array (from draft/submit).
|
|
97
117
|
// Drops anything that isn't a well-formed annotation object; caps at 500.
|
|
118
|
+
// Each annotation may carry an optional author ('user'|'agent', default
|
|
119
|
+
// 'user') and a threaded replies array (validated + capped at 50).
|
|
98
120
|
function sanitizeAnnotations(value) {
|
|
99
121
|
if (!Array.isArray(value)) return [];
|
|
100
122
|
const out = [];
|
|
@@ -103,7 +125,27 @@ function sanitizeAnnotations(value) {
|
|
|
103
125
|
if (a === null || typeof a !== 'object' || Array.isArray(a)) continue;
|
|
104
126
|
if (typeof a.text !== 'string' || a.text.length > 5000) continue;
|
|
105
127
|
if (a.target === null || typeof a.target !== 'object' || Array.isArray(a.target)) continue;
|
|
106
|
-
|
|
128
|
+
const clean = { ...a, author: a.author === 'agent' ? 'agent' : 'user' };
|
|
129
|
+
if (a.replies !== undefined) clean.replies = sanitizeReplies(a.replies);
|
|
130
|
+
out.push(clean);
|
|
131
|
+
}
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Validates + sanitizes an incoming blockEdits map (from draft/submit).
|
|
136
|
+
// Keeps only string-keyed entries with string values <= 20000 chars; caps at
|
|
137
|
+
// 50 entries; drops invalid entries. Returns {} when nothing valid is present.
|
|
138
|
+
function sanitizeBlockEdits(value) {
|
|
139
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) return {};
|
|
140
|
+
const out = {};
|
|
141
|
+
let n = 0;
|
|
142
|
+
for (const key of Object.keys(value)) {
|
|
143
|
+
if (n >= 50) break;
|
|
144
|
+
if (typeof key !== 'string') continue;
|
|
145
|
+
const v = value[key];
|
|
146
|
+
if (typeof v !== 'string' || v.length > 20000) continue;
|
|
147
|
+
out[key] = v;
|
|
148
|
+
n++;
|
|
107
149
|
}
|
|
108
150
|
return out;
|
|
109
151
|
}
|
|
@@ -192,6 +234,47 @@ function readBody(req, limit = 5 * 1024 * 1024) {
|
|
|
192
234
|
});
|
|
193
235
|
}
|
|
194
236
|
|
|
237
|
+
// Push-wake: run the agent's own local shell command after a board finishes.
|
|
238
|
+
// The full result JSON is written to the command's stdin; RLY_BOARD_ID /
|
|
239
|
+
// RLY_STATUS / RLY_URL are exported. Failures are swallowed (best effort) —
|
|
240
|
+
// only a stderr note when not quiet. A 30s kill timer prevents a hung command
|
|
241
|
+
// from keeping the process alive.
|
|
242
|
+
function runOnResult(cmd, result, { quiet = false } = {}) {
|
|
243
|
+
if (typeof cmd !== 'string' || !cmd.trim()) return;
|
|
244
|
+
try {
|
|
245
|
+
const child = spawn('/bin/sh', ['-c', cmd], {
|
|
246
|
+
env: {
|
|
247
|
+
...process.env,
|
|
248
|
+
RLY_BOARD_ID: result.boardId || '',
|
|
249
|
+
RLY_STATUS: result.status || '',
|
|
250
|
+
RLY_URL: result.url || '',
|
|
251
|
+
},
|
|
252
|
+
stdio: ['pipe', 'ignore', 'ignore'],
|
|
253
|
+
});
|
|
254
|
+
child.on('error', () => {
|
|
255
|
+
if (!quiet) process.stderr.write(`[relay] --on-result command failed to spawn\n`);
|
|
256
|
+
});
|
|
257
|
+
try {
|
|
258
|
+
child.stdin.write(JSON.stringify(result));
|
|
259
|
+
child.stdin.end();
|
|
260
|
+
} catch {
|
|
261
|
+
// best effort — stdin may already be gone
|
|
262
|
+
}
|
|
263
|
+
const killTimer = setTimeout(() => {
|
|
264
|
+
try {
|
|
265
|
+
child.kill('SIGKILL');
|
|
266
|
+
} catch {
|
|
267
|
+
// already gone
|
|
268
|
+
}
|
|
269
|
+
}, 30000);
|
|
270
|
+
killTimer.unref();
|
|
271
|
+
child.on('close', () => clearTimeout(killTimer));
|
|
272
|
+
child.unref();
|
|
273
|
+
} catch {
|
|
274
|
+
if (!quiet) process.stderr.write(`[relay] --on-result command failed to run\n`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
195
278
|
// Serves one board on 127.0.0.1 and resolves `done` when it finishes
|
|
196
279
|
// (submitted / acknowledged / timeout / cancelled). The result is also
|
|
197
280
|
// persisted into the board record so `rly wait` / `rly result` can read it
|
|
@@ -211,6 +294,7 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
|
|
|
211
294
|
comment: record.result.comment || '',
|
|
212
295
|
notes: record.result.notes || {},
|
|
213
296
|
annotations: record.result.annotations || [],
|
|
297
|
+
blockEdits: record.result.blockEdits || {},
|
|
214
298
|
updatedAt: new Date().toISOString(),
|
|
215
299
|
};
|
|
216
300
|
}
|
|
@@ -220,8 +304,14 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
|
|
|
220
304
|
}
|
|
221
305
|
|
|
222
306
|
const startedAt = Date.now();
|
|
307
|
+
// Mutation token: only `rly update` (which reads the running-file record)
|
|
308
|
+
// can authenticate to POST /api/update. Never embedded in the page/boot.
|
|
309
|
+
const token = crypto.randomBytes(16).toString('hex');
|
|
310
|
+
let rev = 1;
|
|
223
311
|
let status = 'open';
|
|
224
312
|
let finished = false;
|
|
313
|
+
// Latest client presence ping (null until the first ping arrives).
|
|
314
|
+
let presence = null;
|
|
225
315
|
let resolveDone;
|
|
226
316
|
const done = new Promise((r) => {
|
|
227
317
|
resolveDone = r;
|
|
@@ -233,11 +323,48 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
|
|
|
233
323
|
const theme = reqUrl.searchParams.get('theme') === 'dark' ? 'dark' : 'light';
|
|
234
324
|
try {
|
|
235
325
|
if (req.method === 'GET' && pathname === '/') {
|
|
236
|
-
sendHtml(res, buildPage(record));
|
|
326
|
+
sendHtml(res, buildPage(record, rev));
|
|
237
327
|
} else if (req.method === 'GET' && pathname === '/api/board') {
|
|
238
|
-
sendJson(res, 200, { id: record.id, spec, draft: record.draft, result: record.result });
|
|
328
|
+
sendJson(res, 200, { id: record.id, spec: record.spec, draft: record.draft, result: record.result });
|
|
239
329
|
} else if (req.method === 'GET' && pathname === '/api/status') {
|
|
240
|
-
sendJson(res, 200, { status });
|
|
330
|
+
sendJson(res, 200, { status, rev });
|
|
331
|
+
} else if (req.method === 'POST' && pathname === '/api/ping') {
|
|
332
|
+
const body = JSON.parse((await readBody(req)) || '{}');
|
|
333
|
+
// Validate body shape: visible/focused booleans, idleMs finite >= 0.
|
|
334
|
+
if (
|
|
335
|
+
typeof body.visible === 'boolean' &&
|
|
336
|
+
typeof body.focused === 'boolean' &&
|
|
337
|
+
Number.isFinite(body.idleMs) &&
|
|
338
|
+
body.idleMs >= 0
|
|
339
|
+
) {
|
|
340
|
+
presence = { atMs: Date.now(), visible: body.visible, focused: body.focused, idleMs: body.idleMs };
|
|
341
|
+
}
|
|
342
|
+
sendJson(res, 200, { ok: true });
|
|
343
|
+
} else if (req.method === 'GET' && pathname === '/api/presence') {
|
|
344
|
+
if (!presence) {
|
|
345
|
+
sendJson(res, 200, { open: true, seen: false });
|
|
346
|
+
} else {
|
|
347
|
+
sendJson(res, 200, {
|
|
348
|
+
open: true,
|
|
349
|
+
seen: true,
|
|
350
|
+
visible: presence.visible,
|
|
351
|
+
focused: presence.focused,
|
|
352
|
+
secondsSinceActivity: Math.round((Date.now() - presence.atMs + presence.idleMs) / 1000),
|
|
353
|
+
secondsSincePing: Math.round((Date.now() - presence.atMs) / 1000),
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
} else if (req.method === 'POST' && pathname === '/api/update') {
|
|
357
|
+
if (req.headers['x-relay-token'] !== token) return sendJson(res, 403, { error: 'forbidden' });
|
|
358
|
+
const body = JSON.parse((await readBody(req)) || '{}');
|
|
359
|
+
const next = body.spec;
|
|
360
|
+
if (next === null || typeof next !== 'object' || Array.isArray(next) ||
|
|
361
|
+
!Array.isArray(next.questions) || !Array.isArray(next.blocks)) {
|
|
362
|
+
return sendJson(res, 400, { error: 'spec must be an object with questions[] and blocks[] arrays' });
|
|
363
|
+
}
|
|
364
|
+
record.spec = next;
|
|
365
|
+
rev++;
|
|
366
|
+
saveBoard(record);
|
|
367
|
+
sendJson(res, 200, { ok: true, rev });
|
|
241
368
|
} else if (req.method === 'GET' && pathname === '/kit.js') {
|
|
242
369
|
if (!sendFromDir(res, UI_DIR, 'kit.js', 'application/javascript; charset=utf-8')) {
|
|
243
370
|
sendJs(res, ''); // kit.js authored concurrently — empty fallback keeps iframes working
|
|
@@ -249,16 +376,16 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
|
|
|
249
376
|
}
|
|
250
377
|
} else if (req.method === 'GET' && pathname.startsWith('/html/b/')) {
|
|
251
378
|
const blockId = decodeURIComponent(pathname.slice('/html/b/'.length));
|
|
252
|
-
const block = findHtmlBlock(spec, blockId);
|
|
379
|
+
const block = findHtmlBlock(record.spec, blockId);
|
|
253
380
|
if (!block) return sendJson(res, 404, { error: `no html block "${blockId}"` });
|
|
254
381
|
sendHtml(res, wrapFragment(block.html || '', theme));
|
|
255
382
|
} else if (req.method === 'GET' && pathname === '/html/board') {
|
|
256
383
|
// Legacy alias → the board's first html block.
|
|
257
|
-
const block = firstBoardHtml(spec);
|
|
384
|
+
const block = firstBoardHtml(record.spec);
|
|
258
385
|
sendHtml(res, wrapFragment((block && block.html) || '', theme));
|
|
259
386
|
} else if (req.method === 'GET' && pathname.startsWith('/html/q/')) {
|
|
260
387
|
const qid = decodeURIComponent(pathname.slice('/html/q/'.length));
|
|
261
|
-
const q = spec.questions.find((q) => q.id === qid);
|
|
388
|
+
const q = record.spec.questions.find((q) => q.id === qid);
|
|
262
389
|
if (!q) return sendJson(res, 404, { error: `no question "${qid}"` });
|
|
263
390
|
const block = firstQuestionHtml(q);
|
|
264
391
|
sendHtml(res, wrapFragment((block && block.html) || '', theme));
|
|
@@ -273,6 +400,7 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
|
|
|
273
400
|
comment: typeof body.comment === 'string' ? body.comment : '',
|
|
274
401
|
notes: body.notes && typeof body.notes === 'object' ? body.notes : {},
|
|
275
402
|
annotations: sanitizeAnnotations(body.annotations),
|
|
403
|
+
blockEdits: sanitizeBlockEdits(body.blockEdits),
|
|
276
404
|
updatedAt: new Date().toISOString(),
|
|
277
405
|
};
|
|
278
406
|
saveBoard(record);
|
|
@@ -281,15 +409,16 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
|
|
|
281
409
|
if (status !== 'open') return sendJson(res, 409, { error: 'board already finished' });
|
|
282
410
|
const body = JSON.parse((await readBody(req)) || '{}');
|
|
283
411
|
const answers = body.answers && typeof body.answers === 'object' ? body.answers : {};
|
|
284
|
-
const skipped = spec.questions.filter((q) => !(q.id in answers)).map((q) => q.id);
|
|
412
|
+
const skipped = record.spec.questions.filter((q) => !(q.id in answers)).map((q) => q.id);
|
|
285
413
|
sendJson(res, 200, { ok: true });
|
|
286
414
|
finish({
|
|
287
|
-
status: spec.questions.length ? 'submitted' : 'acknowledged',
|
|
415
|
+
status: record.spec.questions.length ? 'submitted' : 'acknowledged',
|
|
288
416
|
answers,
|
|
289
417
|
skipped,
|
|
290
418
|
comment: typeof body.comment === 'string' ? body.comment : '',
|
|
291
419
|
notes: body.notes && typeof body.notes === 'object' ? body.notes : {},
|
|
292
420
|
annotations: sanitizeAnnotations(body.annotations),
|
|
421
|
+
blockEdits: sanitizeBlockEdits(body.blockEdits),
|
|
293
422
|
});
|
|
294
423
|
} else {
|
|
295
424
|
sendJson(res, 404, { error: 'not found' });
|
|
@@ -311,6 +440,7 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
|
|
|
311
440
|
port: actualPort,
|
|
312
441
|
url,
|
|
313
442
|
title: spec.title,
|
|
443
|
+
token,
|
|
314
444
|
startedAt: new Date().toISOString(),
|
|
315
445
|
});
|
|
316
446
|
|
|
@@ -324,6 +454,10 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
|
|
|
324
454
|
if (finished) return;
|
|
325
455
|
finished = true;
|
|
326
456
|
status = partial.status;
|
|
457
|
+
// blockEdits: from this submit, else fall back to the autosaved draft
|
|
458
|
+
// (timeout/cancel). null when there are none.
|
|
459
|
+
const editsRaw = partial.blockEdits ?? (record.draft?.blockEdits || {});
|
|
460
|
+
const blockEdits = editsRaw && Object.keys(editsRaw).length ? editsRaw : null;
|
|
327
461
|
const result = {
|
|
328
462
|
status: partial.status,
|
|
329
463
|
boardId: record.id,
|
|
@@ -334,6 +468,7 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
|
|
|
334
468
|
comment: partial.comment ?? '',
|
|
335
469
|
notes: partial.notes ?? null,
|
|
336
470
|
annotations: partial.annotations ?? (record.draft?.annotations || []),
|
|
471
|
+
blockEdits,
|
|
337
472
|
createdAt: record.createdAt,
|
|
338
473
|
finishedAt: new Date().toISOString(),
|
|
339
474
|
durationMs: Date.now() - startedAt,
|
|
@@ -345,6 +480,8 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
|
|
|
345
480
|
}
|
|
346
481
|
record.result = result;
|
|
347
482
|
saveBoard(record);
|
|
483
|
+
// Push-wake: run the agent's local command for EVERY terminal status.
|
|
484
|
+
runOnResult(record.onResult, result, { quiet });
|
|
348
485
|
removeRunning(record.id);
|
|
349
486
|
if (timer) clearTimeout(timer);
|
|
350
487
|
process.removeListener('SIGINT', onSignal);
|
package/src/spec.js
CHANGED
|
@@ -25,7 +25,7 @@ const HTML_HEIGHT = { min: 100, max: 2400, boardDefault: 400, questionDefault: 3
|
|
|
25
25
|
|
|
26
26
|
// Block heights clamp to the same window; defaults vary per block type.
|
|
27
27
|
const BLOCK_HEIGHT = { min: 100, max: 2400 };
|
|
28
|
-
export const BLOCK_TYPES = ['markdown', 'mermaid', 'chart', 'table', 'code', 'html'];
|
|
28
|
+
export const BLOCK_TYPES = ['markdown', 'mermaid', 'graphviz', 'plantuml', 'chart', 'table', 'code', 'html'];
|
|
29
29
|
const CHART_KINDS = ['bar', 'line', 'pie', 'doughnut', 'radar', 'scatter'];
|
|
30
30
|
|
|
31
31
|
const asStr = (v) => (typeof v === 'string' ? v : v == null ? '' : String(v));
|
|
@@ -89,6 +89,30 @@ function normalizeBlock(rawBlock, id, cwd, where) {
|
|
|
89
89
|
const code = asStr(rawBlock.code);
|
|
90
90
|
if (!code.trim()) throw new CliError(`${where}: mermaid block needs a non-empty "code" string.`);
|
|
91
91
|
const block = { id, type: 'mermaid', code };
|
|
92
|
+
if (rawBlock.editable === true) block.editable = true;
|
|
93
|
+
if (hasHeight) block.height = clampInt(rawBlock.height, BLOCK_HEIGHT.min, BLOCK_HEIGHT.max, undefined);
|
|
94
|
+
return block;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (type === 'graphviz') {
|
|
98
|
+
const dot = asStr(rawBlock.dot);
|
|
99
|
+
if (!dot.trim()) throw new CliError(`${where}: graphviz block needs a non-empty "dot" string.`);
|
|
100
|
+
const block = { id, type: 'graphviz', dot };
|
|
101
|
+
if (hasHeight) block.height = clampInt(rawBlock.height, BLOCK_HEIGHT.min, BLOCK_HEIGHT.max, undefined);
|
|
102
|
+
return block;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (type === 'plantuml') {
|
|
106
|
+
const code = asStr(rawBlock.code);
|
|
107
|
+
if (!code.trim()) throw new CliError(`${where}: plantuml block needs a non-empty "code" string.`);
|
|
108
|
+
const block = { id, type: 'plantuml', code };
|
|
109
|
+
if (rawBlock.server !== undefined && rawBlock.server !== null && rawBlock.server !== '') {
|
|
110
|
+
const server = asStr(rawBlock.server).trim();
|
|
111
|
+
if (!/^https?:\/\/\S+$/i.test(server)) {
|
|
112
|
+
throw new CliError(`${where}: plantuml "server" must be an http(s) URL string.`);
|
|
113
|
+
}
|
|
114
|
+
block.server = server;
|
|
115
|
+
}
|
|
92
116
|
if (hasHeight) block.height = clampInt(rawBlock.height, BLOCK_HEIGHT.min, BLOCK_HEIGHT.max, undefined);
|
|
93
117
|
return block;
|
|
94
118
|
}
|
|
@@ -343,7 +367,10 @@ const BLOCK_SCHEMA = {
|
|
|
343
367
|
properties: {
|
|
344
368
|
type: { type: 'string', enum: BLOCK_TYPES },
|
|
345
369
|
md: { type: 'string', description: 'markdown: built-in mini renderer (no external library). Text selections are commentable.' },
|
|
346
|
-
code: { type: 'string', description: 'mermaid: diagram source (e.g. "graph TD; A-->B"); code: the source to display.' },
|
|
370
|
+
code: { type: 'string', description: 'mermaid: diagram source (e.g. "graph TD; A-->B"); plantuml: the @startuml…@enduml source; code: the source to display.' },
|
|
371
|
+
editable: { type: 'boolean', description: 'mermaid: when true, render an "Edit diagram" toggle so the user can edit the diagram source live. The edited source is returned in result.blockEdits[<blockId>].' },
|
|
372
|
+
dot: { type: 'string', description: 'graphviz: DOT source (e.g. "digraph { a -> b }"). Rendered offline via vendored Viz.js; nodes and edges are individually commentable.' },
|
|
373
|
+
server: { type: 'string', description: 'plantuml: PlantUML server base URL (http(s)). Defaults to https://www.plantuml.com/plantuml. Diagrams render via this server (needs network).' },
|
|
347
374
|
lang: { type: 'string', description: 'code block: language hint for display.' },
|
|
348
375
|
config: { type: 'object', description: 'chart: a full Chart.js config object.' },
|
|
349
376
|
kind: { type: 'string', enum: CHART_KINDS, description: 'chart shorthand: chart kind (alternative to "config").' },
|
|
@@ -363,7 +390,7 @@ const BLOCK_SCHEMA = {
|
|
|
363
390
|
sortable: { type: 'boolean', description: 'table: enable click-to-sort headers.' },
|
|
364
391
|
html: { type: 'string', description: 'html: custom markup rendered in a sandboxed iframe.' },
|
|
365
392
|
htmlFile: { type: 'string', description: 'html: path to an HTML file (alternative to "html").' },
|
|
366
|
-
height: { type: 'integer', minimum: BLOCK_HEIGHT.min, maximum: BLOCK_HEIGHT.max, description: 'Block height in px. Defaults: chart 320, html 360; markdown/table/code flow naturally; mermaid natural (max 1200, scrolls).' },
|
|
393
|
+
height: { type: 'integer', minimum: BLOCK_HEIGHT.min, maximum: BLOCK_HEIGHT.max, description: 'Block height in px. Defaults: chart 320, html 360; markdown/table/code flow naturally; mermaid/graphviz/plantuml natural (max 1200, scrolls).' },
|
|
367
394
|
},
|
|
368
395
|
},
|
|
369
396
|
};
|
|
@@ -373,7 +400,7 @@ export const SPEC_SCHEMA = {
|
|
|
373
400
|
title: 'relay board spec',
|
|
374
401
|
type: 'object',
|
|
375
402
|
description:
|
|
376
|
-
'A relay board. Renders an optional intro, board-level blocks, then questions (each with optional per-question blocks), then a submit button. The result JSON contains "answers", "comment", per-question "notes",
|
|
403
|
+
'A relay board. Renders an optional intro, board-level blocks, then questions (each with optional per-question blocks), then a submit button. The result JSON contains "answers", "comment", per-question "notes", "annotations" (element-level comments the user attached to any block — see the annotation shape below), and "blockEdits" (a map blockId→edited source for any editable mermaid block the user changed, null when none).',
|
|
377
404
|
properties: {
|
|
378
405
|
title: { type: 'string', description: 'Board title (default "Relay")' },
|
|
379
406
|
intro: { type: 'string', description: 'Intro text shown under the title. Newlines preserved.' },
|
|
@@ -430,6 +457,12 @@ export const SPEC_SCHEMA = {
|
|
|
430
457
|
description:
|
|
431
458
|
'Returned in the result (not part of the input spec). Element-level comments the user attached to blocks. Each: {id, questionId|null, blockId|null, target:{kind:"chart-element"|"mermaid-node"|"table-cell"|"text"|"html-element", …}, text, createdAt}.',
|
|
432
459
|
},
|
|
460
|
+
blockEdits: {
|
|
461
|
+
type: 'object',
|
|
462
|
+
readOnly: true,
|
|
463
|
+
description:
|
|
464
|
+
'Returned in the result (not part of the input spec). A map blockId→edited mermaid source for any editable mermaid block the user changed. null when the user made no edits. Read result.blockEdits[<blockId>] for the user\'s edited diagram source.',
|
|
465
|
+
},
|
|
433
466
|
},
|
|
434
467
|
anyOf: [{ required: ['questions'] }, { required: ['blocks'] }, { required: ['html'] }, { required: ['htmlFile'] }],
|
|
435
468
|
};
|
package/src/ui/annotate.css
CHANGED
|
@@ -50,14 +50,64 @@
|
|
|
50
50
|
margin-bottom: 8px;
|
|
51
51
|
overflow-wrap: anywhere;
|
|
52
52
|
}
|
|
53
|
-
.ann-pop-existing { margin-bottom: 8px; }
|
|
53
|
+
.ann-pop-existing { margin-bottom: 8px; max-height: min(50vh, 420px); overflow-y: auto; }
|
|
54
54
|
.ann-pop-existing:empty { display: none; margin: 0; }
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
55
|
+
|
|
56
|
+
/* ---------- comment threads (popover) ---------- */
|
|
57
|
+
.ann-thread {
|
|
58
|
+
padding: 7px 0;
|
|
58
59
|
border-top: 1px solid var(--border);
|
|
59
60
|
}
|
|
60
|
-
.ann-
|
|
61
|
+
.ann-thread-head { display: flex; align-items: center; gap: 6px; }
|
|
62
|
+
.ann-thread-head .ann-del { margin-left: auto; }
|
|
63
|
+
.ann-pop-text { font-size: 0.88rem; white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
64
|
+
|
|
65
|
+
/* author chips: "you" muted, "agent" accent */
|
|
66
|
+
.ann-chip {
|
|
67
|
+
flex: none;
|
|
68
|
+
display: inline-block;
|
|
69
|
+
background: var(--bg-sunken); color: var(--muted);
|
|
70
|
+
border-radius: 999px;
|
|
71
|
+
padding: 1px 7px;
|
|
72
|
+
font: 600 0.68rem/1.5 var(--sans);
|
|
73
|
+
}
|
|
74
|
+
.ann-chip-agent { background: var(--accent-soft); color: var(--accent); }
|
|
75
|
+
.ann-time { color: var(--muted); font-size: 0.7rem; }
|
|
76
|
+
|
|
77
|
+
/* replies, indented under their comment */
|
|
78
|
+
.ann-replies {
|
|
79
|
+
margin-top: 5px;
|
|
80
|
+
padding-left: 10px;
|
|
81
|
+
border-left: 2px solid var(--border);
|
|
82
|
+
}
|
|
83
|
+
.ann-reply { padding: 3px 0; }
|
|
84
|
+
.ann-reply-head { display: flex; align-items: center; gap: 6px; }
|
|
85
|
+
.ann-reply-text { font-size: 0.84rem; white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
86
|
+
|
|
87
|
+
/* compact per-thread reply form */
|
|
88
|
+
.ann-reply-form { display: flex; gap: 6px; margin-top: 6px; }
|
|
89
|
+
.ann-reply-input {
|
|
90
|
+
flex: 1; min-width: 0;
|
|
91
|
+
background: var(--bg-sunken); color: var(--fg);
|
|
92
|
+
border: 1px solid var(--border); border-radius: 8px;
|
|
93
|
+
padding: 4px 8px;
|
|
94
|
+
font: 0.8rem/1.4 var(--sans);
|
|
95
|
+
}
|
|
96
|
+
.ann-reply-input:focus {
|
|
97
|
+
outline: none;
|
|
98
|
+
border-color: var(--accent);
|
|
99
|
+
box-shadow: 0 0 0 2px var(--accent-soft);
|
|
100
|
+
}
|
|
101
|
+
.ann-reply-btn {
|
|
102
|
+
flex: none;
|
|
103
|
+
background: transparent; color: var(--accent);
|
|
104
|
+
border: 1px solid var(--border); border-radius: 8px;
|
|
105
|
+
padding: 4px 10px;
|
|
106
|
+
font: 600 0.78rem var(--sans);
|
|
107
|
+
cursor: pointer;
|
|
108
|
+
transition: border-color 150ms var(--ease);
|
|
109
|
+
}
|
|
110
|
+
.ann-reply-btn:hover { border-color: var(--accent); }
|
|
61
111
|
.ann-pop textarea.ann-ta {
|
|
62
112
|
width: 100%; min-height: 64px;
|
|
63
113
|
background: var(--bg-sunken); color: var(--fg);
|
|
@@ -131,6 +181,8 @@
|
|
|
131
181
|
.ann-sum-main { flex: 1; min-width: 0; }
|
|
132
182
|
.ann-sum-target { color: var(--muted); font-size: 0.78rem; overflow-wrap: anywhere; }
|
|
133
183
|
.ann-sum-text { font-size: 0.92rem; white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
184
|
+
.ann-sum-meta { display: flex; align-items: center; gap: 6px; margin-top: 2px; }
|
|
185
|
+
.ann-sum-replies { color: var(--muted); font-size: 0.75rem; }
|
|
134
186
|
|
|
135
187
|
/* ---------- jump-to flash ---------- */
|
|
136
188
|
.ann-flash { animation: ann-flash 1s var(--ease); }
|
package/src/ui/annotate.js
CHANGED
|
@@ -40,6 +40,10 @@
|
|
|
40
40
|
}
|
|
41
41
|
case 'mermaid-node':
|
|
42
42
|
return 'Diagram · ' + truncate(t.text || t.nodeId || 'node', 50);
|
|
43
|
+
case 'graphviz-node':
|
|
44
|
+
return 'Graph · ' + truncate(t.text || t.nodeId || 'node', 50);
|
|
45
|
+
case 'image':
|
|
46
|
+
return truncate(t.label || 'Image', 50);
|
|
43
47
|
case 'table-cell': {
|
|
44
48
|
let s = `Table · row ${(Number(t.row) || 0) + 1} · ${t.col}`;
|
|
45
49
|
if (t.value !== undefined && t.value !== null && t.value !== '') s += ` — “${truncate(t.value, 30)}”`;
|
|
@@ -58,6 +62,25 @@
|
|
|
58
62
|
const sameTarget = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
59
63
|
const sameBlock = (a, b) => (a ?? null) === (b ?? null);
|
|
60
64
|
|
|
65
|
+
// Author chip: "you" (muted) for user comments, "agent" (accent) for agent.
|
|
66
|
+
function chip(author) {
|
|
67
|
+
const agent = author === 'agent';
|
|
68
|
+
return el('span', { class: 'ann-chip' + (agent ? ' ann-chip-agent' : '') }, agent ? 'agent' : 'you');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Short HH:MM timestamp (empty string when the date is unparsable).
|
|
72
|
+
function fmtTime(iso) {
|
|
73
|
+
const d = new Date(iso);
|
|
74
|
+
if (isNaN(d.getTime())) return '';
|
|
75
|
+
const p = (n) => String(n).padStart(2, '0');
|
|
76
|
+
return p(d.getHours()) + ':' + p(d.getMinutes());
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function timeEl(iso) {
|
|
80
|
+
const t = fmtTime(iso);
|
|
81
|
+
return t ? el('span', { class: 'ann-time' }, t) : null;
|
|
82
|
+
}
|
|
83
|
+
|
|
61
84
|
// ---------- state ----------
|
|
62
85
|
let annotations = []; // contract annotation objects
|
|
63
86
|
let idCounter = 0; // continues from max existing "aN"
|
|
@@ -177,6 +200,7 @@
|
|
|
177
200
|
function refreshBadges() {
|
|
178
201
|
for (const b of badges) b.remove();
|
|
179
202
|
badges = [];
|
|
203
|
+
if (torndown) return;
|
|
180
204
|
registered = registered.filter((r) => r.el.isConnected);
|
|
181
205
|
for (const entry of registered) {
|
|
182
206
|
const count = matching(entry.info).length;
|
|
@@ -221,7 +245,10 @@
|
|
|
221
245
|
const pop = dom.pop;
|
|
222
246
|
pop.replaceChildren(el('div', { class: 'ann-pop-label' }, humanize(info.target)));
|
|
223
247
|
|
|
224
|
-
// Existing comments on this exact target,
|
|
248
|
+
// Existing comments on this exact target, rendered as threads: author
|
|
249
|
+
// chip + time + delete, the comment text, its replies indented below,
|
|
250
|
+
// and a compact reply input per thread. Deleting a comment deletes its
|
|
251
|
+
// whole thread.
|
|
225
252
|
const existingWrap = el('div', { class: 'ann-pop-existing' });
|
|
226
253
|
const renderExisting = () => {
|
|
227
254
|
existingWrap.replaceChildren();
|
|
@@ -231,7 +258,37 @@
|
|
|
231
258
|
removeAnnotation(a.id);
|
|
232
259
|
renderExisting();
|
|
233
260
|
});
|
|
234
|
-
|
|
261
|
+
const thread = el('div', { class: 'ann-thread' },
|
|
262
|
+
el('div', { class: 'ann-thread-head' }, chip(a.author), timeEl(a.createdAt), del),
|
|
263
|
+
el('div', { class: 'ann-pop-text' }, a.text)
|
|
264
|
+
);
|
|
265
|
+
if (Array.isArray(a.replies) && a.replies.length) {
|
|
266
|
+
const list = el('div', { class: 'ann-replies' });
|
|
267
|
+
for (const r of a.replies) {
|
|
268
|
+
list.append(el('div', { class: 'ann-reply' },
|
|
269
|
+
el('div', { class: 'ann-reply-head' }, chip(r.author), timeEl(r.createdAt)),
|
|
270
|
+
el('div', { class: 'ann-reply-text' }, r.text)
|
|
271
|
+
));
|
|
272
|
+
}
|
|
273
|
+
thread.append(list);
|
|
274
|
+
}
|
|
275
|
+
const input = el('input', { class: 'ann-reply-input', type: 'text', placeholder: 'Reply…', 'aria-label': 'Reply' });
|
|
276
|
+
const btn = el('button', { class: 'ann-reply-btn', type: 'button' }, 'Reply');
|
|
277
|
+
const submitReply = () => {
|
|
278
|
+
const text = input.value.trim();
|
|
279
|
+
if (!text) return;
|
|
280
|
+
addReply(a.id, text);
|
|
281
|
+
renderExisting();
|
|
282
|
+
};
|
|
283
|
+
btn.addEventListener('click', submitReply);
|
|
284
|
+
input.addEventListener('keydown', (e) => {
|
|
285
|
+
if (e.key === 'Enter' && !e.metaKey && !e.ctrlKey) {
|
|
286
|
+
e.preventDefault();
|
|
287
|
+
submitReply();
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
thread.append(el('div', { class: 'ann-reply-form' }, input, btn));
|
|
291
|
+
existingWrap.append(thread);
|
|
235
292
|
}
|
|
236
293
|
};
|
|
237
294
|
renderExisting();
|
|
@@ -278,6 +335,23 @@
|
|
|
278
335
|
target: info.target,
|
|
279
336
|
text,
|
|
280
337
|
createdAt: new Date().toISOString(),
|
|
338
|
+
author: 'user',
|
|
339
|
+
replies: [],
|
|
340
|
+
});
|
|
341
|
+
changed();
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Append a user reply to a top-level annotation's thread (cap 50, like the
|
|
345
|
+
// server). Empty text is ignored by callers.
|
|
346
|
+
function addReply(id, text) {
|
|
347
|
+
const a = annotations.find((x) => x.id === id);
|
|
348
|
+
if (!a) return;
|
|
349
|
+
if (!Array.isArray(a.replies)) a.replies = [];
|
|
350
|
+
if (a.replies.length >= 50) return;
|
|
351
|
+
a.replies.push({
|
|
352
|
+
author: 'user',
|
|
353
|
+
text: String(text).slice(0, 5000),
|
|
354
|
+
createdAt: new Date().toISOString(),
|
|
281
355
|
});
|
|
282
356
|
changed();
|
|
283
357
|
}
|
|
@@ -367,10 +441,18 @@
|
|
|
367
441
|
e.stopPropagation();
|
|
368
442
|
removeAnnotation(a.id);
|
|
369
443
|
});
|
|
444
|
+
// Thread meta: reply count, plus an "agent" chip when the latest entry
|
|
445
|
+
// in the thread (last reply, or the comment itself) is agent-authored.
|
|
446
|
+
const replyCount = Array.isArray(a.replies) ? a.replies.length : 0;
|
|
447
|
+
const latest = replyCount ? a.replies[replyCount - 1] : a;
|
|
448
|
+
const meta = [];
|
|
449
|
+
if (replyCount) meta.push(el('span', { class: 'ann-sum-replies' }, `${replyCount} ${replyCount === 1 ? 'reply' : 'replies'}`));
|
|
450
|
+
if (latest.author === 'agent') meta.push(chip('agent'));
|
|
370
451
|
const row = el('div', { class: 'ann-sum-row' },
|
|
371
452
|
el('div', { class: 'ann-sum-main' },
|
|
372
453
|
el('div', { class: 'ann-sum-target' }, humanize(a.target)),
|
|
373
|
-
el('div', { class: 'ann-sum-text' }, a.text)
|
|
454
|
+
el('div', { class: 'ann-sum-text' }, a.text),
|
|
455
|
+
meta.length ? el('div', { class: 'ann-sum-meta' }, meta) : null
|
|
374
456
|
),
|
|
375
457
|
del
|
|
376
458
|
);
|
|
@@ -393,7 +475,22 @@
|
|
|
393
475
|
// ---------- public API ----------
|
|
394
476
|
function init(opts = {}) {
|
|
395
477
|
ensureDom();
|
|
396
|
-
|
|
478
|
+
// Normalize threads: missing author -> 'user', missing replies -> [].
|
|
479
|
+
annotations = Array.isArray(opts.initial)
|
|
480
|
+
? opts.initial.map((a) => ({
|
|
481
|
+
...a,
|
|
482
|
+
author: a.author === 'agent' ? 'agent' : 'user',
|
|
483
|
+
replies: Array.isArray(a.replies)
|
|
484
|
+
? a.replies
|
|
485
|
+
.filter((r) => r && typeof r === 'object' && typeof r.text === 'string')
|
|
486
|
+
.map((r) => ({
|
|
487
|
+
author: r.author === 'agent' ? 'agent' : 'user',
|
|
488
|
+
text: r.text,
|
|
489
|
+
createdAt: typeof r.createdAt === 'string' ? r.createdAt : '',
|
|
490
|
+
}))
|
|
491
|
+
: [],
|
|
492
|
+
}))
|
|
493
|
+
: [];
|
|
397
494
|
onChange = typeof opts.onChange === 'function' ? opts.onChange : null;
|
|
398
495
|
idCounter = 0;
|
|
399
496
|
for (const a of annotations) {
|
|
@@ -404,7 +501,32 @@
|
|
|
404
501
|
renderSummaries();
|
|
405
502
|
}
|
|
406
503
|
|
|
504
|
+
// Remove every floating element (pin, badges, popover, selection button)
|
|
505
|
+
// and stop reacting — called when the board reaches its submitted screen so
|
|
506
|
+
// nothing leaks over it (they live on <body> with elevated z-index).
|
|
507
|
+
let torndown = false;
|
|
508
|
+
function teardown() {
|
|
509
|
+
torndown = true;
|
|
510
|
+
try {
|
|
511
|
+
closePopover();
|
|
512
|
+
} catch {
|
|
513
|
+
// popover may not be open
|
|
514
|
+
}
|
|
515
|
+
hidePin();
|
|
516
|
+
hideSelBtn();
|
|
517
|
+
for (const b of badges) b.remove();
|
|
518
|
+
badges = [];
|
|
519
|
+
registered = [];
|
|
520
|
+
if (dom) {
|
|
521
|
+
dom.pin.remove();
|
|
522
|
+
dom.selBtn.remove();
|
|
523
|
+
dom.pop.remove();
|
|
524
|
+
dom = null;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
407
528
|
function register(targetEl, info) {
|
|
529
|
+
if (torndown) return;
|
|
408
530
|
ensureDom();
|
|
409
531
|
targetEl.classList.add('ann-target');
|
|
410
532
|
const entry = { el: targetEl, info };
|
|
@@ -415,14 +537,17 @@
|
|
|
415
537
|
}
|
|
416
538
|
|
|
417
539
|
function enableTextSelection(rootEl, baseInfo) {
|
|
540
|
+
if (torndown) return;
|
|
418
541
|
ensureDom();
|
|
419
542
|
rootEl.addEventListener('mouseup', () => {
|
|
543
|
+
if (torndown) return;
|
|
420
544
|
// defer: the selection settles after mouseup
|
|
421
545
|
setTimeout(() => maybeShowSelBtn(rootEl, baseInfo), 0);
|
|
422
546
|
});
|
|
423
547
|
}
|
|
424
548
|
|
|
425
549
|
function openExternal(info, anchorEl) {
|
|
550
|
+
if (torndown) return;
|
|
426
551
|
openPopover(info, anchorEl);
|
|
427
552
|
}
|
|
428
553
|
|
|
@@ -436,5 +561,5 @@
|
|
|
436
561
|
renderSummaryInto(target);
|
|
437
562
|
}
|
|
438
563
|
|
|
439
|
-
window.RelayAnnotate = { init, register, enableTextSelection, openExternal, list, renderSummary };
|
|
564
|
+
window.RelayAnnotate = { init, register, enableTextSelection, openExternal, list, renderSummary, teardown };
|
|
440
565
|
})();
|