@khanglvm/relay 0.2.0 → 0.3.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 +72 -5
- package/docs/AGENT.md +70 -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 +58 -24
- package/skills/relay/examples/blocks-showcase.json +9 -0
- package/src/cli.js +115 -2
- package/src/server.js +50 -11
- package/src/spec.js +28 -3
- package/src/ui/annotate.css +57 -5
- package/src/ui/annotate.js +100 -4
- package/src/ui/app.js +44 -0
- package/src/ui/blocks.css +34 -0
- package/src/ui/blocks.js +136 -0
- 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,7 @@
|
|
|
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';
|
|
4
5
|
import { fileURLToPath } from 'node:url';
|
|
5
6
|
import { loadBoard, saveBoard, saveRunning, removeRunning, loadPref, savePref } from './store.js';
|
|
6
7
|
import { openUrl } from './open.js';
|
|
@@ -46,7 +47,7 @@ function vendorPresent(file) {
|
|
|
46
47
|
}
|
|
47
48
|
}
|
|
48
49
|
|
|
49
|
-
function buildPage(record) {
|
|
50
|
+
function buildPage(record, rev) {
|
|
50
51
|
const html = fs.readFileSync(path.join(UI_DIR, 'index.html'), 'utf8');
|
|
51
52
|
const css = readUi('style.css');
|
|
52
53
|
const blocksCss = readUi('blocks.css');
|
|
@@ -69,6 +70,7 @@ function buildPage(record) {
|
|
|
69
70
|
const vendor = {
|
|
70
71
|
chart: specNeeds(spec, 'chart') && vendorPresent('chart.umd.js'),
|
|
71
72
|
mermaid: specNeeds(spec, 'mermaid') && vendorPresent('mermaid.min.js'),
|
|
73
|
+
viz: specNeeds(spec, 'graphviz') && vendorPresent('viz-standalone.js'),
|
|
72
74
|
};
|
|
73
75
|
// Prefill from the LIVE draft at request time (drafts autosave in real time),
|
|
74
76
|
// so a mid-fill page reload restores everything the user already entered.
|
|
@@ -80,7 +82,7 @@ function buildPage(record) {
|
|
|
80
82
|
annotations: record.draft.annotations || [],
|
|
81
83
|
}
|
|
82
84
|
: null;
|
|
83
|
-
const boot = { boardId: record.id, spec: clientSpec, prefill, pref: loadPref(), vendor };
|
|
85
|
+
const boot = { boardId: record.id, spec: clientSpec, prefill, pref: loadPref(), vendor, rev };
|
|
84
86
|
const json = JSON.stringify(boot).replace(/</g, '\\u003c');
|
|
85
87
|
return html
|
|
86
88
|
.split('__TITLE__').join(escapeHtml(spec.title))
|
|
@@ -93,8 +95,26 @@ function buildPage(record) {
|
|
|
93
95
|
.split('__BOOT_JSON__').join(json);
|
|
94
96
|
}
|
|
95
97
|
|
|
98
|
+
// Validates + sanitizes one annotation's threaded replies. Keeps only
|
|
99
|
+
// well-formed {author, text, createdAt} entries; caps at 50; coerces author.
|
|
100
|
+
function sanitizeReplies(value) {
|
|
101
|
+
if (!Array.isArray(value)) return [];
|
|
102
|
+
const out = [];
|
|
103
|
+
for (const r of value) {
|
|
104
|
+
if (out.length >= 50) break;
|
|
105
|
+
if (r === null || typeof r !== 'object' || Array.isArray(r)) continue;
|
|
106
|
+
if (typeof r.text !== 'string' || r.text.length > 5000) continue;
|
|
107
|
+
const author = r.author === 'agent' ? 'agent' : 'user';
|
|
108
|
+
const createdAt = typeof r.createdAt === 'string' ? r.createdAt : new Date().toISOString();
|
|
109
|
+
out.push({ author, text: r.text, createdAt });
|
|
110
|
+
}
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
|
|
96
114
|
// Validates + sanitizes an incoming annotations array (from draft/submit).
|
|
97
115
|
// Drops anything that isn't a well-formed annotation object; caps at 500.
|
|
116
|
+
// Each annotation may carry an optional author ('user'|'agent', default
|
|
117
|
+
// 'user') and a threaded replies array (validated + capped at 50).
|
|
98
118
|
function sanitizeAnnotations(value) {
|
|
99
119
|
if (!Array.isArray(value)) return [];
|
|
100
120
|
const out = [];
|
|
@@ -103,7 +123,9 @@ function sanitizeAnnotations(value) {
|
|
|
103
123
|
if (a === null || typeof a !== 'object' || Array.isArray(a)) continue;
|
|
104
124
|
if (typeof a.text !== 'string' || a.text.length > 5000) continue;
|
|
105
125
|
if (a.target === null || typeof a.target !== 'object' || Array.isArray(a.target)) continue;
|
|
106
|
-
|
|
126
|
+
const clean = { ...a, author: a.author === 'agent' ? 'agent' : 'user' };
|
|
127
|
+
if (a.replies !== undefined) clean.replies = sanitizeReplies(a.replies);
|
|
128
|
+
out.push(clean);
|
|
107
129
|
}
|
|
108
130
|
return out;
|
|
109
131
|
}
|
|
@@ -220,6 +242,10 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
|
|
|
220
242
|
}
|
|
221
243
|
|
|
222
244
|
const startedAt = Date.now();
|
|
245
|
+
// Mutation token: only `rly update` (which reads the running-file record)
|
|
246
|
+
// can authenticate to POST /api/update. Never embedded in the page/boot.
|
|
247
|
+
const token = crypto.randomBytes(16).toString('hex');
|
|
248
|
+
let rev = 1;
|
|
223
249
|
let status = 'open';
|
|
224
250
|
let finished = false;
|
|
225
251
|
let resolveDone;
|
|
@@ -233,11 +259,23 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
|
|
|
233
259
|
const theme = reqUrl.searchParams.get('theme') === 'dark' ? 'dark' : 'light';
|
|
234
260
|
try {
|
|
235
261
|
if (req.method === 'GET' && pathname === '/') {
|
|
236
|
-
sendHtml(res, buildPage(record));
|
|
262
|
+
sendHtml(res, buildPage(record, rev));
|
|
237
263
|
} else if (req.method === 'GET' && pathname === '/api/board') {
|
|
238
|
-
sendJson(res, 200, { id: record.id, spec, draft: record.draft, result: record.result });
|
|
264
|
+
sendJson(res, 200, { id: record.id, spec: record.spec, draft: record.draft, result: record.result });
|
|
239
265
|
} else if (req.method === 'GET' && pathname === '/api/status') {
|
|
240
|
-
sendJson(res, 200, { status });
|
|
266
|
+
sendJson(res, 200, { status, rev });
|
|
267
|
+
} else if (req.method === 'POST' && pathname === '/api/update') {
|
|
268
|
+
if (req.headers['x-relay-token'] !== token) return sendJson(res, 403, { error: 'forbidden' });
|
|
269
|
+
const body = JSON.parse((await readBody(req)) || '{}');
|
|
270
|
+
const next = body.spec;
|
|
271
|
+
if (next === null || typeof next !== 'object' || Array.isArray(next) ||
|
|
272
|
+
!Array.isArray(next.questions) || !Array.isArray(next.blocks)) {
|
|
273
|
+
return sendJson(res, 400, { error: 'spec must be an object with questions[] and blocks[] arrays' });
|
|
274
|
+
}
|
|
275
|
+
record.spec = next;
|
|
276
|
+
rev++;
|
|
277
|
+
saveBoard(record);
|
|
278
|
+
sendJson(res, 200, { ok: true, rev });
|
|
241
279
|
} else if (req.method === 'GET' && pathname === '/kit.js') {
|
|
242
280
|
if (!sendFromDir(res, UI_DIR, 'kit.js', 'application/javascript; charset=utf-8')) {
|
|
243
281
|
sendJs(res, ''); // kit.js authored concurrently — empty fallback keeps iframes working
|
|
@@ -249,16 +287,16 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
|
|
|
249
287
|
}
|
|
250
288
|
} else if (req.method === 'GET' && pathname.startsWith('/html/b/')) {
|
|
251
289
|
const blockId = decodeURIComponent(pathname.slice('/html/b/'.length));
|
|
252
|
-
const block = findHtmlBlock(spec, blockId);
|
|
290
|
+
const block = findHtmlBlock(record.spec, blockId);
|
|
253
291
|
if (!block) return sendJson(res, 404, { error: `no html block "${blockId}"` });
|
|
254
292
|
sendHtml(res, wrapFragment(block.html || '', theme));
|
|
255
293
|
} else if (req.method === 'GET' && pathname === '/html/board') {
|
|
256
294
|
// Legacy alias → the board's first html block.
|
|
257
|
-
const block = firstBoardHtml(spec);
|
|
295
|
+
const block = firstBoardHtml(record.spec);
|
|
258
296
|
sendHtml(res, wrapFragment((block && block.html) || '', theme));
|
|
259
297
|
} else if (req.method === 'GET' && pathname.startsWith('/html/q/')) {
|
|
260
298
|
const qid = decodeURIComponent(pathname.slice('/html/q/'.length));
|
|
261
|
-
const q = spec.questions.find((q) => q.id === qid);
|
|
299
|
+
const q = record.spec.questions.find((q) => q.id === qid);
|
|
262
300
|
if (!q) return sendJson(res, 404, { error: `no question "${qid}"` });
|
|
263
301
|
const block = firstQuestionHtml(q);
|
|
264
302
|
sendHtml(res, wrapFragment((block && block.html) || '', theme));
|
|
@@ -281,10 +319,10 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
|
|
|
281
319
|
if (status !== 'open') return sendJson(res, 409, { error: 'board already finished' });
|
|
282
320
|
const body = JSON.parse((await readBody(req)) || '{}');
|
|
283
321
|
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);
|
|
322
|
+
const skipped = record.spec.questions.filter((q) => !(q.id in answers)).map((q) => q.id);
|
|
285
323
|
sendJson(res, 200, { ok: true });
|
|
286
324
|
finish({
|
|
287
|
-
status: spec.questions.length ? 'submitted' : 'acknowledged',
|
|
325
|
+
status: record.spec.questions.length ? 'submitted' : 'acknowledged',
|
|
288
326
|
answers,
|
|
289
327
|
skipped,
|
|
290
328
|
comment: typeof body.comment === 'string' ? body.comment : '',
|
|
@@ -311,6 +349,7 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
|
|
|
311
349
|
port: actualPort,
|
|
312
350
|
url,
|
|
313
351
|
title: spec.title,
|
|
352
|
+
token,
|
|
314
353
|
startedAt: new Date().toISOString(),
|
|
315
354
|
});
|
|
316
355
|
|
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));
|
|
@@ -93,6 +93,29 @@ function normalizeBlock(rawBlock, id, cwd, where) {
|
|
|
93
93
|
return block;
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
if (type === 'graphviz') {
|
|
97
|
+
const dot = asStr(rawBlock.dot);
|
|
98
|
+
if (!dot.trim()) throw new CliError(`${where}: graphviz block needs a non-empty "dot" string.`);
|
|
99
|
+
const block = { id, type: 'graphviz', dot };
|
|
100
|
+
if (hasHeight) block.height = clampInt(rawBlock.height, BLOCK_HEIGHT.min, BLOCK_HEIGHT.max, undefined);
|
|
101
|
+
return block;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (type === 'plantuml') {
|
|
105
|
+
const code = asStr(rawBlock.code);
|
|
106
|
+
if (!code.trim()) throw new CliError(`${where}: plantuml block needs a non-empty "code" string.`);
|
|
107
|
+
const block = { id, type: 'plantuml', code };
|
|
108
|
+
if (rawBlock.server !== undefined && rawBlock.server !== null && rawBlock.server !== '') {
|
|
109
|
+
const server = asStr(rawBlock.server).trim();
|
|
110
|
+
if (!/^https?:\/\/\S+$/i.test(server)) {
|
|
111
|
+
throw new CliError(`${where}: plantuml "server" must be an http(s) URL string.`);
|
|
112
|
+
}
|
|
113
|
+
block.server = server;
|
|
114
|
+
}
|
|
115
|
+
if (hasHeight) block.height = clampInt(rawBlock.height, BLOCK_HEIGHT.min, BLOCK_HEIGHT.max, undefined);
|
|
116
|
+
return block;
|
|
117
|
+
}
|
|
118
|
+
|
|
96
119
|
if (type === 'code') {
|
|
97
120
|
const code = asStr(rawBlock.code);
|
|
98
121
|
if (!code) throw new CliError(`${where}: code block needs a "code" string.`);
|
|
@@ -343,7 +366,9 @@ const BLOCK_SCHEMA = {
|
|
|
343
366
|
properties: {
|
|
344
367
|
type: { type: 'string', enum: BLOCK_TYPES },
|
|
345
368
|
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.' },
|
|
369
|
+
code: { type: 'string', description: 'mermaid: diagram source (e.g. "graph TD; A-->B"); plantuml: the @startuml…@enduml source; code: the source to display.' },
|
|
370
|
+
dot: { type: 'string', description: 'graphviz: DOT source (e.g. "digraph { a -> b }"). Rendered offline via vendored Viz.js; nodes and edges are individually commentable.' },
|
|
371
|
+
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
372
|
lang: { type: 'string', description: 'code block: language hint for display.' },
|
|
348
373
|
config: { type: 'object', description: 'chart: a full Chart.js config object.' },
|
|
349
374
|
kind: { type: 'string', enum: CHART_KINDS, description: 'chart shorthand: chart kind (alternative to "config").' },
|
|
@@ -363,7 +388,7 @@ const BLOCK_SCHEMA = {
|
|
|
363
388
|
sortable: { type: 'boolean', description: 'table: enable click-to-sort headers.' },
|
|
364
389
|
html: { type: 'string', description: 'html: custom markup rendered in a sandboxed iframe.' },
|
|
365
390
|
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).' },
|
|
391
|
+
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
392
|
},
|
|
368
393
|
},
|
|
369
394
|
};
|
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"
|
|
@@ -221,7 +244,10 @@
|
|
|
221
244
|
const pop = dom.pop;
|
|
222
245
|
pop.replaceChildren(el('div', { class: 'ann-pop-label' }, humanize(info.target)));
|
|
223
246
|
|
|
224
|
-
// Existing comments on this exact target,
|
|
247
|
+
// Existing comments on this exact target, rendered as threads: author
|
|
248
|
+
// chip + time + delete, the comment text, its replies indented below,
|
|
249
|
+
// and a compact reply input per thread. Deleting a comment deletes its
|
|
250
|
+
// whole thread.
|
|
225
251
|
const existingWrap = el('div', { class: 'ann-pop-existing' });
|
|
226
252
|
const renderExisting = () => {
|
|
227
253
|
existingWrap.replaceChildren();
|
|
@@ -231,7 +257,37 @@
|
|
|
231
257
|
removeAnnotation(a.id);
|
|
232
258
|
renderExisting();
|
|
233
259
|
});
|
|
234
|
-
|
|
260
|
+
const thread = el('div', { class: 'ann-thread' },
|
|
261
|
+
el('div', { class: 'ann-thread-head' }, chip(a.author), timeEl(a.createdAt), del),
|
|
262
|
+
el('div', { class: 'ann-pop-text' }, a.text)
|
|
263
|
+
);
|
|
264
|
+
if (Array.isArray(a.replies) && a.replies.length) {
|
|
265
|
+
const list = el('div', { class: 'ann-replies' });
|
|
266
|
+
for (const r of a.replies) {
|
|
267
|
+
list.append(el('div', { class: 'ann-reply' },
|
|
268
|
+
el('div', { class: 'ann-reply-head' }, chip(r.author), timeEl(r.createdAt)),
|
|
269
|
+
el('div', { class: 'ann-reply-text' }, r.text)
|
|
270
|
+
));
|
|
271
|
+
}
|
|
272
|
+
thread.append(list);
|
|
273
|
+
}
|
|
274
|
+
const input = el('input', { class: 'ann-reply-input', type: 'text', placeholder: 'Reply…', 'aria-label': 'Reply' });
|
|
275
|
+
const btn = el('button', { class: 'ann-reply-btn', type: 'button' }, 'Reply');
|
|
276
|
+
const submitReply = () => {
|
|
277
|
+
const text = input.value.trim();
|
|
278
|
+
if (!text) return;
|
|
279
|
+
addReply(a.id, text);
|
|
280
|
+
renderExisting();
|
|
281
|
+
};
|
|
282
|
+
btn.addEventListener('click', submitReply);
|
|
283
|
+
input.addEventListener('keydown', (e) => {
|
|
284
|
+
if (e.key === 'Enter' && !e.metaKey && !e.ctrlKey) {
|
|
285
|
+
e.preventDefault();
|
|
286
|
+
submitReply();
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
thread.append(el('div', { class: 'ann-reply-form' }, input, btn));
|
|
290
|
+
existingWrap.append(thread);
|
|
235
291
|
}
|
|
236
292
|
};
|
|
237
293
|
renderExisting();
|
|
@@ -278,6 +334,23 @@
|
|
|
278
334
|
target: info.target,
|
|
279
335
|
text,
|
|
280
336
|
createdAt: new Date().toISOString(),
|
|
337
|
+
author: 'user',
|
|
338
|
+
replies: [],
|
|
339
|
+
});
|
|
340
|
+
changed();
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Append a user reply to a top-level annotation's thread (cap 50, like the
|
|
344
|
+
// server). Empty text is ignored by callers.
|
|
345
|
+
function addReply(id, text) {
|
|
346
|
+
const a = annotations.find((x) => x.id === id);
|
|
347
|
+
if (!a) return;
|
|
348
|
+
if (!Array.isArray(a.replies)) a.replies = [];
|
|
349
|
+
if (a.replies.length >= 50) return;
|
|
350
|
+
a.replies.push({
|
|
351
|
+
author: 'user',
|
|
352
|
+
text: String(text).slice(0, 5000),
|
|
353
|
+
createdAt: new Date().toISOString(),
|
|
281
354
|
});
|
|
282
355
|
changed();
|
|
283
356
|
}
|
|
@@ -367,10 +440,18 @@
|
|
|
367
440
|
e.stopPropagation();
|
|
368
441
|
removeAnnotation(a.id);
|
|
369
442
|
});
|
|
443
|
+
// Thread meta: reply count, plus an "agent" chip when the latest entry
|
|
444
|
+
// in the thread (last reply, or the comment itself) is agent-authored.
|
|
445
|
+
const replyCount = Array.isArray(a.replies) ? a.replies.length : 0;
|
|
446
|
+
const latest = replyCount ? a.replies[replyCount - 1] : a;
|
|
447
|
+
const meta = [];
|
|
448
|
+
if (replyCount) meta.push(el('span', { class: 'ann-sum-replies' }, `${replyCount} ${replyCount === 1 ? 'reply' : 'replies'}`));
|
|
449
|
+
if (latest.author === 'agent') meta.push(chip('agent'));
|
|
370
450
|
const row = el('div', { class: 'ann-sum-row' },
|
|
371
451
|
el('div', { class: 'ann-sum-main' },
|
|
372
452
|
el('div', { class: 'ann-sum-target' }, humanize(a.target)),
|
|
373
|
-
el('div', { class: 'ann-sum-text' }, a.text)
|
|
453
|
+
el('div', { class: 'ann-sum-text' }, a.text),
|
|
454
|
+
meta.length ? el('div', { class: 'ann-sum-meta' }, meta) : null
|
|
374
455
|
),
|
|
375
456
|
del
|
|
376
457
|
);
|
|
@@ -393,7 +474,22 @@
|
|
|
393
474
|
// ---------- public API ----------
|
|
394
475
|
function init(opts = {}) {
|
|
395
476
|
ensureDom();
|
|
396
|
-
|
|
477
|
+
// Normalize threads: missing author -> 'user', missing replies -> [].
|
|
478
|
+
annotations = Array.isArray(opts.initial)
|
|
479
|
+
? opts.initial.map((a) => ({
|
|
480
|
+
...a,
|
|
481
|
+
author: a.author === 'agent' ? 'agent' : 'user',
|
|
482
|
+
replies: Array.isArray(a.replies)
|
|
483
|
+
? a.replies
|
|
484
|
+
.filter((r) => r && typeof r === 'object' && typeof r.text === 'string')
|
|
485
|
+
.map((r) => ({
|
|
486
|
+
author: r.author === 'agent' ? 'agent' : 'user',
|
|
487
|
+
text: r.text,
|
|
488
|
+
createdAt: typeof r.createdAt === 'string' ? r.createdAt : '',
|
|
489
|
+
}))
|
|
490
|
+
: [],
|
|
491
|
+
}))
|
|
492
|
+
: [];
|
|
397
493
|
onChange = typeof opts.onChange === 'function' ? opts.onChange : null;
|
|
398
494
|
idCounter = 0;
|
|
399
495
|
for (const a of annotations) {
|
package/src/ui/app.js
CHANGED
|
@@ -6,6 +6,10 @@
|
|
|
6
6
|
const QS = spec.questions || [];
|
|
7
7
|
const app = document.getElementById('app');
|
|
8
8
|
const banner = document.getElementById('banner');
|
|
9
|
+
// Live-update baseline: the server's rev at page-build time. The heartbeat
|
|
10
|
+
// compares /api/status.rev to this and reloads the board when it advances
|
|
11
|
+
// (an agent ran `rly update`).
|
|
12
|
+
const bootRev = typeof boot.rev === 'number' ? boot.rev : null;
|
|
9
13
|
|
|
10
14
|
// ---------- helpers ----------
|
|
11
15
|
function el(tag, attrs = {}, ...children) {
|
|
@@ -22,6 +26,14 @@
|
|
|
22
26
|
return n;
|
|
23
27
|
}
|
|
24
28
|
|
|
29
|
+
// Small, self-dismissing toast pinned to the top-center of the viewport.
|
|
30
|
+
// Used to flag a live `rly update` after the reload (accent-soft bg).
|
|
31
|
+
function showToast(message) {
|
|
32
|
+
const toast = el('div', { class: 'toast' }, message);
|
|
33
|
+
document.body.append(toast);
|
|
34
|
+
setTimeout(() => toast.remove(), 4000);
|
|
35
|
+
}
|
|
36
|
+
|
|
25
37
|
// ---------- theme (auto -> light -> dark) ----------
|
|
26
38
|
// Server-side pref wins: boards run on random ports, so localStorage alone
|
|
27
39
|
// can't carry the choice across boards. The server persists it globally.
|
|
@@ -413,6 +425,16 @@
|
|
|
413
425
|
});
|
|
414
426
|
applyTheme();
|
|
415
427
|
|
|
428
|
+
// Just reloaded after a live `rly update`? Flag set before reload below.
|
|
429
|
+
try {
|
|
430
|
+
if (sessionStorage.getItem('relay-updated') === '1') {
|
|
431
|
+
sessionStorage.removeItem('relay-updated');
|
|
432
|
+
showToast('Board updated by the agent');
|
|
433
|
+
}
|
|
434
|
+
} catch {
|
|
435
|
+
// sessionStorage may be unavailable (privacy mode) — non-fatal
|
|
436
|
+
}
|
|
437
|
+
|
|
416
438
|
app.append(el('header', { class: 'qb-header' }, el('h1', {}, spec.title), themeBtn));
|
|
417
439
|
if (spec.intro) {
|
|
418
440
|
const intro = el('p', { class: 'intro' }, spec.intro);
|
|
@@ -584,11 +606,33 @@
|
|
|
584
606
|
|
|
585
607
|
// ---------- heartbeat ----------
|
|
586
608
|
let misses = 0;
|
|
609
|
+
let reloading = false;
|
|
587
610
|
let hb = setInterval(async () => {
|
|
588
611
|
try {
|
|
589
612
|
const r = await fetch('/api/status', { cache: 'no-store' });
|
|
590
613
|
if (!r.ok) throw new Error('bad status');
|
|
591
614
|
misses = 0;
|
|
615
|
+
// Live update: the agent ran `rly update`, advancing the server rev.
|
|
616
|
+
// Flush whatever the user has typed so far (the reload re-prefills from
|
|
617
|
+
// the live draft — answers for now-removed question ids are ignored),
|
|
618
|
+
// then reload to render the new spec. Guarded so it fires once.
|
|
619
|
+
const body = await r.json().catch(() => null);
|
|
620
|
+
if (body && typeof body.rev === 'number' && bootRev !== null && body.rev !== bootRev && !submitted && !reloading) {
|
|
621
|
+
reloading = true;
|
|
622
|
+
stopHeartbeat();
|
|
623
|
+
clearTimeout(saveTimer);
|
|
624
|
+
try {
|
|
625
|
+
await saveDraft();
|
|
626
|
+
} catch {
|
|
627
|
+
// a failed final save shouldn't block the reload to the new spec
|
|
628
|
+
}
|
|
629
|
+
try {
|
|
630
|
+
sessionStorage.setItem('relay-updated', '1');
|
|
631
|
+
} catch {
|
|
632
|
+
// sessionStorage may be unavailable — the reload still applies the update
|
|
633
|
+
}
|
|
634
|
+
location.reload();
|
|
635
|
+
}
|
|
592
636
|
} catch {
|
|
593
637
|
if (++misses >= 2 && !submitted) {
|
|
594
638
|
banner.textContent = 'This board is closed — the server has stopped. Answers up to your last edit were autosaved.';
|
package/src/ui/blocks.css
CHANGED
|
@@ -168,3 +168,37 @@
|
|
|
168
168
|
}
|
|
169
169
|
.blk-mermaid .node,
|
|
170
170
|
.blk-mermaid .edgeLabel { cursor: default; }
|
|
171
|
+
|
|
172
|
+
/* ---------- graphviz (offline, vendored Viz.js) ---------- */
|
|
173
|
+
.blk-graphviz {
|
|
174
|
+
overflow: auto;
|
|
175
|
+
max-height: 1200px;
|
|
176
|
+
text-align: center;
|
|
177
|
+
/* graphviz draws on its own white canvas; frame it as a deliberate card so
|
|
178
|
+
it reads intentional in dark mode instead of a stray white rectangle */
|
|
179
|
+
background: #fff;
|
|
180
|
+
border: 1px solid var(--border);
|
|
181
|
+
border-radius: 10px;
|
|
182
|
+
padding: 10px;
|
|
183
|
+
}
|
|
184
|
+
.blk-graphviz svg {
|
|
185
|
+
max-width: 100%;
|
|
186
|
+
height: auto;
|
|
187
|
+
}
|
|
188
|
+
.blk-graphviz g.node,
|
|
189
|
+
.blk-graphviz g.edge { cursor: default; }
|
|
190
|
+
|
|
191
|
+
/* ---------- plantuml (server-rendered image) ---------- */
|
|
192
|
+
.blk-plantuml {
|
|
193
|
+
overflow: auto;
|
|
194
|
+
max-height: 1200px;
|
|
195
|
+
text-align: center;
|
|
196
|
+
background: #fff;
|
|
197
|
+
border: 1px solid var(--border);
|
|
198
|
+
border-radius: 10px;
|
|
199
|
+
padding: 10px;
|
|
200
|
+
}
|
|
201
|
+
.blk-plantuml .blk-plantuml-img {
|
|
202
|
+
max-width: 100%;
|
|
203
|
+
height: auto;
|
|
204
|
+
}
|