@parley-im/parley 0.1.2 → 0.2.1
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 +19 -17
- package/bin/parley +7 -0
- package/dist/access.js +57 -0
- package/dist/account.js +5 -1
- package/dist/agents/claude.js +20 -11
- package/dist/agents/codex.js +10 -5
- package/dist/agents/process.js +35 -4
- package/dist/api.js +32 -10
- package/dist/autostart.js +86 -0
- package/dist/charter.js +6 -5
- package/dist/cli.js +62 -16
- package/dist/config.js +49 -9
- package/dist/control.js +16 -10
- package/dist/daemon.js +34 -10
- package/dist/db.js +111 -27
- package/dist/mcp.js +20 -20
- package/dist/models.js +8 -2
- package/dist/relay.js +35 -10
- package/dist/runner.js +9 -7
- package/dist/seal.js +7 -5
- package/dist/server.js +43 -17
- package/dist/state.js +7 -4
- package/dist/transcript.js +10 -6
- package/dist/version.js +15 -0
- package/package.json +2 -2
- package/web/app.css +29 -13
- package/web/chat.html +6 -6
- package/web/chat.js +198 -62
- package/web/dashboard.js +41 -0
- package/web/ended.html +1 -1
- package/web/missing.html +1 -1
- package/web/panel.html +4 -4
- package/web/panel.js +6 -6
package/web/chat.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
chip: $("chip"), infoBtn: $("info-btn"), sheet: $("sheet"), sheetBack: $("sheet-back"), sheetBody: $("sheet-body"),
|
|
11
11
|
sheetClose: $("sheet-close"), app: document.querySelector(".app"),
|
|
12
12
|
};
|
|
13
|
-
if (!owner) document.body.classList.add("
|
|
13
|
+
if (!owner) document.body.classList.add("guest-view");
|
|
14
14
|
|
|
15
15
|
let session = null;
|
|
16
16
|
let ownerName = null;
|
|
@@ -18,9 +18,11 @@
|
|
|
18
18
|
let more = false;
|
|
19
19
|
let loadingOlder = false;
|
|
20
20
|
const seen = new Set();
|
|
21
|
+
const kept = new Map(); // id -> message, for saving a copy
|
|
21
22
|
let live = null; // { el, text, steps, bodyEl, stepsEl }
|
|
22
23
|
let attachments = []; // { handle, preview, uploading }
|
|
23
24
|
let sending = false;
|
|
25
|
+
let draftId = null; // see submit()
|
|
24
26
|
let ended = false;
|
|
25
27
|
let offline = false;
|
|
26
28
|
|
|
@@ -109,34 +111,42 @@
|
|
|
109
111
|
if (scroller.clientHeight !== viewHeight) viewResized();
|
|
110
112
|
}).observe(scroller);
|
|
111
113
|
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
// the
|
|
114
|
+
// Phones show the keyboard over the page and then scroll the page to keep
|
|
115
|
+
// the box in view, which pushes the header off the top and leaves empty
|
|
116
|
+
// space above the composer (Safari, and apps' in-app browsers). So the app
|
|
117
|
+
// is always pinned to exactly the visible area, its size and its offset,
|
|
118
|
+
// keyboard or not, and any scrolling of the page itself is undone. Desktop
|
|
119
|
+
// browsers see no difference.
|
|
116
120
|
const vv = window.visualViewport;
|
|
117
121
|
if (vv) {
|
|
122
|
+
let queued = false;
|
|
118
123
|
const fit = () => {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
124
|
+
queued = false;
|
|
125
|
+
Object.assign(els.app.style, {
|
|
126
|
+
position: "fixed",
|
|
127
|
+
left: "0",
|
|
128
|
+
right: "0",
|
|
129
|
+
top: "0",
|
|
130
|
+
height: `${vv.height}px`,
|
|
131
|
+
transform: vv.offsetTop ? `translateY(${vv.offsetTop}px)` : "",
|
|
132
|
+
});
|
|
133
|
+
if (window.scrollY) window.scrollTo(0, 0);
|
|
134
|
+
};
|
|
135
|
+
const soon = () => {
|
|
136
|
+
if (!queued) (queued = true), requestAnimationFrame(fit);
|
|
129
137
|
};
|
|
138
|
+
fit();
|
|
130
139
|
vv.addEventListener("resize", fit);
|
|
131
|
-
vv.addEventListener("scroll",
|
|
140
|
+
vv.addEventListener("scroll", soon);
|
|
132
141
|
addEventListener("resize", fit);
|
|
142
|
+
addEventListener("scroll", soon, { passive: true });
|
|
133
143
|
}
|
|
134
144
|
|
|
135
145
|
// ---------- messages ----------
|
|
136
146
|
|
|
137
147
|
function messageEl(m) {
|
|
138
148
|
const time = clock(m.createdAt);
|
|
139
|
-
const body = m.role === "
|
|
149
|
+
const body = m.role === "guest" && m.body === "(screenshot)" && m.images.length ? null : el("div", { className: "body" });
|
|
140
150
|
if (body) body.innerHTML = window.renderMarkdown(m.body);
|
|
141
151
|
const shots = m.images.length
|
|
142
152
|
? el(
|
|
@@ -156,11 +166,12 @@
|
|
|
156
166
|
)
|
|
157
167
|
: null;
|
|
158
168
|
|
|
159
|
-
if (m.role === "
|
|
160
|
-
const who = owner ? session.
|
|
161
|
-
return el("li", { className: "msg
|
|
169
|
+
if (m.role === "guest") {
|
|
170
|
+
const who = owner ? session.guest : "You";
|
|
171
|
+
return el("li", { className: "msg guest" }, el("div", { className: "bubble" }, body, shots), el("div", { className: "stamp", textContent: `${who} · ${time}` }));
|
|
162
172
|
}
|
|
163
|
-
|
|
173
|
+
// Your own view says why a reply failed; guests just get the apology.
|
|
174
|
+
if (m.role === "error") return el("li", { className: "msg error" }, body, m.detail ? el("div", { className: "detail mono", textContent: m.detail }) : null);
|
|
164
175
|
const meta = el("div", { className: "meta" }, el("span", { className: "who", textContent: agentName() }), ` · ${time}`);
|
|
165
176
|
return el("li", { className: "msg agent" }, meta, body, shots, owner && m.turn ? trailEl(m.turn) : null);
|
|
166
177
|
}
|
|
@@ -183,11 +194,12 @@
|
|
|
183
194
|
function addMessage(m, { prepend = false } = {}) {
|
|
184
195
|
if (seen.has(m.id)) return;
|
|
185
196
|
seen.add(m.id);
|
|
197
|
+
kept.set(m.id, m);
|
|
186
198
|
const node = messageEl(m);
|
|
187
199
|
if (prepend) els.messages.prepend(node);
|
|
188
200
|
else if (live?.el.isConnected) els.messages.insertBefore(node, live.el);
|
|
189
201
|
else els.messages.append(node);
|
|
190
|
-
if (m.role !== "
|
|
202
|
+
if (m.role !== "guest" && !prepend) clearLive();
|
|
191
203
|
}
|
|
192
204
|
|
|
193
205
|
function showLive(state) {
|
|
@@ -253,21 +265,65 @@
|
|
|
253
265
|
els.status.hidden = false;
|
|
254
266
|
let dot = "on";
|
|
255
267
|
let label = owner ? `${agentName()} · live` : speaker();
|
|
256
|
-
if (offline) (dot = "
|
|
257
|
-
|
|
268
|
+
if (offline) (dot = "off"), (label = offlineWhy === "connection" ? "reconnecting" : "offline");
|
|
269
|
+
// A reply in progress shows as typing dots in the conversation; the
|
|
270
|
+
// header stays put.
|
|
271
|
+
else if (live && owner) (dot = "busy"), (label = `${agentName()} is replying`);
|
|
258
272
|
else if (!owner && repliesLeft === 0) (dot = "warn"), (label = "paused");
|
|
259
273
|
else if (owner && session.status !== "open") (dot = "off"), (label = "ended");
|
|
260
274
|
els.statusDot.className = `dot ${dot}`;
|
|
261
275
|
els.statusText.textContent = label;
|
|
262
276
|
if (!owner && repliesLeft != null) {
|
|
263
277
|
els.chip.hidden = false;
|
|
264
|
-
els.chip.textContent = `${repliesLeft}
|
|
278
|
+
els.chip.textContent = `${repliesLeft}/${session.replyLimit}`;
|
|
265
279
|
els.chip.title = `${repliesLeft} of ${session.replyLimit} replies left in this chat`;
|
|
266
280
|
els.chip.className = `chip ${repliesLeft <= 10 ? "low" : ""}`;
|
|
267
281
|
}
|
|
268
282
|
}
|
|
269
283
|
|
|
270
|
-
//
|
|
284
|
+
// The conversation as plain text, everything from the start, for the guest
|
|
285
|
+
// to keep.
|
|
286
|
+
async function saveCopy() {
|
|
287
|
+
while (more) {
|
|
288
|
+
const before = seen.size;
|
|
289
|
+
await loadOlder();
|
|
290
|
+
if (seen.size === before) break;
|
|
291
|
+
}
|
|
292
|
+
const projectName = session?.project ?? knownProject ?? "a project";
|
|
293
|
+
const lines = [`parley about ${projectName}, ${new Date().toLocaleDateString()}`, ""];
|
|
294
|
+
for (const m of [...kept.values()].sort((a, b) => a.id - b.id)) {
|
|
295
|
+
if (m.role === "error") continue;
|
|
296
|
+
lines.push(`${m.role === "guest" ? "You" : agentName()} · ${new Date(m.createdAt).toLocaleString()}`, m.body.trim(), "");
|
|
297
|
+
}
|
|
298
|
+
const a = el("a", { href: URL.createObjectURL(new Blob([lines.join("\n")], { type: "text/plain" })), download: `parley-${projectName.replace(/[^\w-]+/g, "-")}.txt` });
|
|
299
|
+
document.body.append(a);
|
|
300
|
+
a.click();
|
|
301
|
+
a.remove();
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// The guest is done: the link stops working for everyone, and the sender
|
|
305
|
+
// keeps what was said. Two taps, so it can't happen by accident.
|
|
306
|
+
async function endConversation(button) {
|
|
307
|
+
if (!button.dataset.armed) {
|
|
308
|
+
button.dataset.armed = "1";
|
|
309
|
+
button.textContent = "Tap again to end it";
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
button.disabled = true;
|
|
313
|
+
button.textContent = "Ending…";
|
|
314
|
+
try {
|
|
315
|
+
await transport.end();
|
|
316
|
+
closeSheet();
|
|
317
|
+
showEnded();
|
|
318
|
+
} catch (error) {
|
|
319
|
+
button.disabled = false;
|
|
320
|
+
delete button.dataset.armed;
|
|
321
|
+
button.textContent = "End conversation";
|
|
322
|
+
banner(`Couldn't end it just now. ${error.message}`);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Details and actions for the guest, behind the button in the header.
|
|
271
327
|
function openSheet() {
|
|
272
328
|
const who = sender?.name || ownerName || "The person who sent this link";
|
|
273
329
|
const whom = ownerName || "them";
|
|
@@ -278,11 +334,22 @@
|
|
|
278
334
|
const facts = [];
|
|
279
335
|
if (sender) facts.push(["Sent by", `${sender.name} (${sender.email})`]);
|
|
280
336
|
if (session) facts.push(["Talking with", session.model ? `${agentName()} (${session.model})` : agentName()]);
|
|
281
|
-
if (session && repliesLeft != null) facts.push(["Replies left", `${repliesLeft}
|
|
337
|
+
if (session && repliesLeft != null) facts.push(["Replies left", `${repliesLeft}/${session.replyLimit}`]);
|
|
338
|
+
const save = el("button", { className: "btn", type: "button", textContent: "Save a copy" });
|
|
339
|
+
save.onclick = () => saveCopy().catch(() => banner("Couldn't save a copy just now."));
|
|
340
|
+
const end = el("button", { className: "btn danger", type: "button", textContent: "End conversation" });
|
|
341
|
+
end.onclick = () => endConversation(end);
|
|
342
|
+
const open = !ended && session?.status !== "ended";
|
|
282
343
|
els.sheetBody.replaceChildren(
|
|
283
344
|
el("p", {}, strong(who), " set this up to hear what you think of ", strong(projectName), sender ? `. parley.im checked it's really them, with Google.` : "."),
|
|
284
345
|
kv(facts),
|
|
285
|
-
el("
|
|
346
|
+
el("div", { className: "sheet-actions" }, save, open ? end : null),
|
|
347
|
+
open ? el("p", { className: "fine" }, `Ending it stops this link working, for you and anyone else who has it. ${ownerName || "They"} keep${ownerName ? "s" : ""} everything said so far.`) : null,
|
|
348
|
+
el(
|
|
349
|
+
"details",
|
|
350
|
+
{ className: "how" },
|
|
351
|
+
el("summary", { textContent: "How this works" }),
|
|
352
|
+
el("p", {}, `${agentName()} runs on ${theirs} computer. It can read ${projectName}'s code to answer your questions, but it can't change anything. Everything you write here, screenshots included, is saved for ${whom}.`),
|
|
286
353
|
relay
|
|
287
354
|
? el(
|
|
288
355
|
"p",
|
|
@@ -290,7 +357,8 @@
|
|
|
290
357
|
`Messages and screenshots are end-to-end encrypted between this page and ${theirs} computer. parley.im passes them along but can't read them; it knows who sent this link, the project's name and when messages go by. The key is the part of your link after the #, so anyone with the whole link can read this chat: keep it to yourself. (This page's code comes from parley.im, so that promise relies on parley.im serving it honestly. And ${agentName()} reads the conversation to reply, as with any ${agentName()} chat.)`,
|
|
291
358
|
)
|
|
292
359
|
: null,
|
|
293
|
-
|
|
360
|
+
el("p", {}, `When the replies run out, the chat pauses until ${whom} adds more. Replies come from ${theirs} computer, so it needs to be on, and the link stops working for good once the chat ends.`),
|
|
361
|
+
),
|
|
294
362
|
);
|
|
295
363
|
els.sheet.hidden = els.sheetBack.hidden = false;
|
|
296
364
|
els.sheetClose.focus();
|
|
@@ -301,16 +369,16 @@
|
|
|
301
369
|
els.sheetBack.addEventListener("click", closeSheet);
|
|
302
370
|
addEventListener("keydown", (e) => e.key === "Escape" && closeSheet());
|
|
303
371
|
|
|
304
|
-
// The
|
|
372
|
+
// The guest's intro. On parley.im links it can show before the
|
|
305
373
|
// conversation loads, from what parley.im vouches for.
|
|
306
|
-
function
|
|
374
|
+
function paintGuest() {
|
|
307
375
|
const name = ownerName || sender?.name?.split(" ")[0];
|
|
308
376
|
const projectName = session?.project ?? knownProject ?? "";
|
|
309
377
|
els.project.textContent = projectName;
|
|
310
378
|
els.infoBtn.hidden = false;
|
|
311
379
|
els.intro.hidden = false;
|
|
312
380
|
const strong = (t) => el("strong", { textContent: t });
|
|
313
|
-
const more = el("button", { type: "button", textContent: "
|
|
381
|
+
const more = el("button", { type: "button", textContent: "Details" });
|
|
314
382
|
more.onclick = openSheet;
|
|
315
383
|
els.intro.replaceChildren(
|
|
316
384
|
name ? strong(name) : "Someone",
|
|
@@ -389,9 +457,9 @@
|
|
|
389
457
|
el("span", { className: "slash", textContent: "/" }),
|
|
390
458
|
el("a", { className: "product", href: "/", textContent: "Control panel" }),
|
|
391
459
|
el("span", { className: "slash", textContent: "/" }),
|
|
392
|
-
el("span", { className: "here", textContent: `${s.
|
|
460
|
+
el("span", { className: "here", textContent: `${s.guest} on ${s.project}` }),
|
|
393
461
|
);
|
|
394
|
-
document.title = `${s.
|
|
462
|
+
document.title = `${s.guest} on ${s.project} · parley`;
|
|
395
463
|
const used = s.replyLimit - s.repliesLeft;
|
|
396
464
|
const pct = Math.min(100, Math.round((used / s.replyLimit) * 100));
|
|
397
465
|
const kv = (pairs) => el("dl", { className: "kv" }, ...pairs.flatMap(([k, v]) => [el("dt", { textContent: k }), el("dd", { textContent: v })]));
|
|
@@ -420,14 +488,14 @@
|
|
|
420
488
|
|
|
421
489
|
els.side.textContent = "";
|
|
422
490
|
els.side.append(
|
|
423
|
-
el("h2", { textContent: s.
|
|
491
|
+
el("h2", { textContent: s.guest }),
|
|
424
492
|
el("div", { className: "sub", textContent: `${s.project} · ${s.agent === "codex" ? "Codex" : "Claude"} · ${open ? (s.busy ? "replying now" : "open") : "ended"}` }),
|
|
425
493
|
el(
|
|
426
494
|
"section",
|
|
427
495
|
{},
|
|
428
496
|
el("div", { className: "label", textContent: "Replies" }),
|
|
429
497
|
el("div", { className: `meter ${s.repliesLeft === 0 ? "full" : ""}` }, el("span", { style: `width:${pct}%` })),
|
|
430
|
-
el("div", { className: "cell-muted", textContent: s.repliesLeft === 0 ? `All ${s.replyLimit} used. The
|
|
498
|
+
el("div", { className: "cell-muted", textContent: s.repliesLeft === 0 ? `All ${s.replyLimit} used. The guest is paused until you add more.` : `${used} of ${s.replyLimit} used, ${s.repliesLeft} left` }),
|
|
431
499
|
open
|
|
432
500
|
? el(
|
|
433
501
|
"div",
|
|
@@ -442,15 +510,15 @@
|
|
|
442
510
|
"section",
|
|
443
511
|
{},
|
|
444
512
|
kv([
|
|
445
|
-
["Messages from
|
|
513
|
+
["Messages from guest", String(s.stats.guest_messages)],
|
|
446
514
|
["Model", s.model ?? "known after the first reply"],
|
|
447
515
|
["Spend", s.stats.cost_usd ? `$${s.stats.cost_usd.toFixed(2)} at API prices` : s.agent === "codex" ? "not reported by Codex" : "$0.00"],
|
|
448
|
-
["Last heard from", ago(s.stats.
|
|
516
|
+
["Last heard from", ago(s.stats.last_guest_at)],
|
|
449
517
|
["Started", ago(s.createdAt)],
|
|
450
518
|
["Session", s.id],
|
|
451
519
|
]),
|
|
452
520
|
),
|
|
453
|
-
open ? el("section", {}, el("div", { className: "label", textContent: "
|
|
521
|
+
open ? el("section", {}, el("div", { className: "label", textContent: "Guest link" }), el("div", { className: "linkbox", style: "margin-top:8px" }, linkInput, copy)) : null,
|
|
454
522
|
el("section", {}, el("div", { className: "label", textContent: "Brief" }), el("div", { className: "brief-text", style: "margin-top:6px", textContent: s.brief })),
|
|
455
523
|
el("section", {}, el("div", { className: "actions", style: "margin-top:0" }, kill), el("div", { className: "cell-muted", style: "margin-top:8px", textContent: open ? "Ending kills the link for good. You keep the transcript." : "Deleting erases the transcript and screenshots." })),
|
|
456
524
|
);
|
|
@@ -491,6 +559,7 @@
|
|
|
491
559
|
|
|
492
560
|
const problemError = (status, code, message) => Object.assign(new Error(message), { status, code });
|
|
493
561
|
const offlineError = () => Object.assign(new Error(`${ownerLabel()[0].toUpperCase() + ownerLabel().slice(1)}'s computer is offline right now.`), { offline: true });
|
|
562
|
+
const connectionError = () => Object.assign(new Error("The connection dropped. Reconnecting…"), { offline: true, connection: true });
|
|
494
563
|
|
|
495
564
|
function httpTransport() {
|
|
496
565
|
let h;
|
|
@@ -545,8 +614,12 @@
|
|
|
545
614
|
if (!res.ok) throw await problemFrom(res);
|
|
546
615
|
return res.json();
|
|
547
616
|
},
|
|
548
|
-
async
|
|
549
|
-
const res = await fetch(`${base}/api/
|
|
617
|
+
async end() {
|
|
618
|
+
const res = await fetch(`${base}/api/end`, { method: "POST" });
|
|
619
|
+
if (!res.ok) throw await problemFrom(res);
|
|
620
|
+
},
|
|
621
|
+
async send(text, images, cid) {
|
|
622
|
+
const res = await fetch(`${base}/api/messages`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ text, images, cid }) });
|
|
550
623
|
if (!res.ok) throw await problemFrom(res);
|
|
551
624
|
return res.json();
|
|
552
625
|
},
|
|
@@ -580,13 +653,16 @@
|
|
|
580
653
|
bad_request: "This link's key doesn't match the conversation. Ask for the link again.",
|
|
581
654
|
};
|
|
582
655
|
|
|
583
|
-
async function request(op, params = {}, timeoutMs =
|
|
656
|
+
async function request(op, params = {}, timeoutMs = 15_000) {
|
|
584
657
|
if (ws?.readyState !== WebSocket.OPEN) throw offlineError();
|
|
585
658
|
const r = crypto.randomUUID();
|
|
586
|
-
const d = await S.seal(key, token, "req", { op, r, at: Date.now(), ...params });
|
|
659
|
+
const d = await S.seal(key, token, "req", { op, r, at: Date.now() + clock, ...params });
|
|
587
660
|
return new Promise((resolve, reject) => {
|
|
661
|
+
// No answer means the computer is gone, whatever parley.im last said:
|
|
662
|
+
// show it offline until it's heard from again.
|
|
588
663
|
const timer = setTimeout(() => {
|
|
589
664
|
pending.delete(r);
|
|
665
|
+
h.offline(true, "computer");
|
|
590
666
|
reject(problemError(504, "timeout", `No answer from ${ownerLabel()}'s computer. Try again in a moment.`));
|
|
591
667
|
}, timeoutMs);
|
|
592
668
|
pending.set(r, { resolve, reject, timer });
|
|
@@ -606,7 +682,7 @@
|
|
|
606
682
|
for (const item of early) if (item.epoch === epoch && item.seq > cursor) (cursor = item.seq), h.event(item.event);
|
|
607
683
|
} catch (error) {
|
|
608
684
|
if (error.code === "ended") h.ended();
|
|
609
|
-
else if (error.offline) h.offline(true);
|
|
685
|
+
else if (error.offline) h.offline(true, error.connection ? "connection" : "computer");
|
|
610
686
|
else if (error.code === "bad_request") h.badKey();
|
|
611
687
|
} finally {
|
|
612
688
|
early = [];
|
|
@@ -623,9 +699,13 @@
|
|
|
623
699
|
if (!p) return;
|
|
624
700
|
pending.delete(m.r);
|
|
625
701
|
clearTimeout(p.timer);
|
|
626
|
-
if (m.offline)
|
|
702
|
+
if (m.offline) {
|
|
703
|
+
h.offline(true, "computer");
|
|
704
|
+
return p.reject(offlineError());
|
|
705
|
+
}
|
|
706
|
+
h.offline(false, "computer");
|
|
627
707
|
if (m.error) return p.reject(problemError(m.error === "ended" ? 410 : 400, m.error, RELAY_ERRORS[m.error] ?? "That didn't work."));
|
|
628
|
-
const body = await S.open(key, token,
|
|
708
|
+
const body = await S.open(key, token, `res:${m.r}`, m.d);
|
|
629
709
|
return body.ok ? p.resolve(body.result) : p.reject(problemError(body.status, body.error, body.message));
|
|
630
710
|
}
|
|
631
711
|
if (m.t === "evt") {
|
|
@@ -636,6 +716,7 @@
|
|
|
636
716
|
return;
|
|
637
717
|
}
|
|
638
718
|
if (m.t === "host") {
|
|
719
|
+
if (typeof m.now === "number") clock = m.now - Date.now();
|
|
639
720
|
h.offline(!m.on);
|
|
640
721
|
if (m.on) load();
|
|
641
722
|
return;
|
|
@@ -643,21 +724,43 @@
|
|
|
643
724
|
if (m.t === "ended") h.ended();
|
|
644
725
|
}
|
|
645
726
|
|
|
727
|
+
// Frames are handled one at a time, in order: decrypting is asynchronous,
|
|
728
|
+
// and a later update finishing first would make an earlier one look old.
|
|
729
|
+
let frames = Promise.resolve();
|
|
730
|
+
// parley.im's clock minus this phone's, so a request's age is judged the
|
|
731
|
+
// same way at both ends whatever time the phone thinks it is.
|
|
732
|
+
let clock = 0;
|
|
733
|
+
let retry = null;
|
|
734
|
+
|
|
646
735
|
function connect() {
|
|
736
|
+
clearTimeout(retry);
|
|
737
|
+
retry = null;
|
|
647
738
|
ws = new WebSocket(`${location.origin.replace(/^http/, "ws")}${base}/ws`);
|
|
648
|
-
ws.onopen = () =>
|
|
649
|
-
|
|
739
|
+
ws.onopen = () => {
|
|
740
|
+
failures = 0;
|
|
741
|
+
h.offline(false, "connection");
|
|
742
|
+
};
|
|
743
|
+
ws.onmessage = (event) => (frames = frames.then(() => onFrame(event.data)).catch((error) => console.error(error)));
|
|
650
744
|
ws.onclose = (event) => {
|
|
651
|
-
for (const p of pending.values()) clearTimeout(p.timer), p.reject(
|
|
745
|
+
for (const p of pending.values()) clearTimeout(p.timer), p.reject(connectionError());
|
|
652
746
|
pending.clear();
|
|
653
747
|
if (event.code === 4410) return h.ended();
|
|
654
748
|
if (ended) return;
|
|
655
749
|
failures++;
|
|
656
|
-
|
|
657
|
-
|
|
750
|
+
// This page lost its own connection (a phone switching networks, or
|
|
751
|
+
// waking up); the sender's computer may be fine.
|
|
752
|
+
if (failures > 1) h.offline(true, "connection");
|
|
753
|
+
retry = setTimeout(connect, Math.min(1000 * failures, 10_000));
|
|
658
754
|
};
|
|
659
755
|
}
|
|
660
756
|
|
|
757
|
+
// Back from the background or back online: reconnect now, not on a timer.
|
|
758
|
+
const wake = () => {
|
|
759
|
+
if (!ended && document.visibilityState === "visible" && retry) connect();
|
|
760
|
+
};
|
|
761
|
+
document.addEventListener("visibilitychange", wake);
|
|
762
|
+
window.addEventListener("online", wake);
|
|
763
|
+
|
|
661
764
|
return {
|
|
662
765
|
async start(handlers) {
|
|
663
766
|
h = handlers;
|
|
@@ -669,7 +772,8 @@
|
|
|
669
772
|
connect();
|
|
670
773
|
},
|
|
671
774
|
history: (before) => request("history", { before }),
|
|
672
|
-
send: (text, images) => request("send", { text, images }),
|
|
775
|
+
send: (text, images, cid) => request("send", { text, images, cid }),
|
|
776
|
+
end: () => request("end"),
|
|
673
777
|
async upload(file) {
|
|
674
778
|
const sealed = await S.sealBytes(key, token, "blob", new Uint8Array(await file.arrayBuffer()));
|
|
675
779
|
const res = await fetch(`${base}/blobs`, { method: "PUT", body: sealed });
|
|
@@ -686,7 +790,7 @@
|
|
|
686
790
|
// while it's offline; forget the failure and try again later.
|
|
687
791
|
const res = await fetch(`${base}/blobs/${ref.id}`);
|
|
688
792
|
if (!res.ok) throw new Error(`screenshot ${res.status}`);
|
|
689
|
-
const plain = await S.openBytes(key, token,
|
|
793
|
+
const plain = await S.openBytes(key, token, `shot:${ref.id}`, new Uint8Array(await res.arrayBuffer()));
|
|
690
794
|
return URL.createObjectURL(new Blob([plain], { type: ref.type }));
|
|
691
795
|
})().catch((error) => {
|
|
692
796
|
images.delete(ref.id);
|
|
@@ -713,16 +817,24 @@
|
|
|
713
817
|
for (const m of state.messages) addMessage(m);
|
|
714
818
|
if (state.live) showLive(state.live);
|
|
715
819
|
if (owner) paintSide();
|
|
716
|
-
else
|
|
820
|
+
else paintGuest();
|
|
717
821
|
banner("");
|
|
718
822
|
toEnd();
|
|
719
823
|
}
|
|
720
824
|
|
|
721
|
-
|
|
722
|
-
|
|
825
|
+
// Offline means one of two things: the sender's computer (asleep, or
|
|
826
|
+
// parley isn't running), or this page's own connection.
|
|
827
|
+
let offlineWhy = null;
|
|
828
|
+
function setOffline(on, why = "computer") {
|
|
829
|
+
if (!on && offlineWhy && why !== offlineWhy) return;
|
|
830
|
+
if (on === offline && (!on || why === offlineWhy)) return;
|
|
723
831
|
offline = on;
|
|
832
|
+
offlineWhy = on ? why : null;
|
|
833
|
+
els.app.classList.toggle("offline", on);
|
|
724
834
|
paintStatus();
|
|
725
|
-
|
|
835
|
+
updateSend();
|
|
836
|
+
if (on && why === "connection") banner("The connection dropped. Reconnecting…");
|
|
837
|
+
else if (on) {
|
|
726
838
|
const whose = ownerName ? `${ownerName}'s` : sender ? `${sender.name.split(" ")[0]}'s` : "The sender's";
|
|
727
839
|
banner(`${whose} computer is offline right now, so nothing can be sent or loaded. This page will reconnect on its own.`);
|
|
728
840
|
} else banner("");
|
|
@@ -757,7 +869,7 @@
|
|
|
757
869
|
function updateSend() {
|
|
758
870
|
const length = els.input.value.trim().length;
|
|
759
871
|
const ready = !attachments.some((a) => a.uploading);
|
|
760
|
-
els.send.disabled = sending || !ready || length > maxText || (!length && !attachments.length);
|
|
872
|
+
els.send.disabled = offline || sending || !ready || length > maxText || (!length && !attachments.length);
|
|
761
873
|
}
|
|
762
874
|
|
|
763
875
|
// Browsers with field-sizing size the box themselves (no jumping while you
|
|
@@ -783,9 +895,28 @@
|
|
|
783
895
|
updateSend();
|
|
784
896
|
}
|
|
785
897
|
|
|
898
|
+
// Phone photos are often several MB, more than the agents take, so big
|
|
899
|
+
// ones are scaled down (2000 px on the long side) before they're sent.
|
|
900
|
+
async function shrink(file) {
|
|
901
|
+
if (file.type === "image/gif" || file.size <= 1_500_000) return file;
|
|
902
|
+
try {
|
|
903
|
+
const bitmap = await createImageBitmap(file);
|
|
904
|
+
const scale = Math.min(1, 2000 / Math.max(bitmap.width, bitmap.height));
|
|
905
|
+
const canvas = document.createElement("canvas");
|
|
906
|
+
canvas.width = Math.round(bitmap.width * scale);
|
|
907
|
+
canvas.height = Math.round(bitmap.height * scale);
|
|
908
|
+
canvas.getContext("2d").drawImage(bitmap, 0, 0, canvas.width, canvas.height);
|
|
909
|
+
const blob = await new Promise((resolve) => canvas.toBlob(resolve, "image/jpeg", 0.85));
|
|
910
|
+
return blob && blob.size < file.size ? new File([blob], "screenshot.jpg", { type: "image/jpeg" }) : file;
|
|
911
|
+
} catch {
|
|
912
|
+
return file;
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
|
|
786
916
|
async function addFiles(files) {
|
|
787
|
-
for (const
|
|
788
|
-
|
|
917
|
+
for (const original of files) {
|
|
918
|
+
const file = await shrink(original);
|
|
919
|
+
if (!/^image\/(png|jpeg|gif|webp)$/.test(original.type)) {
|
|
789
920
|
banner("Only images can be added: PNG, JPEG, GIF or WebP.");
|
|
790
921
|
continue;
|
|
791
922
|
}
|
|
@@ -810,10 +941,14 @@
|
|
|
810
941
|
if (els.send.disabled) return;
|
|
811
942
|
const text = els.input.value.trim();
|
|
812
943
|
const images = attachments.filter((a) => a.handle).map((a) => a.handle);
|
|
944
|
+
// One id per message you mean to send, kept until it goes through, so
|
|
945
|
+
// sending again after a lost answer can't post it twice.
|
|
946
|
+
draftId ??= crypto.randomUUID();
|
|
813
947
|
sending = true;
|
|
814
948
|
updateSend();
|
|
815
949
|
try {
|
|
816
|
-
const { message } = await transport.send(text, images);
|
|
950
|
+
const { message } = await transport.send(text, images, draftId);
|
|
951
|
+
draftId = null;
|
|
817
952
|
addMessage(message);
|
|
818
953
|
toEnd(); // you just spoke: back to the bottom, and follow from here
|
|
819
954
|
els.input.value = "";
|
|
@@ -842,6 +977,7 @@
|
|
|
842
977
|
}
|
|
843
978
|
|
|
844
979
|
els.input.addEventListener("input", () => {
|
|
980
|
+
draftId = null; // edited: a new message, not a resend
|
|
845
981
|
autosize();
|
|
846
982
|
paintComposer();
|
|
847
983
|
});
|
|
@@ -882,7 +1018,7 @@
|
|
|
882
1018
|
}
|
|
883
1019
|
|
|
884
1020
|
let started = false;
|
|
885
|
-
if (relay)
|
|
1021
|
+
if (relay) paintGuest();
|
|
886
1022
|
transport.start({
|
|
887
1023
|
state(state) {
|
|
888
1024
|
showState(state);
|
package/web/dashboard.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Keeps the parley.im dashboard current: who's on each session's page right
|
|
2
|
+
// now, when they were last active, and whether a reply is coming in. It
|
|
3
|
+
// never shows what was said; parley.im doesn't have that.
|
|
4
|
+
(() => {
|
|
5
|
+
const ago = (ms) => {
|
|
6
|
+
const s = Math.max(0, Math.round((Date.now() - ms) / 1000));
|
|
7
|
+
if (s < 60) return "just now";
|
|
8
|
+
if (s < 3600) return `${Math.round(s / 60)}m ago`;
|
|
9
|
+
if (s < 86400) return `${Math.round(s / 3600)}h ago`;
|
|
10
|
+
return `${Math.round(s / 86400)}d ago`;
|
|
11
|
+
};
|
|
12
|
+
const dot = (kind, text) => {
|
|
13
|
+
const span = document.createElement("span");
|
|
14
|
+
span.className = `dot${kind ? ` ${kind}` : ""}`;
|
|
15
|
+
return [span, document.createTextNode(text)];
|
|
16
|
+
};
|
|
17
|
+
// Matches live() in relay/src/pages.ts.
|
|
18
|
+
const liveNodes = (s) => {
|
|
19
|
+
if (s.online === false) return dot("off", "computer offline");
|
|
20
|
+
if (s.replying) return dot("busy", "replying");
|
|
21
|
+
if (s.here) return dot("on", s.here > 1 ? `${s.here} people here` : "here now");
|
|
22
|
+
if (s.active) return dot("", `active ${ago(s.active)}`);
|
|
23
|
+
return dot("", "not opened yet");
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
async function refresh() {
|
|
27
|
+
if (document.hidden) return;
|
|
28
|
+
try {
|
|
29
|
+
const res = await fetch("/dashboard.json", { cache: "no-store" });
|
|
30
|
+
if (!res.ok) return;
|
|
31
|
+
const { sessions } = await res.json();
|
|
32
|
+
const rows = [...document.querySelectorAll("[data-session]")];
|
|
33
|
+
// A session opened or ended elsewhere: show the page fresh.
|
|
34
|
+
if (rows.map((r) => r.dataset.session).sort().join() !== sessions.map((s) => s.id).sort().join()) return location.reload();
|
|
35
|
+
for (const s of sessions) rows.find((r) => r.dataset.session === s.id)?.querySelector(".live")?.replaceChildren(...liveNodes(s));
|
|
36
|
+
} catch {}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
setInterval(refresh, 4000);
|
|
40
|
+
document.addEventListener("visibilitychange", refresh);
|
|
41
|
+
})();
|
package/web/ended.html
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
6
|
<meta name="robots" content="noindex, nofollow">
|
|
7
7
|
<title>This parley has ended</title>
|
|
8
|
-
<link rel="icon" href="/static/icon.svg">
|
|
8
|
+
<link rel="icon" href="/static/icon.svg?v=2" type="image/svg+xml">
|
|
9
9
|
<link rel="stylesheet" href="/static/app.css">
|
|
10
10
|
</head>
|
|
11
11
|
<body>
|
package/web/missing.html
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
6
|
<meta name="robots" content="noindex, nofollow">
|
|
7
7
|
<title>Link not found</title>
|
|
8
|
-
<link rel="icon" href="/static/icon.svg">
|
|
8
|
+
<link rel="icon" href="/static/icon.svg?v=2" type="image/svg+xml">
|
|
9
9
|
<link rel="stylesheet" href="/static/app.css">
|
|
10
10
|
</head>
|
|
11
11
|
<body>
|
package/web/panel.html
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
<meta charset="utf-8">
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
6
|
<title>Control panel · parley</title>
|
|
7
|
-
<link rel="icon" href="/static/icon.svg">
|
|
7
|
+
<link rel="icon" href="/static/icon.svg?v=2" type="image/svg+xml">
|
|
8
8
|
<link rel="stylesheet" href="/static/app.css">
|
|
9
9
|
</head>
|
|
10
10
|
<body class="panel">
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
|
|
21
21
|
<div class="toolbar">
|
|
22
22
|
<div class="tabs" id="tabs" role="tablist"></div>
|
|
23
|
-
<input class="search" id="search" type="search" placeholder="Filter by
|
|
23
|
+
<input class="search" id="search" type="search" placeholder="Filter by guest or project" aria-label="Filter sessions">
|
|
24
24
|
<button class="btn primary" id="new" type="button">
|
|
25
25
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>New session
|
|
26
26
|
</button>
|
|
@@ -41,8 +41,8 @@
|
|
|
41
41
|
<span class="help">The interviewer runs here, read-only.</span>
|
|
42
42
|
</div>
|
|
43
43
|
<div class="field">
|
|
44
|
-
<label for="f-
|
|
45
|
-
<input id="f-
|
|
44
|
+
<label for="f-guest">Who it's for</label>
|
|
45
|
+
<input id="f-guest" name="guest" placeholder="Sam" required autocomplete="off">
|
|
46
46
|
</div>
|
|
47
47
|
<div class="field">
|
|
48
48
|
<label for="f-brief">Brief</label>
|