@privateclaw/privateclaw-relay 0.1.3 → 0.1.5
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 +16 -1
- package/dist/config.d.ts +1 -0
- package/dist/config.js.map +1 -1
- package/dist/provider-setup.d.ts +13 -2
- package/dist/provider-setup.js +127 -19
- package/dist/provider-setup.js.map +1 -1
- package/dist/relay-cli.d.ts +1 -0
- package/dist/relay-cli.js +26 -2
- package/dist/relay-cli.js.map +1 -1
- package/dist/relay-server.js +37 -18
- package/dist/relay-server.js.map +1 -1
- package/dist/relay-web.d.ts +9 -0
- package/dist/relay-web.js +143 -0
- package/dist/relay-web.js.map +1 -0
- package/dist/web/assets/icon-github.svg +3 -0
- package/dist/web/assets/icon-google-group.svg +6 -0
- package/dist/web/assets/icon-telegram.svg +3 -0
- package/dist/web/assets/privateclaw_app_icon.png +0 -0
- package/dist/web/assets/privateclaw_feature_graphic.png +0 -0
- package/dist/web/chat/index.html +196 -0
- package/dist/web/index.html +174 -0
- package/dist/web/privacy/index.html +83 -0
- package/dist/web/scripts/chat.js +1320 -0
- package/dist/web/scripts/i18n.js +997 -0
- package/dist/web/scripts/policy.js +354 -0
- package/dist/web/scripts/protocol-web.js +332 -0
- package/dist/web/scripts/session-client.js +469 -0
- package/dist/web/scripts/site.js +158 -0
- package/dist/web/scripts/vendor/LICENSE-jsQR.txt +201 -0
- package/dist/web/scripts/vendor/jsQR.js +10101 -0
- package/dist/web/styles.css +1390 -0
- package/dist/web/terms/index.html +83 -0
- package/package.json +2 -2
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
import { createCryptoContext, createMessageId } from "./protocol-web.js?v=20260316-1";
|
|
2
|
+
|
|
3
|
+
const CONNECT_TIMEOUT_MS = 15000;
|
|
4
|
+
const INITIAL_RECONNECT_DELAY_MS = 1000;
|
|
5
|
+
const MAX_RECONNECT_DELAY_MS = 30000;
|
|
6
|
+
|
|
7
|
+
function normalizeTimestamp(value) {
|
|
8
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
9
|
+
return new Date();
|
|
10
|
+
}
|
|
11
|
+
const parsed = new Date(value);
|
|
12
|
+
if (Number.isNaN(parsed.valueOf())) {
|
|
13
|
+
return new Date();
|
|
14
|
+
}
|
|
15
|
+
return parsed;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function parseAttachments(value) {
|
|
19
|
+
if (!Array.isArray(value)) {
|
|
20
|
+
return [];
|
|
21
|
+
}
|
|
22
|
+
return value
|
|
23
|
+
.filter((item) => item && typeof item === "object")
|
|
24
|
+
.map((item) => ({
|
|
25
|
+
id: typeof item.id === "string" ? item.id : createMessageId("attachment"),
|
|
26
|
+
name: typeof item.name === "string" ? item.name : "attachment",
|
|
27
|
+
mimeType: typeof item.mimeType === "string" ? item.mimeType : "application/octet-stream",
|
|
28
|
+
sizeBytes: typeof item.sizeBytes === "number" ? item.sizeBytes : 0,
|
|
29
|
+
dataBase64: typeof item.dataBase64 === "string" ? item.dataBase64 : null,
|
|
30
|
+
uri: typeof item.uri === "string" ? item.uri : null,
|
|
31
|
+
}));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function parseCommands(value) {
|
|
35
|
+
if (!Array.isArray(value)) {
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
38
|
+
return value
|
|
39
|
+
.filter((item) => item && typeof item === "object")
|
|
40
|
+
.map((item) => ({
|
|
41
|
+
slash: typeof item.slash === "string" ? item.slash : "/unknown",
|
|
42
|
+
description: typeof item.description === "string" ? item.description : "",
|
|
43
|
+
acceptsArgs: Boolean(item.acceptsArgs),
|
|
44
|
+
source: typeof item.source === "string" ? item.source : "privateclaw",
|
|
45
|
+
}));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function parseParticipants(value) {
|
|
49
|
+
if (!Array.isArray(value)) {
|
|
50
|
+
return [];
|
|
51
|
+
}
|
|
52
|
+
return value
|
|
53
|
+
.filter((item) => item && typeof item === "object")
|
|
54
|
+
.map((item) => ({
|
|
55
|
+
appId: typeof item.appId === "string" ? item.appId : createMessageId("participant"),
|
|
56
|
+
displayName: typeof item.displayName === "string" ? item.displayName : "Participant",
|
|
57
|
+
deviceLabel: typeof item.deviceLabel === "string" ? item.deviceLabel : null,
|
|
58
|
+
joinedAt: normalizeTimestamp(item.joinedAt),
|
|
59
|
+
}));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export class PrivateClawWebSessionClient extends EventTarget {
|
|
63
|
+
constructor(invite, { identity }) {
|
|
64
|
+
super();
|
|
65
|
+
this.invite = { ...invite };
|
|
66
|
+
this.identity = { ...identity };
|
|
67
|
+
this.socket = null;
|
|
68
|
+
this.cryptoContext = null;
|
|
69
|
+
this.connectTimeout = null;
|
|
70
|
+
this.reconnectTimer = null;
|
|
71
|
+
this.disposed = false;
|
|
72
|
+
this.sawTerminalClose = false;
|
|
73
|
+
this.messageCounter = 0;
|
|
74
|
+
this.connectionGeneration = 0;
|
|
75
|
+
this.reconnectDelayMs = INITIAL_RECONNECT_DELAY_MS;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async connect() {
|
|
79
|
+
if (this.disposed) {
|
|
80
|
+
throw new Error("session_client_disposed");
|
|
81
|
+
}
|
|
82
|
+
this.cryptoContext = await createCryptoContext({
|
|
83
|
+
sessionId: this.invite.sessionId,
|
|
84
|
+
sessionKey: this.invite.sessionKey,
|
|
85
|
+
});
|
|
86
|
+
this.#openSocket("connecting");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async disconnect({ reason = "client_closed", notifyRemote = true } = {}) {
|
|
90
|
+
if (this.disposed) {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
this.disposed = true;
|
|
94
|
+
clearTimeout(this.connectTimeout);
|
|
95
|
+
clearTimeout(this.reconnectTimer);
|
|
96
|
+
this.connectTimeout = null;
|
|
97
|
+
this.reconnectTimer = null;
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
if (notifyRemote && this.socket?.readyState === WebSocket.OPEN && this.cryptoContext) {
|
|
101
|
+
await this.#sendEncrypted({
|
|
102
|
+
kind: "session_close",
|
|
103
|
+
reason,
|
|
104
|
+
appId: this.identity.appId,
|
|
105
|
+
sentAt: new Date().toISOString(),
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
} finally {
|
|
109
|
+
if (this.socket) {
|
|
110
|
+
this.socket.close(1000, reason);
|
|
111
|
+
}
|
|
112
|
+
this.socket = null;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async sendUserMessage(text, { attachments = [] } = {}) {
|
|
117
|
+
const trimmed = typeof text === "string" ? text.trim() : "";
|
|
118
|
+
if (!trimmed && attachments.length === 0) {
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const sentAt = new Date();
|
|
123
|
+
const clientMessageId = this.#nextLocalMessageId();
|
|
124
|
+
await this.#sendEncrypted({
|
|
125
|
+
kind: "user_message",
|
|
126
|
+
text: trimmed,
|
|
127
|
+
clientMessageId,
|
|
128
|
+
sentAt: sentAt.toISOString(),
|
|
129
|
+
appId: this.identity.appId,
|
|
130
|
+
...(this.identity.displayName ? { displayName: this.identity.displayName } : {}),
|
|
131
|
+
...(attachments.length > 0 ? { attachments } : {}),
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
this.#dispatch("message", {
|
|
135
|
+
message: {
|
|
136
|
+
id: clientMessageId,
|
|
137
|
+
sender: "user",
|
|
138
|
+
text: trimmed,
|
|
139
|
+
sentAt,
|
|
140
|
+
attachments,
|
|
141
|
+
isPending: true,
|
|
142
|
+
isOwnMessage: true,
|
|
143
|
+
senderId: this.identity.appId,
|
|
144
|
+
senderLabel: this.identity.displayName,
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
#openSocket(status) {
|
|
150
|
+
this.connectionGeneration += 1;
|
|
151
|
+
const generation = this.connectionGeneration;
|
|
152
|
+
this.sawTerminalClose = false;
|
|
153
|
+
|
|
154
|
+
clearTimeout(this.connectTimeout);
|
|
155
|
+
if (this.socket) {
|
|
156
|
+
this.socket.close(1000, "reconnect");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
this.#dispatch("state", {
|
|
160
|
+
status,
|
|
161
|
+
notice: "connectingRelay",
|
|
162
|
+
invite: this.invite,
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const socket = new WebSocket(this.#buildSocketUrl());
|
|
166
|
+
this.socket = socket;
|
|
167
|
+
|
|
168
|
+
this.connectTimeout = window.setTimeout(() => {
|
|
169
|
+
if (socket.readyState === WebSocket.CONNECTING) {
|
|
170
|
+
socket.close(4000, "connect_timeout");
|
|
171
|
+
this.#handleSocketError(new Error("connect_timeout"), generation);
|
|
172
|
+
}
|
|
173
|
+
}, CONNECT_TIMEOUT_MS);
|
|
174
|
+
|
|
175
|
+
socket.addEventListener("open", () => {
|
|
176
|
+
clearTimeout(this.connectTimeout);
|
|
177
|
+
this.connectTimeout = null;
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
socket.addEventListener("message", async (event) => {
|
|
181
|
+
try {
|
|
182
|
+
await this.#handleRawMessage(event.data, generation);
|
|
183
|
+
} catch (error) {
|
|
184
|
+
this.#dispatch("state", {
|
|
185
|
+
status: "error",
|
|
186
|
+
notice: "unknownPayload",
|
|
187
|
+
details: error instanceof Error ? error.message : String(error),
|
|
188
|
+
invite: this.invite,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
socket.addEventListener("error", () => {
|
|
194
|
+
this.#handleSocketError(new Error("websocket_error"), generation);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
socket.addEventListener("close", () => {
|
|
198
|
+
clearTimeout(this.connectTimeout);
|
|
199
|
+
this.connectTimeout = null;
|
|
200
|
+
if (this.disposed || this.sawTerminalClose || generation !== this.connectionGeneration) {
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
this.socket = null;
|
|
204
|
+
this.#scheduleReconnect();
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
#handleSocketError(error, generation) {
|
|
209
|
+
if (this.disposed || this.sawTerminalClose || generation !== this.connectionGeneration) {
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
this.#dispatch("state", {
|
|
213
|
+
status: "error",
|
|
214
|
+
notice: "connectionError",
|
|
215
|
+
details: error instanceof Error ? error.message : String(error),
|
|
216
|
+
invite: this.invite,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
#scheduleReconnect() {
|
|
221
|
+
if (this.disposed || this.sawTerminalClose || this.reconnectTimer) {
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
const delay = this.reconnectDelayMs;
|
|
225
|
+
this.#dispatch("state", {
|
|
226
|
+
status: "reconnecting",
|
|
227
|
+
notice: "connectingRelay",
|
|
228
|
+
invite: this.invite,
|
|
229
|
+
});
|
|
230
|
+
this.reconnectTimer = window.setTimeout(() => {
|
|
231
|
+
this.reconnectTimer = null;
|
|
232
|
+
this.#openSocket("reconnecting");
|
|
233
|
+
}, delay);
|
|
234
|
+
this.reconnectDelayMs = Math.min(MAX_RECONNECT_DELAY_MS, this.reconnectDelayMs * 2);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async #handleRawMessage(rawMessage, generation) {
|
|
238
|
+
if (this.disposed || generation !== this.connectionGeneration || typeof rawMessage !== "string") {
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const decoded = JSON.parse(rawMessage);
|
|
243
|
+
if (!decoded || typeof decoded !== "object") {
|
|
244
|
+
throw new Error("relay_event_not_object");
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
switch (decoded.type) {
|
|
248
|
+
case "relay:attached": {
|
|
249
|
+
this.reconnectDelayMs = INITIAL_RECONNECT_DELAY_MS;
|
|
250
|
+
if (typeof decoded.expiresAt === "string" && decoded.expiresAt) {
|
|
251
|
+
this.invite = {
|
|
252
|
+
...this.invite,
|
|
253
|
+
expiresAt: decoded.expiresAt,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
this.#dispatch("state", {
|
|
257
|
+
status: "relayAttached",
|
|
258
|
+
notice: "relayAttached",
|
|
259
|
+
invite: this.invite,
|
|
260
|
+
});
|
|
261
|
+
await this.#sendEncrypted({
|
|
262
|
+
kind: "client_hello",
|
|
263
|
+
appVersion: "privateclaw_web/0.1.0",
|
|
264
|
+
appId: this.identity.appId,
|
|
265
|
+
deviceLabel: "PrivateClaw Web",
|
|
266
|
+
...(this.identity.displayName ? { displayName: this.identity.displayName } : {}),
|
|
267
|
+
sentAt: new Date().toISOString(),
|
|
268
|
+
});
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
case "relay:frame": {
|
|
272
|
+
if (!decoded.envelope || typeof decoded.envelope !== "object") {
|
|
273
|
+
throw new Error("missing_encrypted_envelope");
|
|
274
|
+
}
|
|
275
|
+
if (!this.cryptoContext) {
|
|
276
|
+
throw new Error("missing_crypto_context");
|
|
277
|
+
}
|
|
278
|
+
const payload = await this.cryptoContext.decrypt(decoded.envelope);
|
|
279
|
+
await this.#handlePayload(payload);
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
case "relay:error": {
|
|
283
|
+
this.#dispatch("state", {
|
|
284
|
+
status: "error",
|
|
285
|
+
notice: "relayError",
|
|
286
|
+
details: typeof decoded.message === "string" ? decoded.message : "unknown_error",
|
|
287
|
+
invite: this.invite,
|
|
288
|
+
});
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
case "relay:session_closed": {
|
|
292
|
+
this.sawTerminalClose = true;
|
|
293
|
+
this.#dispatch("state", {
|
|
294
|
+
status: "closed",
|
|
295
|
+
notice: "sessionClosed",
|
|
296
|
+
details: typeof decoded.reason === "string" ? decoded.reason : "unknown_reason",
|
|
297
|
+
invite: this.invite,
|
|
298
|
+
});
|
|
299
|
+
await this.disconnect({ notifyRemote: false, reason: "session_closed" });
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
default:
|
|
303
|
+
this.#dispatch("state", {
|
|
304
|
+
status: "error",
|
|
305
|
+
notice: "unknownRelayEvent",
|
|
306
|
+
details: typeof decoded.type === "string" ? decoded.type : "unknown_event",
|
|
307
|
+
invite: this.invite,
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async #handlePayload(payload) {
|
|
313
|
+
switch (payload.kind) {
|
|
314
|
+
case "server_welcome": {
|
|
315
|
+
this.#dispatch("state", {
|
|
316
|
+
status: "active",
|
|
317
|
+
notice: "welcome",
|
|
318
|
+
details: typeof payload.message === "string" ? payload.message : null,
|
|
319
|
+
invite: this.invite,
|
|
320
|
+
});
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
case "assistant_message": {
|
|
324
|
+
this.#dispatch("message", {
|
|
325
|
+
message: {
|
|
326
|
+
id: typeof payload.messageId === "string" ? payload.messageId : this.#nextLocalMessageId(),
|
|
327
|
+
sender: "assistant",
|
|
328
|
+
text: typeof payload.text === "string" ? payload.text : "",
|
|
329
|
+
sentAt: normalizeTimestamp(payload.sentAt),
|
|
330
|
+
replyTo: typeof payload.replyTo === "string" ? payload.replyTo : null,
|
|
331
|
+
isPending: payload.pending === true,
|
|
332
|
+
attachments: parseAttachments(payload.attachments),
|
|
333
|
+
},
|
|
334
|
+
});
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
case "participant_message": {
|
|
338
|
+
const senderAppId = typeof payload.senderAppId === "string" ? payload.senderAppId : "unknown-app";
|
|
339
|
+
this.#dispatch("message", {
|
|
340
|
+
message: {
|
|
341
|
+
id: typeof payload.messageId === "string" ? payload.messageId : this.#nextLocalMessageId(),
|
|
342
|
+
sender: "user",
|
|
343
|
+
text: typeof payload.text === "string" ? payload.text : "",
|
|
344
|
+
sentAt: normalizeTimestamp(payload.sentAt),
|
|
345
|
+
replyTo: typeof payload.clientMessageId === "string" ? payload.clientMessageId : null,
|
|
346
|
+
attachments: parseAttachments(payload.attachments),
|
|
347
|
+
isOwnMessage: senderAppId === this.identity.appId,
|
|
348
|
+
senderId: senderAppId,
|
|
349
|
+
senderLabel:
|
|
350
|
+
typeof payload.senderDisplayName === "string" ? payload.senderDisplayName : senderAppId,
|
|
351
|
+
},
|
|
352
|
+
});
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
case "system_message": {
|
|
356
|
+
this.#dispatch("message", {
|
|
357
|
+
message: {
|
|
358
|
+
id: typeof payload.messageId === "string" ? payload.messageId : this.#nextLocalMessageId(),
|
|
359
|
+
sender: "system",
|
|
360
|
+
text: typeof payload.message === "string" ? payload.message : "",
|
|
361
|
+
sentAt: normalizeTimestamp(payload.sentAt),
|
|
362
|
+
replyTo: typeof payload.replyTo === "string" ? payload.replyTo : null,
|
|
363
|
+
severity: typeof payload.severity === "string" ? payload.severity : "info",
|
|
364
|
+
attachments: [],
|
|
365
|
+
},
|
|
366
|
+
});
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
case "provider_capabilities": {
|
|
370
|
+
this.invite = {
|
|
371
|
+
...this.invite,
|
|
372
|
+
expiresAt: typeof payload.expiresAt === "string" ? payload.expiresAt : this.invite.expiresAt,
|
|
373
|
+
groupMode: typeof payload.groupMode === "boolean" ? payload.groupMode : this.invite.groupMode,
|
|
374
|
+
providerLabel:
|
|
375
|
+
typeof payload.providerLabel === "string" ? payload.providerLabel : this.invite.providerLabel,
|
|
376
|
+
};
|
|
377
|
+
let assignedIdentity = null;
|
|
378
|
+
if (
|
|
379
|
+
payload.currentAppId === this.identity.appId &&
|
|
380
|
+
typeof payload.currentDisplayName === "string" &&
|
|
381
|
+
payload.currentDisplayName.trim() !== "" &&
|
|
382
|
+
payload.currentDisplayName !== this.identity.displayName
|
|
383
|
+
) {
|
|
384
|
+
this.identity = {
|
|
385
|
+
...this.identity,
|
|
386
|
+
displayName: payload.currentDisplayName,
|
|
387
|
+
};
|
|
388
|
+
assignedIdentity = this.identity;
|
|
389
|
+
}
|
|
390
|
+
this.#dispatch("capabilities", {
|
|
391
|
+
status: "active",
|
|
392
|
+
invite: this.invite,
|
|
393
|
+
commands: parseCommands(payload.commands),
|
|
394
|
+
participants: parseParticipants(payload.participants),
|
|
395
|
+
identity: assignedIdentity,
|
|
396
|
+
botMuted: Boolean(payload.botMuted),
|
|
397
|
+
});
|
|
398
|
+
this.#dispatch("state", {
|
|
399
|
+
status: "active",
|
|
400
|
+
invite: this.invite,
|
|
401
|
+
});
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
case "session_renewed": {
|
|
405
|
+
if (typeof payload.newSessionKey !== "string" || payload.newSessionKey.trim() === "") {
|
|
406
|
+
throw new Error("missing_next_session_key");
|
|
407
|
+
}
|
|
408
|
+
this.invite = {
|
|
409
|
+
...this.invite,
|
|
410
|
+
sessionKey: payload.newSessionKey,
|
|
411
|
+
expiresAt: typeof payload.expiresAt === "string" ? payload.expiresAt : this.invite.expiresAt,
|
|
412
|
+
};
|
|
413
|
+
this.cryptoContext = await createCryptoContext({
|
|
414
|
+
sessionId: this.invite.sessionId,
|
|
415
|
+
sessionKey: this.invite.sessionKey,
|
|
416
|
+
});
|
|
417
|
+
this.#dispatch("renewed", {
|
|
418
|
+
invite: this.invite,
|
|
419
|
+
expiresAt: normalizeTimestamp(payload.expiresAt),
|
|
420
|
+
replyTo: typeof payload.replyTo === "string" ? payload.replyTo : null,
|
|
421
|
+
message: typeof payload.message === "string" ? payload.message : "",
|
|
422
|
+
});
|
|
423
|
+
this.#dispatch("state", {
|
|
424
|
+
status: "active",
|
|
425
|
+
invite: this.invite,
|
|
426
|
+
});
|
|
427
|
+
await this.#sendEncrypted({
|
|
428
|
+
kind: "client_hello",
|
|
429
|
+
appVersion: "privateclaw_web/0.1.0",
|
|
430
|
+
appId: this.identity.appId,
|
|
431
|
+
deviceLabel: "PrivateClaw Web",
|
|
432
|
+
...(this.identity.displayName ? { displayName: this.identity.displayName } : {}),
|
|
433
|
+
sentAt: new Date().toISOString(),
|
|
434
|
+
});
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
default:
|
|
438
|
+
this.#dispatch("state", {
|
|
439
|
+
status: "error",
|
|
440
|
+
notice: "unknownPayload",
|
|
441
|
+
details: typeof payload.kind === "string" ? payload.kind : "unknown_payload",
|
|
442
|
+
invite: this.invite,
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
async #sendEncrypted(payload) {
|
|
448
|
+
if (!this.cryptoContext || !this.socket || this.socket.readyState !== WebSocket.OPEN) {
|
|
449
|
+
throw new Error("session_not_connected");
|
|
450
|
+
}
|
|
451
|
+
const envelope = await this.cryptoContext.encrypt(payload);
|
|
452
|
+
this.socket.send(JSON.stringify({ type: "app:frame", envelope }));
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
#buildSocketUrl() {
|
|
456
|
+
const baseUrl = new URL(this.invite.appWsUrl);
|
|
457
|
+
baseUrl.searchParams.set("appId", this.identity.appId);
|
|
458
|
+
return baseUrl.toString();
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
#dispatch(type, detail) {
|
|
462
|
+
this.dispatchEvent(new CustomEvent(type, { detail }));
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
#nextLocalMessageId() {
|
|
466
|
+
this.messageCounter += 1;
|
|
467
|
+
return `client-${Date.now()}-${this.messageCounter}`;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { applyTranslations, bindLocaleSelect, getValue, onLocaleChange, t } from "./i18n.js?v=20260316-3";
|
|
2
|
+
|
|
3
|
+
const localeSelect = document.getElementById("locale-select");
|
|
4
|
+
const webEntry = document.getElementById("web-entry");
|
|
5
|
+
const deviceHintCopy = document.getElementById("device-hint-copy");
|
|
6
|
+
const heroStats = document.getElementById("hero-stats");
|
|
7
|
+
const previewChat = document.getElementById("preview-chat");
|
|
8
|
+
const featureGrid = document.getElementById("feature-grid");
|
|
9
|
+
const scenarioGrid = document.getElementById("scenario-grid");
|
|
10
|
+
const setupGrid = document.getElementById("setup-grid");
|
|
11
|
+
|
|
12
|
+
bindLocaleSelect(localeSelect);
|
|
13
|
+
|
|
14
|
+
function isMobileDevice() {
|
|
15
|
+
const ua = navigator.userAgent || "";
|
|
16
|
+
const coarsePointer = globalThis.matchMedia?.("(pointer: coarse)")?.matches ?? false;
|
|
17
|
+
const narrowScreen = globalThis.matchMedia?.("(max-width: 820px)")?.matches ?? false;
|
|
18
|
+
return coarsePointer || narrowScreen || /Android|iPhone|iPad|iPod|Mobile/i.test(ua);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function renderStats() {
|
|
22
|
+
heroStats.replaceChildren();
|
|
23
|
+
const stats = getValue("site.heroStats");
|
|
24
|
+
if (!Array.isArray(stats)) {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
for (const stat of stats) {
|
|
28
|
+
const card = document.createElement("div");
|
|
29
|
+
card.className = "stat-card";
|
|
30
|
+
|
|
31
|
+
const title = document.createElement("strong");
|
|
32
|
+
title.textContent = stat.value;
|
|
33
|
+
|
|
34
|
+
const body = document.createElement("span");
|
|
35
|
+
body.textContent = stat.label;
|
|
36
|
+
|
|
37
|
+
card.append(title, body);
|
|
38
|
+
heroStats.append(card);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function renderPreview() {
|
|
43
|
+
previewChat.replaceChildren();
|
|
44
|
+
const messages = getValue("site.previewMessages");
|
|
45
|
+
if (!Array.isArray(messages)) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
for (const item of messages) {
|
|
49
|
+
const bubble = document.createElement("div");
|
|
50
|
+
bubble.className = `preview-bubble ${item.role === "assistant" ? "assistant" : "member"}`;
|
|
51
|
+
|
|
52
|
+
const label = document.createElement("span");
|
|
53
|
+
label.className = "preview-name";
|
|
54
|
+
label.textContent = item.speaker;
|
|
55
|
+
|
|
56
|
+
const text = document.createElement("div");
|
|
57
|
+
text.textContent = item.text;
|
|
58
|
+
|
|
59
|
+
bubble.append(label, text);
|
|
60
|
+
previewChat.append(bubble);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function renderCards(target, items, cardClass) {
|
|
65
|
+
target.replaceChildren();
|
|
66
|
+
if (!Array.isArray(items)) {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
for (const item of items) {
|
|
70
|
+
const card = document.createElement("article");
|
|
71
|
+
card.className = cardClass;
|
|
72
|
+
|
|
73
|
+
const eyebrow = document.createElement("div");
|
|
74
|
+
eyebrow.className = cardClass === "feature-card" ? "feature-eyebrow" : "scenario-eyebrow";
|
|
75
|
+
eyebrow.textContent = item.eyebrow;
|
|
76
|
+
|
|
77
|
+
const title = document.createElement("h3");
|
|
78
|
+
title.textContent = item.title;
|
|
79
|
+
|
|
80
|
+
const body = document.createElement("p");
|
|
81
|
+
body.textContent = item.body;
|
|
82
|
+
|
|
83
|
+
card.append(eyebrow, title, body);
|
|
84
|
+
target.append(card);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function renderSetupSteps() {
|
|
89
|
+
setupGrid.replaceChildren();
|
|
90
|
+
const steps = getValue("site.setupSteps");
|
|
91
|
+
if (!Array.isArray(steps)) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
for (const item of steps) {
|
|
96
|
+
const card = document.createElement("article");
|
|
97
|
+
card.className = "setup-card";
|
|
98
|
+
|
|
99
|
+
const stepLabel = document.createElement("div");
|
|
100
|
+
stepLabel.className = "setup-step-label";
|
|
101
|
+
stepLabel.textContent = item.step;
|
|
102
|
+
|
|
103
|
+
const title = document.createElement("h3");
|
|
104
|
+
title.textContent = item.title;
|
|
105
|
+
|
|
106
|
+
const body = document.createElement("p");
|
|
107
|
+
body.textContent = item.body;
|
|
108
|
+
|
|
109
|
+
card.append(stepLabel, title, body);
|
|
110
|
+
|
|
111
|
+
if (Array.isArray(item.commands) && item.commands.length > 0) {
|
|
112
|
+
const commands = document.createElement("div");
|
|
113
|
+
commands.className = "setup-command-list";
|
|
114
|
+
for (const command of item.commands) {
|
|
115
|
+
const pre = document.createElement("pre");
|
|
116
|
+
pre.className = "setup-command";
|
|
117
|
+
|
|
118
|
+
const code = document.createElement("code");
|
|
119
|
+
code.textContent = command;
|
|
120
|
+
|
|
121
|
+
pre.append(code);
|
|
122
|
+
commands.append(pre);
|
|
123
|
+
}
|
|
124
|
+
card.append(commands);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (item.note) {
|
|
128
|
+
const note = document.createElement("p");
|
|
129
|
+
note.className = "setup-note";
|
|
130
|
+
note.textContent = item.note;
|
|
131
|
+
card.append(note);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
setupGrid.append(card);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function renderWebEntry() {
|
|
139
|
+
const mobile = isMobileDevice();
|
|
140
|
+
webEntry.hidden = false;
|
|
141
|
+
webEntry.classList.remove("hidden");
|
|
142
|
+
deviceHintCopy.textContent = t(mobile ? "site.heroMobileHint" : "site.heroDesktopHint");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function renderPage() {
|
|
146
|
+
applyTranslations();
|
|
147
|
+
document.title = t("site.documentTitle");
|
|
148
|
+
renderWebEntry();
|
|
149
|
+
renderStats();
|
|
150
|
+
renderPreview();
|
|
151
|
+
renderCards(featureGrid, getValue("site.features"), "feature-card");
|
|
152
|
+
renderCards(scenarioGrid, getValue("site.scenarios"), "scenario-card");
|
|
153
|
+
renderSetupSteps();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
window.addEventListener("resize", renderWebEntry);
|
|
157
|
+
onLocaleChange(renderPage);
|
|
158
|
+
renderPage();
|