@khanglvm/relay 0.15.0 → 0.16.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 +15 -1
- package/docs/AGENT.md +52 -14
- package/package.json +1 -1
- package/skills/relay/SKILL.md +37 -7
- package/src/cli.js +22 -11
- package/src/mcp-ui/board.js +46 -8
- package/src/mcp.js +9 -3
- package/src/server.js +110 -11
- package/src/spec.js +5 -0
- package/src/store.js +15 -0
- package/src/ui/annotate.js +57 -19
- package/src/ui/app.js +25 -6
- package/src/ui/blocks.css +58 -8
- package/src/ui/blocks.js +240 -28
package/src/server.js
CHANGED
|
@@ -5,13 +5,28 @@ import path from 'node:path';
|
|
|
5
5
|
import crypto from 'node:crypto';
|
|
6
6
|
import { spawn } from 'node:child_process';
|
|
7
7
|
import { fileURLToPath } from 'node:url';
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
loadBoard,
|
|
10
|
+
saveBoard,
|
|
11
|
+
saveRunning,
|
|
12
|
+
removeRunning,
|
|
13
|
+
loadPref,
|
|
14
|
+
savePref,
|
|
15
|
+
saveBoardArtifact,
|
|
16
|
+
artifactDirPath,
|
|
17
|
+
} from './store.js';
|
|
9
18
|
import { openUrl } from './open.js';
|
|
10
19
|
import { assertSpecReady } from './spec.js';
|
|
11
20
|
|
|
12
21
|
const UI_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), 'ui');
|
|
13
22
|
const PKG_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
14
23
|
const VENDOR_DIR = path.join(PKG_ROOT, 'vendor');
|
|
24
|
+
const ARTIFACT_MAX_BYTES = 4 * 1024 * 1024;
|
|
25
|
+
const ARTIFACT_MIMES = {
|
|
26
|
+
'image/png': 'png',
|
|
27
|
+
'image/jpeg': 'jpg',
|
|
28
|
+
'image/webp': 'webp',
|
|
29
|
+
};
|
|
15
30
|
|
|
16
31
|
const escapeHtml = (s) =>
|
|
17
32
|
s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
@@ -262,7 +277,51 @@ function sanitizeReplies(value) {
|
|
|
262
277
|
// Drops anything that isn't a well-formed annotation object; caps at 500.
|
|
263
278
|
// Each annotation may carry an optional author ('user'|'agent', default
|
|
264
279
|
// 'user') and a threaded replies array (validated + capped at 50).
|
|
265
|
-
function
|
|
280
|
+
function sanitizeAnnotationCrop(value, boardId) {
|
|
281
|
+
if (!value || typeof value !== 'object' || Array.isArray(value) || !boardId) return null;
|
|
282
|
+
const rawPath = typeof value.path === 'string' ? value.path : '';
|
|
283
|
+
if (!rawPath) return null;
|
|
284
|
+
const base = path.resolve(artifactDirPath(boardId)) + path.sep;
|
|
285
|
+
const resolved = path.resolve(rawPath);
|
|
286
|
+
if (!resolved.startsWith(base)) return null;
|
|
287
|
+
let stat;
|
|
288
|
+
try { stat = fs.statSync(resolved); } catch { return null; }
|
|
289
|
+
if (!stat.isFile() || stat.size < 1 || stat.size > ARTIFACT_MAX_BYTES) return null;
|
|
290
|
+
const mime = ARTIFACT_MIMES[value.mime] ? value.mime : 'image/png';
|
|
291
|
+
return {
|
|
292
|
+
path: resolved,
|
|
293
|
+
name: path.basename(resolved),
|
|
294
|
+
mime,
|
|
295
|
+
bytes: stat.size,
|
|
296
|
+
width: Math.max(1, Math.min(10000, Number.parseInt(value.width, 10) || 1)),
|
|
297
|
+
height: Math.max(1, Math.min(10000, Number.parseInt(value.height, 10) || 1)),
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function sanitizeAnnotationTarget(value, boardId) {
|
|
302
|
+
const clean = { ...value };
|
|
303
|
+
delete clean.cropDataUrl;
|
|
304
|
+
if (clean.kind !== 'image-region') {
|
|
305
|
+
delete clean.crop;
|
|
306
|
+
return clean;
|
|
307
|
+
}
|
|
308
|
+
const unit = (v) => Math.max(0, Math.min(1, Number(v) || 0));
|
|
309
|
+
clean.x = unit(clean.x);
|
|
310
|
+
clean.y = unit(clean.y);
|
|
311
|
+
clean.w = unit(clean.w);
|
|
312
|
+
clean.h = unit(clean.h);
|
|
313
|
+
if (clean.x + clean.w > 1) clean.w = 1 - clean.x;
|
|
314
|
+
if (clean.y + clean.h > 1) clean.h = 1 - clean.y;
|
|
315
|
+
clean.label = limitString(clean.label, 200);
|
|
316
|
+
if (clean.side === 'before' || clean.side === 'after') clean.side = clean.side;
|
|
317
|
+
else delete clean.side;
|
|
318
|
+
const crop = sanitizeAnnotationCrop(clean.crop, boardId);
|
|
319
|
+
if (crop) clean.crop = crop;
|
|
320
|
+
else delete clean.crop;
|
|
321
|
+
return clean;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function sanitizeAnnotations(value, boardId) {
|
|
266
325
|
if (!Array.isArray(value)) return [];
|
|
267
326
|
const out = [];
|
|
268
327
|
for (const a of value) {
|
|
@@ -270,7 +329,11 @@ function sanitizeAnnotations(value) {
|
|
|
270
329
|
if (a === null || typeof a !== 'object' || Array.isArray(a)) continue;
|
|
271
330
|
if (typeof a.text !== 'string' || a.text.length > 5000) continue;
|
|
272
331
|
if (a.target === null || typeof a.target !== 'object' || Array.isArray(a.target)) continue;
|
|
273
|
-
const clean = {
|
|
332
|
+
const clean = {
|
|
333
|
+
...a,
|
|
334
|
+
target: sanitizeAnnotationTarget(a.target, boardId),
|
|
335
|
+
author: a.author === 'agent' ? 'agent' : 'user',
|
|
336
|
+
};
|
|
274
337
|
if (a.replies !== undefined) clean.replies = sanitizeReplies(a.replies);
|
|
275
338
|
out.push(clean);
|
|
276
339
|
}
|
|
@@ -628,6 +691,14 @@ function readBody(req, limit = 5 * 1024 * 1024) {
|
|
|
628
691
|
});
|
|
629
692
|
}
|
|
630
693
|
|
|
694
|
+
function artifactBytesMatch(mime, bytes) {
|
|
695
|
+
if (!Buffer.isBuffer(bytes) || bytes.length < 4) return false;
|
|
696
|
+
if (mime === 'image/png') return bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]));
|
|
697
|
+
if (mime === 'image/jpeg') return bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[bytes.length - 2] === 0xff && bytes[bytes.length - 1] === 0xd9;
|
|
698
|
+
if (mime === 'image/webp') return bytes.subarray(0, 4).toString('ascii') === 'RIFF' && bytes.subarray(8, 12).toString('ascii') === 'WEBP';
|
|
699
|
+
return false;
|
|
700
|
+
}
|
|
701
|
+
|
|
631
702
|
// Push-wake: run the agent's own local shell command after a board finishes.
|
|
632
703
|
// The full result JSON is written to the command's stdin; RLY_BOARD_ID /
|
|
633
704
|
// RLY_STATUS / RLY_URL are exported. Failures are swallowed (best effort) —
|
|
@@ -673,7 +744,7 @@ function runOnResult(cmd, result, { quiet = false } = {}) {
|
|
|
673
744
|
// (submitted / acknowledged / timeout / cancelled). The result is also
|
|
674
745
|
// persisted into the board record so `rly wait` / `rly result` can read it
|
|
675
746
|
// from another process.
|
|
676
|
-
export async function runBoard({ id, port = 0, open = true, timeoutSec =
|
|
747
|
+
export async function runBoard({ id, port = 0, open = true, timeoutSec = 86400, quiet = false, keepAliveOnTimeout = false }) {
|
|
677
748
|
const record = loadBoard(id);
|
|
678
749
|
if (!record) throw new Error(`board ${id} not found`);
|
|
679
750
|
const spec = record.spec;
|
|
@@ -933,7 +1004,7 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
|
|
|
933
1004
|
const block = findHtmlBlock(record.spec, blockId);
|
|
934
1005
|
if (!block) return sendJson(res, 404, { error: `no html block "${blockId}"` });
|
|
935
1006
|
const access = accessFor(req, reqUrl);
|
|
936
|
-
sendHtml(res, wrapFragment(block.html || '', theme, access.canComment === true));
|
|
1007
|
+
sendHtml(res, wrapFragment(block.html || '', theme, access.canComment === true && record.spec.responseRequired !== false));
|
|
937
1008
|
} else if (req.method === 'GET' && pathname.startsWith('/img/b/')) {
|
|
938
1009
|
// Embedded image bytes (image blocks authored from local files).
|
|
939
1010
|
const blockId = decodeURIComponent(pathname.slice('/img/b/'.length));
|
|
@@ -942,6 +1013,32 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
|
|
|
942
1013
|
if (!m) return sendJson(res, 404, { error: `no embedded image block "${blockId}"` });
|
|
943
1014
|
res.writeHead(200, { 'content-type': m[1], 'cache-control': 'no-store' });
|
|
944
1015
|
res.end(Buffer.from(m[2], 'base64'));
|
|
1016
|
+
} else if (req.method === 'POST' && pathname === '/api/artifact') {
|
|
1017
|
+
if (record.spec.responseRequired === false) return sendJson(res, 409, { error: 'display-only boards do not collect feedback' });
|
|
1018
|
+
if (!sameOrigin(req, actualPort, [advertisedHost])) return sendJson(res, 403, { error: 'cross-origin artifact upload rejected' });
|
|
1019
|
+
const access = accessFor(req, reqUrl);
|
|
1020
|
+
if (!access.canComment) return sendJson(res, 403, { error: 'this share cannot add comments' });
|
|
1021
|
+
const body = JSON.parse((await readBody(req, 6 * 1024 * 1024)) || '{}');
|
|
1022
|
+
const blockId = typeof body.blockId === 'string' ? body.blockId : '';
|
|
1023
|
+
const block = findBlock(record.spec, blockId, 'image') || findBlock(record.spec, blockId, 'compare');
|
|
1024
|
+
if (!block) return sendJson(res, 404, { error: `no image or comparison block "${blockId}"` });
|
|
1025
|
+
const match = typeof body.dataUrl === 'string'
|
|
1026
|
+
? body.dataUrl.match(/^data:(image\/(?:png|jpeg|webp));base64,([A-Za-z0-9+/=]+)$/)
|
|
1027
|
+
: null;
|
|
1028
|
+
if (!match || !ARTIFACT_MIMES[match[1]]) return sendJson(res, 400, { error: 'artifact must be a PNG, JPEG, or WebP data URL' });
|
|
1029
|
+
const bytes = Buffer.from(match[2], 'base64');
|
|
1030
|
+
if (bytes.length < 1 || bytes.length > ARTIFACT_MAX_BYTES) return sendJson(res, 400, { error: 'artifact image is empty or larger than 4 MB' });
|
|
1031
|
+
if (!artifactBytesMatch(match[1], bytes)) return sendJson(res, 400, { error: 'artifact bytes do not match the declared image type' });
|
|
1032
|
+
const target = saveBoardArtifact(record.id, bytes, ARTIFACT_MIMES[match[1]]);
|
|
1033
|
+
sendJson(res, 200, {
|
|
1034
|
+
ok: true,
|
|
1035
|
+
path: target,
|
|
1036
|
+
name: path.basename(target),
|
|
1037
|
+
mime: match[1],
|
|
1038
|
+
bytes: bytes.length,
|
|
1039
|
+
width: Math.max(1, Math.min(10000, Number.parseInt(body.width, 10) || 1)),
|
|
1040
|
+
height: Math.max(1, Math.min(10000, Number.parseInt(body.height, 10) || 1)),
|
|
1041
|
+
});
|
|
945
1042
|
} else if ((req.method === 'GET' || req.method === 'HEAD') && pathname.startsWith('/video/b/')) {
|
|
946
1043
|
// Local video bytes, Range-streamed so the <video> element can seek.
|
|
947
1044
|
const blockId = decodeURIComponent(pathname.slice('/video/b/'.length));
|
|
@@ -958,14 +1055,14 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
|
|
|
958
1055
|
// Legacy alias → the board's first html block.
|
|
959
1056
|
const block = firstBoardHtml(record.spec);
|
|
960
1057
|
const access = accessFor(req, reqUrl);
|
|
961
|
-
sendHtml(res, wrapFragment((block && block.html) || '', theme, access.canComment === true));
|
|
1058
|
+
sendHtml(res, wrapFragment((block && block.html) || '', theme, access.canComment === true && record.spec.responseRequired !== false));
|
|
962
1059
|
} else if (req.method === 'GET' && pathname.startsWith('/html/q/')) {
|
|
963
1060
|
const qid = decodeURIComponent(pathname.slice('/html/q/'.length));
|
|
964
1061
|
const q = record.spec.questions.find((q) => q.id === qid);
|
|
965
1062
|
if (!q) return sendJson(res, 404, { error: `no question "${qid}"` });
|
|
966
1063
|
const block = firstQuestionHtml(q);
|
|
967
1064
|
const access = accessFor(req, reqUrl);
|
|
968
|
-
sendHtml(res, wrapFragment((block && block.html) || '', theme, access.canComment === true));
|
|
1065
|
+
sendHtml(res, wrapFragment((block && block.html) || '', theme, access.canComment === true && record.spec.responseRequired !== false));
|
|
969
1066
|
} else if (req.method === 'POST' && pathname === '/api/pref') {
|
|
970
1067
|
const body = JSON.parse((await readBody(req)) || '{}');
|
|
971
1068
|
if (['auto', 'light', 'dark'].includes(body.theme)) savePref({ theme: body.theme });
|
|
@@ -998,6 +1095,7 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
|
|
|
998
1095
|
if (!openUrl(target)) return sendJson(res, 500, { error: 'could not open the file' });
|
|
999
1096
|
sendJson(res, 200, { ok: true, path: target, name: path.basename(target) });
|
|
1000
1097
|
} else if (req.method === 'POST' && pathname === '/api/draft') {
|
|
1098
|
+
if (record.spec.responseRequired === false) return sendJson(res, 409, { error: 'display-only boards do not collect feedback' });
|
|
1001
1099
|
const access = accessFor(req, reqUrl);
|
|
1002
1100
|
if (access.role === 'locked') return sendJson(res, 403, { error: 'share link is not active' });
|
|
1003
1101
|
if (!access.canEditAnswers && !access.canComment && !access.canEditBlocks) {
|
|
@@ -1011,7 +1109,7 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
|
|
|
1011
1109
|
answers: body.answers && typeof body.answers === 'object' ? body.answers : {},
|
|
1012
1110
|
comment: typeof body.comment === 'string' ? body.comment : '',
|
|
1013
1111
|
notes: body.notes && typeof body.notes === 'object' ? body.notes : {},
|
|
1014
|
-
annotations: sanitizeAnnotations(body.annotations),
|
|
1112
|
+
annotations: sanitizeAnnotations(body.annotations, record.id),
|
|
1015
1113
|
blockEdits: {},
|
|
1016
1114
|
draftRev: priorRev + 1,
|
|
1017
1115
|
updatedAt: new Date().toISOString(),
|
|
@@ -1022,7 +1120,7 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
|
|
|
1022
1120
|
answers: body.answers && typeof body.answers === 'object' ? body.answers : {},
|
|
1023
1121
|
comment: typeof body.comment === 'string' ? body.comment : '',
|
|
1024
1122
|
notes: body.notes && typeof body.notes === 'object' ? body.notes : {},
|
|
1025
|
-
annotations: sanitizeAnnotations(body.annotations),
|
|
1123
|
+
annotations: sanitizeAnnotations(body.annotations, record.id),
|
|
1026
1124
|
blockEdits: sanitizeBlockEdits(body.blockEdits),
|
|
1027
1125
|
updatedAt: new Date().toISOString(),
|
|
1028
1126
|
};
|
|
@@ -1034,6 +1132,7 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
|
|
|
1034
1132
|
: draftRev;
|
|
1035
1133
|
sendJson(res, 200, { ok: true, draftRev: accessDraftRev });
|
|
1036
1134
|
} else if (req.method === 'POST' && pathname === '/api/submit') {
|
|
1135
|
+
if (record.spec.responseRequired === false) return sendJson(res, 409, { error: 'display-only boards do not accept submissions' });
|
|
1037
1136
|
const access = accessFor(req, reqUrl);
|
|
1038
1137
|
if (access.role === 'locked') return sendJson(res, 403, { error: 'share link is not active' });
|
|
1039
1138
|
if (!access.canSubmit) return sendJson(res, 403, { error: 'this share is read only' });
|
|
@@ -1052,7 +1151,7 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
|
|
|
1052
1151
|
skipped,
|
|
1053
1152
|
comment: typeof body.comment === 'string' ? body.comment : '',
|
|
1054
1153
|
notes: body.notes && typeof body.notes === 'object' ? body.notes : {},
|
|
1055
|
-
annotations: sanitizeAnnotations(body.annotations),
|
|
1154
|
+
annotations: sanitizeAnnotations(body.annotations, record.id),
|
|
1056
1155
|
blockEdits: {},
|
|
1057
1156
|
submittedAt: new Date().toISOString(),
|
|
1058
1157
|
};
|
|
@@ -1076,7 +1175,7 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
|
|
|
1076
1175
|
skipped,
|
|
1077
1176
|
comment: typeof body.comment === 'string' ? body.comment : '',
|
|
1078
1177
|
notes: body.notes && typeof body.notes === 'object' ? body.notes : {},
|
|
1079
|
-
annotations: sanitizeAnnotations(body.annotations),
|
|
1178
|
+
annotations: sanitizeAnnotations(body.annotations, record.id),
|
|
1080
1179
|
blockEdits: sanitizeBlockEdits(body.blockEdits),
|
|
1081
1180
|
});
|
|
1082
1181
|
} else if (req.method === 'GET' && pathname === '/api/share') {
|
package/src/spec.js
CHANGED
|
@@ -853,6 +853,7 @@ export function normalizeSpec(raw, { cwd = process.cwd() } = {}) {
|
|
|
853
853
|
allowPartial: raw.allowPartial !== false,
|
|
854
854
|
note: raw.note !== false,
|
|
855
855
|
autoClose: raw.autoClose !== false,
|
|
856
|
+
responseRequired: raw.responseRequired !== false,
|
|
856
857
|
questions: [],
|
|
857
858
|
submitLabel: '',
|
|
858
859
|
};
|
|
@@ -997,6 +998,9 @@ export function normalizeSpec(raw, { cwd = process.cwd() } = {}) {
|
|
|
997
998
|
if (!spec.questions.length && !spec.blocks.length) {
|
|
998
999
|
throw new CliError('Spec needs "questions" and/or "blocks"/"html" — nothing to show.');
|
|
999
1000
|
}
|
|
1001
|
+
if (!spec.responseRequired && spec.questions.length) {
|
|
1002
|
+
throw new CliError('responseRequired:false is display-only and cannot contain questions. Use responseRequired:true when answers are needed.');
|
|
1003
|
+
}
|
|
1000
1004
|
spec.submitLabel = asStr(raw.submitLabel).trim() || (spec.questions.length ? 'Submit' : 'Acknowledge');
|
|
1001
1005
|
return spec;
|
|
1002
1006
|
}
|
|
@@ -1126,6 +1130,7 @@ export const SPEC_SCHEMA = {
|
|
|
1126
1130
|
allowPartial: { type: 'boolean', default: true, description: 'When true, users may submit with unanswered questions (returned in "skipped").' },
|
|
1127
1131
|
note: { type: 'boolean', default: true, description: 'Show an optional free-text note box ("Anything else?") returned as "comment".' },
|
|
1128
1132
|
autoClose: { type: 'boolean', default: true, description: 'Try to close the browser tab automatically after submit.' },
|
|
1133
|
+
responseRequired: { type: 'boolean', default: true, description: 'Set false for a display-only board: no questions, note box, comments, or Submit/Acknowledge action are shown, and the presenting agent should continue immediately.' },
|
|
1129
1134
|
submitLabel: { type: 'string', description: 'Submit button label. Defaults: "Submit", or "Acknowledge" when there are no questions.' },
|
|
1130
1135
|
questions: {
|
|
1131
1136
|
type: 'array',
|
package/src/store.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import os from 'node:os';
|
|
4
|
+
import crypto from 'node:crypto';
|
|
4
5
|
|
|
5
6
|
// All state lives under one dir so multiple boards/instances never collide.
|
|
6
7
|
// RLY_HOME override exists for tests and sandboxed agents.
|
|
@@ -21,6 +22,19 @@ export function newId() {
|
|
|
21
22
|
|
|
22
23
|
const boardPath = (id) => path.join(BOARDS_DIR, `${id}.json`);
|
|
23
24
|
const runningPath = (id) => path.join(RUNNING_DIR, `${id}.json`);
|
|
25
|
+
export const artifactDirPath = (id) => path.join(BOARDS_DIR, `${id}.artifacts`);
|
|
26
|
+
|
|
27
|
+
export function saveBoardArtifact(id, bytes, ext = 'png') {
|
|
28
|
+
ensureDirs();
|
|
29
|
+
if (!/^b-[a-z0-9]+$/i.test(String(id))) throw new Error('invalid board id');
|
|
30
|
+
const cleanExt = /^(?:png|jpe?g|webp)$/i.test(String(ext)) ? String(ext).toLowerCase() : 'png';
|
|
31
|
+
const dir = artifactDirPath(id);
|
|
32
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
33
|
+
const file = `region-${Date.now().toString(36)}-${crypto.randomBytes(4).toString('hex')}.${cleanExt}`;
|
|
34
|
+
const target = path.join(dir, file);
|
|
35
|
+
fs.writeFileSync(target, bytes);
|
|
36
|
+
return target;
|
|
37
|
+
}
|
|
24
38
|
|
|
25
39
|
export function createBoard(spec) {
|
|
26
40
|
ensureDirs();
|
|
@@ -72,6 +86,7 @@ export function deleteBoard(id) {
|
|
|
72
86
|
try {
|
|
73
87
|
fs.unlinkSync(boardPath(id));
|
|
74
88
|
try { fs.unlinkSync(path.join(BOARDS_DIR, `${id}.result.json`)); } catch { /* no sidecar */ }
|
|
89
|
+
try { fs.rmSync(artifactDirPath(id), { recursive: true, force: true }); } catch { /* no artifacts */ }
|
|
75
90
|
return true;
|
|
76
91
|
} catch {
|
|
77
92
|
return false;
|
package/src/ui/annotate.js
CHANGED
|
@@ -48,6 +48,11 @@
|
|
|
48
48
|
return truncate(t.label || 'Image', 50);
|
|
49
49
|
case 'image-point':
|
|
50
50
|
return (t.label ? truncate(t.label, 30) + ' · ' : 'Image · ') + 'pin ' + Math.round((t.x || 0) * 100) + '%, ' + Math.round((t.y || 0) * 100) + '%';
|
|
51
|
+
case 'image-region': {
|
|
52
|
+
const source = t.side ? t.side.charAt(0).toUpperCase() + t.side.slice(1) + ' · ' : '';
|
|
53
|
+
const area = `${Math.round((t.w || 0) * 100)}×${Math.round((t.h || 0) * 100)}% area`;
|
|
54
|
+
return (t.label ? truncate(t.label, 30) + ' · ' : 'Image · ') + source + area;
|
|
55
|
+
}
|
|
51
56
|
case 'table-cell': {
|
|
52
57
|
let s = `Table · row ${(Number(t.row) || 0) + 1} · ${t.col}`;
|
|
53
58
|
if (t.value !== undefined && t.value !== null && t.value !== '') s += ` — “${truncate(t.value, 30)}”`;
|
|
@@ -109,8 +114,10 @@
|
|
|
109
114
|
let pinTimer = null;
|
|
110
115
|
let pinEntry = null;
|
|
111
116
|
let popOpen = false;
|
|
112
|
-
let popScrollY = 0;
|
|
113
117
|
let popSave = null;
|
|
118
|
+
let popAnchorEl = null;
|
|
119
|
+
let popPositionFrame = 0;
|
|
120
|
+
const commentDrafts = new Map();
|
|
114
121
|
// Frozen by the host (e.g. relay's connection-lost block): suppress every way
|
|
115
122
|
// to START a comment, so the user can't type feedback that won't be saved.
|
|
116
123
|
let disabled = false;
|
|
@@ -341,16 +348,21 @@
|
|
|
341
348
|
});
|
|
342
349
|
|
|
343
350
|
// Capture-phase: also fires for nested scroll containers (mermaid pane…).
|
|
351
|
+
// Keep an open composer re-anchored instead of closing it: scrolling is a
|
|
352
|
+
// normal review action, and DOM-only draft text must never disappear merely
|
|
353
|
+
// because the user looked elsewhere on the board.
|
|
344
354
|
window.addEventListener('scroll', (e) => {
|
|
345
355
|
hidePin();
|
|
346
356
|
hideSelBtn();
|
|
347
357
|
if (!popOpen) return;
|
|
348
358
|
if (dom.pop.contains(e.target)) return; // textarea scrolling inside
|
|
349
|
-
|
|
350
|
-
if (!isPage || Math.abs(window.scrollY - popScrollY) > 80) closePopover();
|
|
359
|
+
schedulePositionPopover();
|
|
351
360
|
}, true);
|
|
352
361
|
|
|
353
|
-
window.addEventListener('resize',
|
|
362
|
+
window.addEventListener('resize', () => {
|
|
363
|
+
scheduleBadgeRefresh();
|
|
364
|
+
schedulePositionPopover();
|
|
365
|
+
});
|
|
354
366
|
}
|
|
355
367
|
|
|
356
368
|
// ---------- comments rail ----------
|
|
@@ -491,10 +503,33 @@
|
|
|
491
503
|
}
|
|
492
504
|
|
|
493
505
|
// ---------- popover ----------
|
|
506
|
+
function positionPopover() {
|
|
507
|
+
popPositionFrame = 0;
|
|
508
|
+
if (!popOpen || !dom || !popAnchorEl || !popAnchorEl.isConnected) return;
|
|
509
|
+
const pop = dom.pop;
|
|
510
|
+
const rect = popAnchorEl.getBoundingClientRect();
|
|
511
|
+
const pw = pop.offsetWidth;
|
|
512
|
+
const ph = pop.offsetHeight;
|
|
513
|
+
const left = Math.max(8, Math.min(rect.left, window.innerWidth - pw - 8));
|
|
514
|
+
let top = rect.bottom + 8;
|
|
515
|
+
if (top + ph > window.innerHeight - 8 && rect.top - ph - 8 >= 8) top = rect.top - ph - 8;
|
|
516
|
+
top = Math.max(8, Math.min(top, window.innerHeight - ph - 8));
|
|
517
|
+
pop.style.left = left + 'px';
|
|
518
|
+
pop.style.top = top + 'px';
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function schedulePositionPopover() {
|
|
522
|
+
if (!popOpen || popPositionFrame) return;
|
|
523
|
+
popPositionFrame = requestAnimationFrame(positionPopover);
|
|
524
|
+
}
|
|
525
|
+
|
|
494
526
|
function closePopover() {
|
|
495
527
|
if (!popOpen) return;
|
|
496
528
|
popOpen = false;
|
|
497
529
|
popSave = null;
|
|
530
|
+
popAnchorEl = null;
|
|
531
|
+
if (popPositionFrame) cancelAnimationFrame(popPositionFrame);
|
|
532
|
+
popPositionFrame = 0;
|
|
498
533
|
dom.pop.style.display = 'none';
|
|
499
534
|
dom.pop.replaceChildren();
|
|
500
535
|
}
|
|
@@ -548,6 +583,7 @@
|
|
|
548
583
|
if (disabled) return;
|
|
549
584
|
ensureDom();
|
|
550
585
|
const hasExisting = matching(info).length > 0;
|
|
586
|
+
const draftKey = sigOf(info.blockId, info.target);
|
|
551
587
|
// Read-only viewers can open existing threads from their badges, but a
|
|
552
588
|
// target with no feedback has nothing to show and must not expose an empty
|
|
553
589
|
// add-comment dialog.
|
|
@@ -632,34 +668,35 @@
|
|
|
632
668
|
pop.append(existingWrap);
|
|
633
669
|
|
|
634
670
|
const ta = el('textarea', { class: 'ann-ta', placeholder: 'Add a comment…', rows: '3' });
|
|
671
|
+
ta.value = commentDrafts.get(draftKey) || '';
|
|
672
|
+
ta.addEventListener('input', () => {
|
|
673
|
+
if (ta.value) commentDrafts.set(draftKey, ta.value);
|
|
674
|
+
else commentDrafts.delete(draftKey);
|
|
675
|
+
});
|
|
635
676
|
const save = el('button', { class: 'ann-save', type: 'button' }, 'Save');
|
|
636
677
|
const cancel = el('button', { class: 'ann-cancel', type: 'button' }, 'Cancel');
|
|
637
678
|
popSave = () => {
|
|
638
679
|
const text = ta.value.trim();
|
|
639
680
|
if (text) addAnnotation(info, text);
|
|
681
|
+
commentDrafts.delete(draftKey);
|
|
640
682
|
closePopover();
|
|
641
683
|
};
|
|
642
684
|
save.addEventListener('click', () => popSave && popSave());
|
|
643
|
-
cancel.addEventListener('click',
|
|
685
|
+
cancel.addEventListener('click', () => {
|
|
686
|
+
commentDrafts.delete(draftKey);
|
|
687
|
+
closePopover();
|
|
688
|
+
});
|
|
644
689
|
if (permissions.add) pop.append(ta, el('div', { class: 'ann-pop-actions' }, save, cancel));
|
|
645
690
|
|
|
646
|
-
// Position: prefer below the anchor, flip above when out of room,
|
|
647
|
-
//
|
|
648
|
-
//
|
|
691
|
+
// Position: prefer below the anchor, flip above when out of room, and clamp
|
|
692
|
+
// to the viewport. Scroll/resize re-run this through one animation frame so
|
|
693
|
+
// the composer tracks browser scrolling smoothly without losing its draft.
|
|
649
694
|
pop.style.display = 'block';
|
|
650
695
|
pop.style.visibility = 'hidden';
|
|
651
|
-
const rect = anchorEl.getBoundingClientRect();
|
|
652
|
-
const pw = pop.offsetWidth;
|
|
653
|
-
const ph = pop.offsetHeight;
|
|
654
|
-
const left = Math.max(8, Math.min(rect.left, window.innerWidth - pw - 8));
|
|
655
|
-
let top = rect.bottom + 8;
|
|
656
|
-
if (top + ph > window.innerHeight - 8 && rect.top - ph - 8 >= 8) top = rect.top - ph - 8;
|
|
657
|
-
top = Math.max(8, Math.min(top, window.innerHeight - ph - 8));
|
|
658
|
-
pop.style.left = left + 'px';
|
|
659
|
-
pop.style.top = top + 'px';
|
|
660
|
-
pop.style.visibility = '';
|
|
661
696
|
popOpen = true;
|
|
662
|
-
|
|
697
|
+
popAnchorEl = anchorEl;
|
|
698
|
+
positionPopover();
|
|
699
|
+
pop.style.visibility = '';
|
|
663
700
|
if (permissions.add) ta.focus();
|
|
664
701
|
else popClose.focus();
|
|
665
702
|
}
|
|
@@ -921,6 +958,7 @@
|
|
|
921
958
|
registered = [];
|
|
922
959
|
textRoots.clear();
|
|
923
960
|
highlightMap.clear();
|
|
961
|
+
commentDrafts.clear();
|
|
924
962
|
if (dom) {
|
|
925
963
|
dom.pin.remove();
|
|
926
964
|
dom.selBtn.remove();
|
package/src/ui/app.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
const boot = JSON.parse(document.getElementById('boot').textContent);
|
|
5
5
|
const spec = boot.spec;
|
|
6
|
+
const responseRequired = spec.responseRequired !== false;
|
|
6
7
|
const access = boot.access || {
|
|
7
8
|
role: 'owner',
|
|
8
9
|
canShare: true,
|
|
@@ -29,7 +30,7 @@
|
|
|
29
30
|
if (access.reviewSessionId) headers['x-relay-review-session'] = access.reviewSessionId;
|
|
30
31
|
return headers;
|
|
31
32
|
}
|
|
32
|
-
const canPersistFeedback = Boolean(access.canEditAnswers || access.canComment || access.canEditBlocks);
|
|
33
|
+
const canPersistFeedback = responseRequired && Boolean(access.canEditAnswers || access.canComment || access.canEditBlocks);
|
|
33
34
|
|
|
34
35
|
// ---------- helpers ----------
|
|
35
36
|
function el(tag, attrs = {}, ...children) {
|
|
@@ -541,7 +542,7 @@
|
|
|
541
542
|
// RelayAnnotate owns the live annotation list; mirror it into state on every
|
|
542
543
|
// change so payload()/autosave/submit carry it exactly like answers.
|
|
543
544
|
const Annotate = typeof window.RelayAnnotate !== 'undefined' ? window.RelayAnnotate : null;
|
|
544
|
-
if (Annotate) {
|
|
545
|
+
if (Annotate && responseRequired) {
|
|
545
546
|
Annotate.init({
|
|
546
547
|
initial: state.annotations,
|
|
547
548
|
permissions: {
|
|
@@ -558,6 +559,8 @@
|
|
|
558
559
|
if (window.__relayBroadcastCounts) window.__relayBroadcastCounts();
|
|
559
560
|
},
|
|
560
561
|
});
|
|
562
|
+
} else if (Annotate && !responseRequired && typeof Annotate.teardown === 'function') {
|
|
563
|
+
Annotate.teardown();
|
|
561
564
|
}
|
|
562
565
|
|
|
563
566
|
// Editable-mermaid: record (or clear) the user's edit for a block, then
|
|
@@ -575,6 +578,17 @@
|
|
|
575
578
|
onBlockEdit(d.blockId, d.value);
|
|
576
579
|
});
|
|
577
580
|
|
|
581
|
+
async function saveRegionArtifact(artifact) {
|
|
582
|
+
const res = await fetch('/api/artifact', {
|
|
583
|
+
method: 'POST',
|
|
584
|
+
headers: authHeaders({ 'content-type': 'application/json' }),
|
|
585
|
+
body: JSON.stringify(artifact),
|
|
586
|
+
});
|
|
587
|
+
const body = await res.json().catch(() => null);
|
|
588
|
+
if (!res.ok || !body || !body.path) throw new Error(body && body.error ? body.error : 'could not save image crop');
|
|
589
|
+
return body;
|
|
590
|
+
}
|
|
591
|
+
|
|
578
592
|
// ctx for RelayBlocks.render — theme()/htmlSrc per the shared contract, plus
|
|
579
593
|
// the editable-mermaid plumbing (edits map + onBlockEdit callback).
|
|
580
594
|
function blockCtx(questionId) {
|
|
@@ -586,8 +600,9 @@
|
|
|
586
600
|
return '/html/b/' + encodeURIComponent(blockId) + '?' + params.toString();
|
|
587
601
|
},
|
|
588
602
|
questionId: questionId == null ? null : questionId,
|
|
589
|
-
annotate: Annotate,
|
|
590
|
-
canComment: access.canComment !== false,
|
|
603
|
+
annotate: responseRequired ? Annotate : null,
|
|
604
|
+
canComment: responseRequired && access.canComment !== false,
|
|
605
|
+
saveArtifact: responseRequired && access.canComment !== false ? saveRegionArtifact : null,
|
|
591
606
|
edits: state.blockEdits,
|
|
592
607
|
onBlockEdit,
|
|
593
608
|
canEditBlocks: access.canEditBlocks !== false,
|
|
@@ -1145,7 +1160,7 @@
|
|
|
1145
1160
|
app.append(card);
|
|
1146
1161
|
});
|
|
1147
1162
|
|
|
1148
|
-
if (spec.note) {
|
|
1163
|
+
if (spec.note && responseRequired) {
|
|
1149
1164
|
const note = el('textarea', { placeholder: 'optional note back to the agent…' });
|
|
1150
1165
|
note.value = state.comment || '';
|
|
1151
1166
|
note.addEventListener('input', () => {
|
|
@@ -1167,6 +1182,10 @@
|
|
|
1167
1182
|
const hint = el('span', { class: 'hint' },
|
|
1168
1183
|
QS.length && spec.allowPartial ? 'Unanswered questions are returned as skipped.' : '');
|
|
1169
1184
|
const submitbar = el('div', { class: 'submitbar' }, submitBtn, hint, saveEl);
|
|
1185
|
+
if (!responseRequired) {
|
|
1186
|
+
submitbar.replaceChildren(el('span', { class: 'hint' }, 'Display only · no response requested'));
|
|
1187
|
+
submitbar.classList.add('display-only');
|
|
1188
|
+
}
|
|
1170
1189
|
app.append(submitbar);
|
|
1171
1190
|
|
|
1172
1191
|
function copyText(text, btn) {
|
|
@@ -1415,7 +1434,7 @@
|
|
|
1415
1434
|
// parent → {relay:'annotate-counts', counts:{ref:n}} it draws badges
|
|
1416
1435
|
// We own the annotation state, popover, and the submitted result; the iframe
|
|
1417
1436
|
// owns hover/pin/badges over its own (cross-origin) DOM.
|
|
1418
|
-
if (Annotate) {
|
|
1437
|
+
if (Annotate && responseRequired) {
|
|
1419
1438
|
const frameOf = (source) => {
|
|
1420
1439
|
for (const f of document.querySelectorAll('iframe.viz')) {
|
|
1421
1440
|
if (f.contentWindow === source) return f;
|
package/src/ui/blocks.css
CHANGED
|
@@ -648,26 +648,35 @@
|
|
|
648
648
|
}
|
|
649
649
|
.blk-imagewrap .blk-img {
|
|
650
650
|
display: block;
|
|
651
|
-
|
|
651
|
+
width: 100%;
|
|
652
|
+
max-width: none;
|
|
652
653
|
height: auto;
|
|
653
654
|
border-radius: 6px;
|
|
654
655
|
object-fit: contain;
|
|
655
656
|
object-position: left top;
|
|
656
657
|
}
|
|
658
|
+
.blk-imgstage {
|
|
659
|
+
position: relative;
|
|
660
|
+
width: 100%;
|
|
661
|
+
max-width: 100%;
|
|
662
|
+
line-height: 0;
|
|
663
|
+
}
|
|
657
664
|
/* In full-screen a per-block height must never keep the image small — that
|
|
658
665
|
height only shapes the inline preview; the detail view needs every pixel.
|
|
659
666
|
(Belt-and-suspenders alongside the viewer's reapply() on full-screen toggle.) */
|
|
660
667
|
.blk-imagewrap.blk-full .blk-img { max-height: none !important; }
|
|
661
668
|
|
|
662
669
|
/* ---------- viewer controls (full-screen) ---------- */
|
|
663
|
-
/* The
|
|
664
|
-
|
|
665
|
-
the
|
|
666
|
-
.blk-viewer { position: relative;
|
|
670
|
+
/* The controls participate in layout above the visual and use native sticky
|
|
671
|
+
positioning. The previous JS counter-translation chased scrollTop one event
|
|
672
|
+
behind the compositor, which made the bar visibly trail trackpad scrolling. */
|
|
673
|
+
.blk-viewer { position: relative; }
|
|
667
674
|
.blk-tools {
|
|
668
|
-
position:
|
|
675
|
+
position: sticky; top: 0; left: 0; width: 100%; height: 34px;
|
|
669
676
|
display: flex; gap: 2px; align-items: center; justify-content: flex-end;
|
|
670
|
-
|
|
677
|
+
box-sizing: border-box; padding: 4px;
|
|
678
|
+
background: var(--bg-sunken); border-bottom: 1px solid var(--border);
|
|
679
|
+
z-index: 16;
|
|
671
680
|
}
|
|
672
681
|
.blk-tools button {
|
|
673
682
|
background: var(--card); border: 1px solid var(--border); color: var(--fg-2);
|
|
@@ -693,6 +702,7 @@ body.blk-full-open { overflow: hidden; }
|
|
|
693
702
|
/* detach the toolbar from the scrolling content and fix it to the viewport top */
|
|
694
703
|
.blk-full > .blk-tools {
|
|
695
704
|
position: fixed; top: 0; left: 0; right: 0; height: 44px;
|
|
705
|
+
width: auto;
|
|
696
706
|
margin: 0; padding: 0 12px;
|
|
697
707
|
background: var(--card); border-bottom: 1px solid var(--border);
|
|
698
708
|
z-index: 51;
|
|
@@ -727,6 +737,7 @@ body.blk-full-open { overflow: hidden; }
|
|
|
727
737
|
.blk-plantuml.blk-full > svg,
|
|
728
738
|
.blk-plantuml.blk-full > .blk-plantuml-img,
|
|
729
739
|
.blk-imagewrap.blk-full > .blk-img { margin: auto; }
|
|
740
|
+
.blk-imagewrap.blk-full > .blk-imgstage { margin: auto; max-width: none; }
|
|
730
741
|
|
|
731
742
|
/* Small frame on charts + mermaid so they read as a deliberate card, matching
|
|
732
743
|
the graphviz/plantuml/image blocks (already framed). Scoped to the inner
|
|
@@ -898,7 +909,7 @@ body.blk-full-open { overflow: hidden; }
|
|
|
898
909
|
20% { box-shadow: 0 0 0 3px var(--accent); }
|
|
899
910
|
}
|
|
900
911
|
|
|
901
|
-
/* ---------- image
|
|
912
|
+
/* ---------- image point + area comments ---------- */
|
|
902
913
|
.blk-img-pinnable { cursor: crosshair; }
|
|
903
914
|
.blk-imgpins { position: absolute; inset: 0; pointer-events: none; }
|
|
904
915
|
.blk-imgpin {
|
|
@@ -912,3 +923,42 @@ body.blk-full-open { overflow: hidden; }
|
|
|
912
923
|
display: grid; place-items: center;
|
|
913
924
|
}
|
|
914
925
|
.blk-imgpin:hover { filter: brightness(1.08); }
|
|
926
|
+
.blk-region-layer { position: absolute; inset: 0; pointer-events: none; }
|
|
927
|
+
.blk-region-selection,
|
|
928
|
+
.blk-imgregion {
|
|
929
|
+
position: absolute;
|
|
930
|
+
border: 2px solid var(--accent);
|
|
931
|
+
background: color-mix(in srgb, var(--accent) 16%, transparent);
|
|
932
|
+
box-shadow: 0 0 0 1px rgba(255,255,255,0.75) inset;
|
|
933
|
+
border-radius: 5px;
|
|
934
|
+
}
|
|
935
|
+
.blk-region-selection { display: none; z-index: 7; }
|
|
936
|
+
.blk-imgregion {
|
|
937
|
+
z-index: 6;
|
|
938
|
+
pointer-events: auto;
|
|
939
|
+
cursor: pointer;
|
|
940
|
+
color: var(--accent-fg);
|
|
941
|
+
padding: 0;
|
|
942
|
+
min-width: 10px;
|
|
943
|
+
min-height: 10px;
|
|
944
|
+
}
|
|
945
|
+
.blk-imgregion::after {
|
|
946
|
+
content: attr(data-count);
|
|
947
|
+
position: absolute; top: -11px; right: -11px;
|
|
948
|
+
min-width: 20px; height: 20px; padding: 0 5px;
|
|
949
|
+
display: grid; place-items: center;
|
|
950
|
+
border: 2px solid #fff; border-radius: 999px;
|
|
951
|
+
background: var(--accent); color: #fff;
|
|
952
|
+
font: 600 0.68rem/1 var(--sans);
|
|
953
|
+
box-shadow: 0 1px 4px rgba(0,0,0,0.3);
|
|
954
|
+
}
|
|
955
|
+
.blk-region-arming { cursor: crosshair !important; }
|
|
956
|
+
.blk-region-selecting { cursor: crosshair !important; touch-action: none; }
|
|
957
|
+
.blk-region-status {
|
|
958
|
+
position: absolute; left: 50%; bottom: 12px; z-index: 9;
|
|
959
|
+
transform: translateX(-50%);
|
|
960
|
+
padding: 6px 10px; border-radius: 8px;
|
|
961
|
+
background: rgba(28,27,25,0.86); color: #fff;
|
|
962
|
+
font: 500 0.74rem/1.2 var(--sans);
|
|
963
|
+
pointer-events: none;
|
|
964
|
+
}
|