@parley-im/parley 0.2.0 → 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/dist/api.js +11 -0
- package/dist/charter.js +2 -1
- package/dist/relay.js +10 -1
- package/dist/server.js +13 -1
- package/package.json +1 -1
- package/web/app.css +8 -0
- package/web/chat.html +5 -5
- package/web/chat.js +79 -9
package/dist/api.js
CHANGED
|
@@ -95,6 +95,17 @@ export class Conversations {
|
|
|
95
95
|
const message = this.runner.submit(session, text || "(screenshot)", images.filter((name) => ownsUpload(session, name)), clientId);
|
|
96
96
|
return { message: this.messageView(message, scope) };
|
|
97
97
|
}
|
|
98
|
+
// The guest is done. A note goes in the transcript for you, the session
|
|
99
|
+
// ends, and its link stops working. The caller tells parley.im.
|
|
100
|
+
endByGuest(scope) {
|
|
101
|
+
const session = this.store.session(scope.session.id);
|
|
102
|
+
if (session.status !== "open")
|
|
103
|
+
return session;
|
|
104
|
+
const note = this.store.addMessage(session.id, "guest", "(ended the conversation)", [], null);
|
|
105
|
+
this.runner.publish(session.id, { type: "message", message: note });
|
|
106
|
+
this.runner.end(session.id);
|
|
107
|
+
return session;
|
|
108
|
+
}
|
|
98
109
|
// The same message sent again (its answer got lost on the way back): the
|
|
99
110
|
// first one, rather than a second copy.
|
|
100
111
|
resent(scope, clientId) {
|
package/dist/charter.js
CHANGED
|
@@ -13,7 +13,8 @@ You are running a feedback session for ${session.project_name}, the project in y
|
|
|
13
13
|
How to run it:
|
|
14
14
|
- Hear them out. Let them brain dump. Ask one short follow-up at a time to get specifics: what they were trying to do, what they expected, what actually happened, where it happened, and how much it mattered to them.
|
|
15
15
|
- Answer their questions about the project honestly. Read the code in this folder to check how something works or whether a behavior is intended, then explain it the way someone using the product would understand it: what it does and why, not where it lives. Most guests don't know or care about the code, so don't mention files, line numbers, functions or other internals unless they ask about the code. If they do ask, sharing details is fine.
|
|
16
|
-
- Capture their perspective faithfully.
|
|
16
|
+
- Capture their perspective faithfully. Don't try to win them over or talk them out of a reaction. If something is a known limitation, say so plainly.
|
|
17
|
+
- It's a conversation, not a survey: they can interview you too. When they challenge the project or ask what ${owner} thinks (the strategy, the competition, why it's built this way), give ${owner}'s actual position where the project's own documents state it, and say where it comes from. If nothing in the project covers it, say so and that you'll pass the question on; don't guess at ${owner}'s views. Then ask what they make of that answer. Their reaction is what ${owner} wants to hear.
|
|
17
18
|
- Do not promise fixes, features, or dates. Say you will pass it along to ${owner}.
|
|
18
19
|
- You are read-only. You can read files but cannot edit them or run anything. If they ask you to fix or change something, explain that this session is for collecting feedback and that you will make sure ${owner} sees it.
|
|
19
20
|
- This project's folder is all you can see. Stay inside it: don't look for or talk about anything else on ${owner}'s computer, even if asked. Its .parley folder holds other people's conversations, so never open it or repeat anything from it.
|
package/dist/relay.js
CHANGED
|
@@ -80,7 +80,8 @@ export class RelayLink {
|
|
|
80
80
|
ws.onopen = async () => {
|
|
81
81
|
this.delay = 1000;
|
|
82
82
|
this.status = { state: "connected", relayUrl: creds.relayUrl, email: creds.account.email, since: new Date().toISOString() };
|
|
83
|
-
|
|
83
|
+
// parley.im counts this computer as gone after about a minute without one.
|
|
84
|
+
this.ping = setInterval(() => ws.readyState === WebSocket.OPEN && ws.send('{"t":"ping"}'), 20_000);
|
|
84
85
|
// Tell parley.im which sessions are open, so their links work.
|
|
85
86
|
try {
|
|
86
87
|
await registry.sync(creds, this.store.sessions({ status: "open" }));
|
|
@@ -191,6 +192,14 @@ export class RelayLink {
|
|
|
191
192
|
names.push(this.saveImage(session, image));
|
|
192
193
|
return await reply({ ok: true, result: this.api.post(scope, text, names, clientId) });
|
|
193
194
|
}
|
|
195
|
+
if (req.op === "end") {
|
|
196
|
+
this.api.endByGuest(scope);
|
|
197
|
+
await reply({ ok: true, result: { ended: true } });
|
|
198
|
+
const creds = readCredentials(this.config);
|
|
199
|
+
if (creds)
|
|
200
|
+
await registry.end(creds, session).catch((error) => console.error(`parley.im didn't hear ${session.id} ended: ${error.message}`));
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
194
203
|
return await reply({ ok: false, error: "unknown", message: "Unknown request." });
|
|
195
204
|
}
|
|
196
205
|
catch (error) {
|
package/dist/server.js
CHANGED
|
@@ -178,6 +178,16 @@ export async function serve() {
|
|
|
178
178
|
api.check(scope, textBody, images.length);
|
|
179
179
|
return json(res, api.post(scope, textBody, images, clientId));
|
|
180
180
|
}
|
|
181
|
+
// The guest ends it. Local links only; on parley.im it's a sealed request.
|
|
182
|
+
if (rest === "/api/end" && req.method === "POST") {
|
|
183
|
+
if (scope.owner)
|
|
184
|
+
throw new Problem(403, "read_only", "Your view is read-only.");
|
|
185
|
+
api.endByGuest(scope);
|
|
186
|
+
const creds = readCredentials(config);
|
|
187
|
+
if (creds)
|
|
188
|
+
await registry.end(creds, session).catch(() => { });
|
|
189
|
+
return json(res, { ok: true });
|
|
190
|
+
}
|
|
181
191
|
if (rest === "/api/uploads" && req.method === "POST") {
|
|
182
192
|
if (scope.owner)
|
|
183
193
|
throw new Problem(403, "read_only", "Your view is read-only.");
|
|
@@ -247,11 +257,13 @@ export async function serve() {
|
|
|
247
257
|
console.log(`parley: control panel http://localhost:${config.dashboardPort}, local links on ${config.port}`);
|
|
248
258
|
link.start();
|
|
249
259
|
runner.resume();
|
|
260
|
+
// Says goodbye to parley.im before exiting, so guests see this computer go
|
|
261
|
+
// offline at once rather than when parley.im notices the silence.
|
|
250
262
|
const shutdown = () => {
|
|
251
263
|
link.stop();
|
|
252
264
|
runner.stopAll();
|
|
253
265
|
clearState(process.pid);
|
|
254
|
-
process.exit(0);
|
|
266
|
+
setTimeout(() => process.exit(0), 300);
|
|
255
267
|
};
|
|
256
268
|
process.on("SIGINT", shutdown);
|
|
257
269
|
process.on("SIGTERM", shutdown);
|
package/package.json
CHANGED
package/web/app.css
CHANGED
|
@@ -121,6 +121,10 @@ button, input, textarea, select { font: inherit; color: inherit; }
|
|
|
121
121
|
.sheet-body p { margin: 0 0 10px; color: var(--muted); }
|
|
122
122
|
.sheet-body p strong { color: var(--text); font-weight: 600; }
|
|
123
123
|
.sheet-body .kv { margin: 6px 0 14px; padding: 10px 12px; background: var(--subtle); border: 1px solid var(--line); border-radius: 10px; }
|
|
124
|
+
.sheet-actions { display: flex; gap: 8px; flex-wrap: wrap; margin: 0 0 8px; }
|
|
125
|
+
.sheet-body .fine { font-size: 12px; color: var(--faint); }
|
|
126
|
+
.sheet-body details.how { margin-top: 14px; border-top: 1px solid var(--line); padding-top: 10px; }
|
|
127
|
+
.sheet-body details.how summary { cursor: pointer; color: var(--text); font-weight: 500; margin-bottom: 8px; }
|
|
124
128
|
@media (max-width: 560px) {
|
|
125
129
|
.sheet { left: 0; right: 0; top: auto; bottom: 0; transform: none; width: auto; border-radius: 16px 16px 0 0; padding-bottom: env(safe-area-inset-bottom); }
|
|
126
130
|
}
|
|
@@ -221,6 +225,10 @@ body:has(> .app) { overflow: hidden; overscroll-behavior: none; }
|
|
|
221
225
|
.dock .chat-inner { padding: 8px 16px calc(8px + env(safe-area-inset-bottom)); }
|
|
222
226
|
.composer { border: 1px solid var(--line-strong); border-radius: 12px; background: var(--panel); box-shadow: var(--shadow); }
|
|
223
227
|
.composer:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
|
|
228
|
+
/* The sender's computer (or this page's connection) is offline: nothing can
|
|
229
|
+
be sent until it's back, and it looks that way. */
|
|
230
|
+
.app.offline .composer, .app.offline .composer:focus-within { border-color: var(--danger); box-shadow: 0 0 0 3px color-mix(in srgb, var(--danger) 18%, transparent); }
|
|
231
|
+
.app.offline .prompt { color: var(--danger); }
|
|
224
232
|
.composer .row { display: flex; align-items: flex-end; gap: 2px; padding: 4px 4px 4px 12px; }
|
|
225
233
|
.prompt { font-family: var(--mono); color: var(--accent); font-weight: 600; padding: 9px 4px 9px 0; line-height: 1.55; }
|
|
226
234
|
textarea {
|
package/web/chat.html
CHANGED
|
@@ -23,9 +23,9 @@
|
|
|
23
23
|
</nav>
|
|
24
24
|
<span class="status" id="status" hidden><span class="dot" id="status-dot"></span><span id="status-text"></span></span>
|
|
25
25
|
<span class="chip" id="chip" hidden></span>
|
|
26
|
-
<button class="icon-btn info-btn" id="info-btn" type="button" title="
|
|
27
|
-
<svg viewBox="0 0 24 24" aria-hidden="true"><
|
|
28
|
-
<span class="sr">
|
|
26
|
+
<button class="icon-btn info-btn" id="info-btn" type="button" title="This conversation" hidden>
|
|
27
|
+
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 7h14"/><path d="M5 12h14"/><path d="M5 17h9"/></svg>
|
|
28
|
+
<span class="sr">This conversation: details and actions</span>
|
|
29
29
|
</button>
|
|
30
30
|
</header>
|
|
31
31
|
|
|
@@ -68,8 +68,8 @@
|
|
|
68
68
|
</div>
|
|
69
69
|
</div>
|
|
70
70
|
<div class="sheet-back" id="sheet-back" hidden></div>
|
|
71
|
-
<section class="sheet" id="sheet" hidden aria-label="
|
|
72
|
-
<header><h2>
|
|
71
|
+
<section class="sheet" id="sheet" hidden aria-label="This conversation">
|
|
72
|
+
<header><h2>This conversation</h2><button class="btn ghost" id="sheet-close" type="button">Done</button></header>
|
|
73
73
|
<div class="sheet-body" id="sheet-body"></div>
|
|
74
74
|
</section>
|
|
75
75
|
<div class="drop" id="drop" hidden><div>Drop screenshots to add them</div></div>
|
package/web/chat.js
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
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;
|
|
@@ -193,6 +194,7 @@
|
|
|
193
194
|
function addMessage(m, { prepend = false } = {}) {
|
|
194
195
|
if (seen.has(m.id)) return;
|
|
195
196
|
seen.add(m.id);
|
|
197
|
+
kept.set(m.id, m);
|
|
196
198
|
const node = messageEl(m);
|
|
197
199
|
if (prepend) els.messages.prepend(node);
|
|
198
200
|
else if (live?.el.isConnected) els.messages.insertBefore(node, live.el);
|
|
@@ -263,7 +265,7 @@
|
|
|
263
265
|
els.status.hidden = false;
|
|
264
266
|
let dot = "on";
|
|
265
267
|
let label = owner ? `${agentName()} · live` : speaker();
|
|
266
|
-
if (offline) (dot = "
|
|
268
|
+
if (offline) (dot = "off"), (label = offlineWhy === "connection" ? "reconnecting" : "offline");
|
|
267
269
|
// A reply in progress shows as typing dots in the conversation; the
|
|
268
270
|
// header stays put.
|
|
269
271
|
else if (live && owner) (dot = "busy"), (label = `${agentName()} is replying`);
|
|
@@ -279,7 +281,49 @@
|
|
|
279
281
|
}
|
|
280
282
|
}
|
|
281
283
|
|
|
282
|
-
//
|
|
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.
|
|
283
327
|
function openSheet() {
|
|
284
328
|
const who = sender?.name || ownerName || "The person who sent this link";
|
|
285
329
|
const whom = ownerName || "them";
|
|
@@ -290,11 +334,22 @@
|
|
|
290
334
|
const facts = [];
|
|
291
335
|
if (sender) facts.push(["Sent by", `${sender.name} (${sender.email})`]);
|
|
292
336
|
if (session) facts.push(["Talking with", session.model ? `${agentName()} (${session.model})` : agentName()]);
|
|
293
|
-
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";
|
|
294
343
|
els.sheetBody.replaceChildren(
|
|
295
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.` : "."),
|
|
296
345
|
kv(facts),
|
|
297
|
-
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}.`),
|
|
298
353
|
relay
|
|
299
354
|
? el(
|
|
300
355
|
"p",
|
|
@@ -302,7 +357,8 @@
|
|
|
302
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.)`,
|
|
303
358
|
)
|
|
304
359
|
: null,
|
|
305
|
-
|
|
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
|
+
),
|
|
306
362
|
);
|
|
307
363
|
els.sheet.hidden = els.sheetBack.hidden = false;
|
|
308
364
|
els.sheetClose.focus();
|
|
@@ -322,7 +378,7 @@
|
|
|
322
378
|
els.infoBtn.hidden = false;
|
|
323
379
|
els.intro.hidden = false;
|
|
324
380
|
const strong = (t) => el("strong", { textContent: t });
|
|
325
|
-
const more = el("button", { type: "button", textContent: "
|
|
381
|
+
const more = el("button", { type: "button", textContent: "Details" });
|
|
326
382
|
more.onclick = openSheet;
|
|
327
383
|
els.intro.replaceChildren(
|
|
328
384
|
name ? strong(name) : "Someone",
|
|
@@ -558,6 +614,10 @@
|
|
|
558
614
|
if (!res.ok) throw await problemFrom(res);
|
|
559
615
|
return res.json();
|
|
560
616
|
},
|
|
617
|
+
async end() {
|
|
618
|
+
const res = await fetch(`${base}/api/end`, { method: "POST" });
|
|
619
|
+
if (!res.ok) throw await problemFrom(res);
|
|
620
|
+
},
|
|
561
621
|
async send(text, images, cid) {
|
|
562
622
|
const res = await fetch(`${base}/api/messages`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ text, images, cid }) });
|
|
563
623
|
if (!res.ok) throw await problemFrom(res);
|
|
@@ -593,13 +653,16 @@
|
|
|
593
653
|
bad_request: "This link's key doesn't match the conversation. Ask for the link again.",
|
|
594
654
|
};
|
|
595
655
|
|
|
596
|
-
async function request(op, params = {}, timeoutMs =
|
|
656
|
+
async function request(op, params = {}, timeoutMs = 15_000) {
|
|
597
657
|
if (ws?.readyState !== WebSocket.OPEN) throw offlineError();
|
|
598
658
|
const r = crypto.randomUUID();
|
|
599
659
|
const d = await S.seal(key, token, "req", { op, r, at: Date.now() + clock, ...params });
|
|
600
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.
|
|
601
663
|
const timer = setTimeout(() => {
|
|
602
664
|
pending.delete(r);
|
|
665
|
+
h.offline(true, "computer");
|
|
603
666
|
reject(problemError(504, "timeout", `No answer from ${ownerLabel()}'s computer. Try again in a moment.`));
|
|
604
667
|
}, timeoutMs);
|
|
605
668
|
pending.set(r, { resolve, reject, timer });
|
|
@@ -636,7 +699,11 @@
|
|
|
636
699
|
if (!p) return;
|
|
637
700
|
pending.delete(m.r);
|
|
638
701
|
clearTimeout(p.timer);
|
|
639
|
-
if (m.offline)
|
|
702
|
+
if (m.offline) {
|
|
703
|
+
h.offline(true, "computer");
|
|
704
|
+
return p.reject(offlineError());
|
|
705
|
+
}
|
|
706
|
+
h.offline(false, "computer");
|
|
640
707
|
if (m.error) return p.reject(problemError(m.error === "ended" ? 410 : 400, m.error, RELAY_ERRORS[m.error] ?? "That didn't work."));
|
|
641
708
|
const body = await S.open(key, token, `res:${m.r}`, m.d);
|
|
642
709
|
return body.ok ? p.resolve(body.result) : p.reject(problemError(body.status, body.error, body.message));
|
|
@@ -706,6 +773,7 @@
|
|
|
706
773
|
},
|
|
707
774
|
history: (before) => request("history", { before }),
|
|
708
775
|
send: (text, images, cid) => request("send", { text, images, cid }),
|
|
776
|
+
end: () => request("end"),
|
|
709
777
|
async upload(file) {
|
|
710
778
|
const sealed = await S.sealBytes(key, token, "blob", new Uint8Array(await file.arrayBuffer()));
|
|
711
779
|
const res = await fetch(`${base}/blobs`, { method: "PUT", body: sealed });
|
|
@@ -762,7 +830,9 @@
|
|
|
762
830
|
if (on === offline && (!on || why === offlineWhy)) return;
|
|
763
831
|
offline = on;
|
|
764
832
|
offlineWhy = on ? why : null;
|
|
833
|
+
els.app.classList.toggle("offline", on);
|
|
765
834
|
paintStatus();
|
|
835
|
+
updateSend();
|
|
766
836
|
if (on && why === "connection") banner("The connection dropped. Reconnecting…");
|
|
767
837
|
else if (on) {
|
|
768
838
|
const whose = ownerName ? `${ownerName}'s` : sender ? `${sender.name.split(" ")[0]}'s` : "The sender's";
|
|
@@ -799,7 +869,7 @@
|
|
|
799
869
|
function updateSend() {
|
|
800
870
|
const length = els.input.value.trim().length;
|
|
801
871
|
const ready = !attachments.some((a) => a.uploading);
|
|
802
|
-
els.send.disabled = sending || !ready || length > maxText || (!length && !attachments.length);
|
|
872
|
+
els.send.disabled = offline || sending || !ready || length > maxText || (!length && !attachments.length);
|
|
803
873
|
}
|
|
804
874
|
|
|
805
875
|
// Browsers with field-sizing size the box themselves (no jumping while you
|