phoenix_duskmoon 10.0.11 → 10.1.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/CHANGELOG.md +39 -0
- package/README.md +12 -5
- package/assets/js/react_chat/chat.js +15 -16
- package/assets/js/react_chat/index.js +451 -40
- package/assets/js/react_chat/protocol.js +125 -0
- package/assets/js/react_chat/state.js +134 -5
- package/assets/js/react_form/index.js +1 -1
- package/package.json +24 -10
- package/priv/static/assets/css/app-79cf21e84f298332a3addc11d02afa5c.css +2 -0
- package/priv/static/assets/css/app-79cf21e84f298332a3addc11d02afa5c.css.gz +0 -0
- package/priv/static/assets/css/app.css +2 -2
- package/priv/static/assets/css/app.css.gz +0 -0
- package/priv/static/assets/js/{phoenix_duskmoon-c22eb67823f82d7b0d27eba5dbd7be12.js → phoenix_duskmoon-7a95bc687b7d72a85fec06221386e515.js} +1 -1
- package/priv/static/assets/js/{phoenix_duskmoon-c22eb67823f82d7b0d27eba5dbd7be12.js.gz → phoenix_duskmoon-7a95bc687b7d72a85fec06221386e515.js.gz} +0 -0
- package/priv/static/assets/js/phoenix_duskmoon.js +1 -1
- package/priv/static/assets/js/phoenix_duskmoon.js.gz +0 -0
- package/priv/static/cache_manifest.json +2 -2
- package/priv/static/assets/css/app-8305fbeb48a74c180d2d40e5e2a038c9.css +0 -2
- package/priv/static/assets/css/app-8305fbeb48a74c180d2d40e5e2a038c9.css.gz +0 -0
|
@@ -1,28 +1,51 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { createRoot } from "react-dom/client";
|
|
3
|
+
import {
|
|
4
|
+
snapshotComposerDocument,
|
|
5
|
+
snapshotComposerReceipt,
|
|
6
|
+
} from "@duskmoon-dev/components/chat";
|
|
3
7
|
import { ReactChat } from "./chat.js";
|
|
4
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
initialState,
|
|
10
|
+
reduceChat,
|
|
11
|
+
sameChatScope,
|
|
12
|
+
validChatQueue,
|
|
13
|
+
} from "./state.js";
|
|
14
|
+
import {
|
|
15
|
+
assertChatJSON,
|
|
16
|
+
ChatTransportError,
|
|
17
|
+
correlatedReceipt,
|
|
18
|
+
requestId,
|
|
19
|
+
structuredSnapshot,
|
|
20
|
+
submissionSnapshot,
|
|
21
|
+
unknownReceipt,
|
|
22
|
+
chatJSONKey,
|
|
23
|
+
} from "./protocol.js";
|
|
5
24
|
|
|
6
25
|
export { ReactChat, useChatSubmission } from "./chat.js";
|
|
7
26
|
export { initialState, reduceChat } from "./state.js";
|
|
27
|
+
export { ChatTransportError } from "./protocol.js";
|
|
28
|
+
|
|
29
|
+
let nextGeneration = 0;
|
|
8
30
|
|
|
9
31
|
export function createDuskmoonReactChat(options = {}) {
|
|
10
32
|
return {
|
|
11
33
|
mounted() {
|
|
12
|
-
this.config = JSON.parse(this.el.dataset.chat);
|
|
13
|
-
this.state = initialState(
|
|
14
|
-
this.config.conversationId,
|
|
15
|
-
this.config.messages,
|
|
16
|
-
this.config,
|
|
17
|
-
);
|
|
18
|
-
this.root = createRoot(this.el);
|
|
19
34
|
this.connected = true;
|
|
20
35
|
this.requests = new Set();
|
|
21
|
-
this.
|
|
22
|
-
|
|
23
|
-
|
|
36
|
+
this.recoveryFailures = new Map();
|
|
37
|
+
this.bindContext(JSON.parse(this.el.dataset.chat));
|
|
38
|
+
this.root = createRoot(this.el);
|
|
24
39
|
this.eventRef = this.handleEvent(this.config.event, (event) => {
|
|
25
40
|
if (event.id !== this.el.id) return;
|
|
41
|
+
if (
|
|
42
|
+
["chat.snapshot", "chat.reset"].includes(event.type) &&
|
|
43
|
+
event.conversation_id === this.state.conversationId &&
|
|
44
|
+
event.protocolVersion === 1 &&
|
|
45
|
+
sameChatScope(event.scope, this.state.scope) &&
|
|
46
|
+
event.instanceGeneration === this.state.instanceGeneration
|
|
47
|
+
)
|
|
48
|
+
this.reconcileReceipts(event.receipts);
|
|
26
49
|
const previous = this.state;
|
|
27
50
|
this.state = reduceChat(this.state, event);
|
|
28
51
|
if (previous === this.state) return;
|
|
@@ -31,63 +54,449 @@ export function createDuskmoonReactChat(options = {}) {
|
|
|
31
54
|
});
|
|
32
55
|
this.renderChat();
|
|
33
56
|
},
|
|
34
|
-
|
|
35
|
-
if (
|
|
36
|
-
|
|
57
|
+
bindContext(config) {
|
|
58
|
+
if (config.protocolVersion != null && config.protocolVersion !== 1)
|
|
59
|
+
throw new TypeError("Unsupported chat protocol version");
|
|
60
|
+
const generation = ++nextGeneration;
|
|
61
|
+
if (
|
|
62
|
+
config.protocolVersion === 1 &&
|
|
63
|
+
(!config.scope?.contextKey ||
|
|
64
|
+
config.scope.conversationId !== config.conversationId)
|
|
65
|
+
)
|
|
66
|
+
throw new TypeError(
|
|
67
|
+
"Structured chat requires an application context key and matching conversation",
|
|
68
|
+
);
|
|
69
|
+
this.config = config;
|
|
70
|
+
this.state = initialState(config.conversationId, config.messages, {
|
|
71
|
+
...config,
|
|
72
|
+
instanceGeneration: generation,
|
|
73
|
+
});
|
|
74
|
+
this.adapter =
|
|
75
|
+
options.adapter?.({
|
|
76
|
+
scope: this.state.scope,
|
|
77
|
+
instanceGeneration: generation,
|
|
78
|
+
config,
|
|
79
|
+
element: this.el,
|
|
80
|
+
}) || {};
|
|
81
|
+
this.submissionPending = false;
|
|
82
|
+
this.syncPending = null;
|
|
83
|
+
const current = () => {
|
|
84
|
+
if (this.dead || generation !== this.state.instanceGeneration)
|
|
85
|
+
throw new Error("Chat context has changed");
|
|
86
|
+
};
|
|
87
|
+
this.bridge = {
|
|
88
|
+
send: (action, payload) => {
|
|
89
|
+
try {
|
|
90
|
+
current();
|
|
91
|
+
return this.sendChat(action, payload);
|
|
92
|
+
} catch (error) {
|
|
93
|
+
return Promise.reject(error);
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
submit: async (snapshot, signal) => {
|
|
97
|
+
current();
|
|
98
|
+
return this.submitStructured(snapshot, signal);
|
|
99
|
+
},
|
|
100
|
+
reconcile: async (snapshot, signal) => {
|
|
101
|
+
current();
|
|
102
|
+
return this.reconcileSubmission(snapshot, signal);
|
|
103
|
+
},
|
|
104
|
+
mutateQueue: async (mutation, signal) => {
|
|
105
|
+
current();
|
|
106
|
+
return this.mutateQueue(mutation, signal);
|
|
107
|
+
},
|
|
108
|
+
getRecoveryFailures: () => [...this.recoveryFailures.values()],
|
|
109
|
+
};
|
|
110
|
+
},
|
|
111
|
+
// Explicit host operation. Mutating data-chat alone never changes ownership.
|
|
112
|
+
switchContext(config) {
|
|
113
|
+
if (config.event !== this.config.event)
|
|
114
|
+
throw new TypeError("Remount the hook to change its event channel");
|
|
115
|
+
this.requests.forEach((request) => request.cancel());
|
|
116
|
+
this.bindContext(config);
|
|
117
|
+
this.renderChat();
|
|
118
|
+
},
|
|
119
|
+
checkAction(action) {
|
|
120
|
+
if (
|
|
121
|
+
typeof this.config.actions?.[action] !== "string" ||
|
|
122
|
+
!this.config.actions[action].trim()
|
|
123
|
+
)
|
|
124
|
+
throw new TypeError(`Chat action is not configured: ${action}`);
|
|
125
|
+
if (!this.connected || this.dead) throw new Error("Chat is disconnected");
|
|
37
126
|
if (action === "send" && this.state.needsSync)
|
|
38
|
-
|
|
127
|
+
throw new Error("Chat is synchronizing");
|
|
128
|
+
if (this.requests.size >= (options.maxPendingRequests ?? 64))
|
|
129
|
+
throw new Error(
|
|
130
|
+
"Chat transport capacity reached; reconcile pending requests",
|
|
131
|
+
);
|
|
132
|
+
},
|
|
133
|
+
sendChat(action, payload = {}) {
|
|
134
|
+
if (
|
|
135
|
+
action === "send" &&
|
|
136
|
+
(payload.protocolVersion !== undefined ||
|
|
137
|
+
payload.document !== undefined)
|
|
138
|
+
)
|
|
139
|
+
return this.submitStructured(payload);
|
|
140
|
+
return this.transport(action, payload);
|
|
141
|
+
},
|
|
142
|
+
transport(action, payload, structured) {
|
|
143
|
+
try {
|
|
144
|
+
this.checkAction(action);
|
|
145
|
+
assertChatJSON(payload);
|
|
146
|
+
} catch (error) {
|
|
147
|
+
return Promise.reject(error);
|
|
148
|
+
}
|
|
149
|
+
const generation = this.state.instanceGeneration;
|
|
150
|
+
const scope = this.state.scope;
|
|
151
|
+
const correlation = {
|
|
152
|
+
request_id: payload.request_id ?? requestId(),
|
|
153
|
+
instanceGeneration: generation,
|
|
154
|
+
scope,
|
|
155
|
+
clientSubmissionId: structured?.snapshot?.clientSubmissionId,
|
|
156
|
+
};
|
|
157
|
+
if (
|
|
158
|
+
typeof correlation.request_id !== "string" ||
|
|
159
|
+
!correlation.request_id ||
|
|
160
|
+
[...this.requests].some((r) => r.id === correlation.request_id)
|
|
161
|
+
)
|
|
162
|
+
return Promise.reject(
|
|
163
|
+
new TypeError("Chat request_id must be unique while pending"),
|
|
164
|
+
);
|
|
165
|
+
const body = {
|
|
166
|
+
...payload,
|
|
167
|
+
request_id: correlation.request_id,
|
|
168
|
+
id: this.el.id,
|
|
169
|
+
conversation_id: this.state.conversationId,
|
|
170
|
+
...(this.state.protocolVersion === 1
|
|
171
|
+
? { protocolVersion: 1, scope, instanceGeneration: generation }
|
|
172
|
+
: {}),
|
|
173
|
+
};
|
|
39
174
|
return new Promise((resolve, reject) => {
|
|
175
|
+
const uncertain = (message, cause) =>
|
|
176
|
+
new ChatTransportError(
|
|
177
|
+
message,
|
|
178
|
+
{ ...correlation, dispatched: true },
|
|
179
|
+
cause,
|
|
180
|
+
);
|
|
40
181
|
const finish = (error, reply) => {
|
|
41
182
|
if (!this.requests.delete(request)) return;
|
|
42
183
|
clearTimeout(request.timer);
|
|
184
|
+
structured?.signal?.removeEventListener("abort", request.cancel);
|
|
43
185
|
error ? reject(error) : resolve(reply);
|
|
44
186
|
};
|
|
45
187
|
const request = {
|
|
188
|
+
id: correlation.request_id,
|
|
46
189
|
cancel: () =>
|
|
47
190
|
finish(
|
|
48
|
-
|
|
191
|
+
uncertain("Chat disconnected; check status before retrying"),
|
|
49
192
|
),
|
|
50
193
|
};
|
|
194
|
+
if (structured?.signal?.aborted) {
|
|
195
|
+
reject(new Error("Chat request aborted before dispatch"));
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
51
198
|
this.requests.add(request);
|
|
199
|
+
structured?.signal?.addEventListener("abort", request.cancel, {
|
|
200
|
+
once: true,
|
|
201
|
+
});
|
|
52
202
|
request.timer = setTimeout(
|
|
53
203
|
() =>
|
|
54
204
|
finish(
|
|
55
|
-
|
|
205
|
+
uncertain("Chat request timed out; check status before retrying"),
|
|
56
206
|
),
|
|
57
|
-
15000,
|
|
207
|
+
options.requestTimeoutMs ?? 15000,
|
|
58
208
|
);
|
|
59
|
-
const body = {
|
|
60
|
-
...payload,
|
|
61
|
-
id: this.el.id,
|
|
62
|
-
conversation_id: this.state.conversationId,
|
|
63
|
-
};
|
|
64
209
|
const reply = (response) => {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
210
|
+
try {
|
|
211
|
+
if (structured) {
|
|
212
|
+
const receipt = structured.validate(response, correlation);
|
|
213
|
+
structured.onReceipt?.(receipt);
|
|
214
|
+
// A late receipt resolves its original recovery record only.
|
|
215
|
+
if (generation !== this.state.instanceGeneration || this.dead) {
|
|
216
|
+
finish(uncertain("Chat context has changed"));
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
finish(null, receipt);
|
|
220
|
+
} else if (response?.status === "ok") finish(null, response);
|
|
221
|
+
else if (response?.status === "error") {
|
|
222
|
+
const error = new Error(
|
|
223
|
+
response.message || "Chat request rejected",
|
|
224
|
+
);
|
|
225
|
+
error.response = response;
|
|
226
|
+
finish(error);
|
|
227
|
+
} else
|
|
228
|
+
finish(uncertain("Missing or malformed chat acknowledgement"));
|
|
229
|
+
} catch (error) {
|
|
230
|
+
finish(
|
|
231
|
+
uncertain(
|
|
232
|
+
"Invalid chat acknowledgement; check status before retrying",
|
|
233
|
+
error,
|
|
234
|
+
),
|
|
235
|
+
);
|
|
236
|
+
}
|
|
71
237
|
};
|
|
72
238
|
try {
|
|
73
239
|
const event = this.config.actions[action];
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
this.config.target || this.el.dataset.target,
|
|
77
|
-
event,
|
|
78
|
-
body,
|
|
79
|
-
reply,
|
|
80
|
-
);
|
|
240
|
+
const target = this.config.target || this.el.dataset.target;
|
|
241
|
+
if (target) this.pushEventTo(target, event, body, reply);
|
|
81
242
|
else this.pushEvent(event, body, reply);
|
|
82
243
|
} catch (error) {
|
|
83
|
-
finish(error);
|
|
244
|
+
finish(uncertain("Chat dispatch outcome is unknown", error));
|
|
84
245
|
}
|
|
85
246
|
});
|
|
86
247
|
},
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
()
|
|
248
|
+
retain(adapter, attempt) {
|
|
249
|
+
try {
|
|
250
|
+
adapter.recovery.retain(attempt);
|
|
251
|
+
this.recoveryFailures.delete(attempt.snapshot.clientSubmissionId);
|
|
252
|
+
} catch (error) {
|
|
253
|
+
this.recoveryFailures.set(attempt.snapshot.clientSubmissionId, {
|
|
254
|
+
attempt,
|
|
255
|
+
error,
|
|
256
|
+
});
|
|
257
|
+
throw error;
|
|
258
|
+
}
|
|
259
|
+
},
|
|
260
|
+
async submitStructured(payload, signal) {
|
|
261
|
+
if (signal?.aborted)
|
|
262
|
+
throw new Error("Chat request aborted before dispatch");
|
|
263
|
+
this.checkAction("send");
|
|
264
|
+
if (this.submissionPending)
|
|
265
|
+
throw new Error("Chat is not accepting messages");
|
|
266
|
+
const snapshot = structuredSnapshot(payload, this.state);
|
|
267
|
+
const adapter = this.adapter;
|
|
268
|
+
if (!adapter.recovery?.retain || !adapter.recovery?.getSnapshot)
|
|
269
|
+
throw new TypeError(
|
|
270
|
+
"Structured submissions require an application recovery owner",
|
|
271
|
+
);
|
|
272
|
+
const generation = this.state.instanceGeneration;
|
|
273
|
+
const previous = adapter.recovery
|
|
274
|
+
.getSnapshot()
|
|
275
|
+
.find(
|
|
276
|
+
(a) => a.snapshot.clientSubmissionId === snapshot.clientSubmissionId,
|
|
277
|
+
);
|
|
278
|
+
if (previous && chatJSONKey(previous.snapshot) !== chatJSONKey(snapshot))
|
|
279
|
+
throw new TypeError(
|
|
280
|
+
"A logical submission ID cannot change its payload",
|
|
281
|
+
);
|
|
282
|
+
if (previous?.status === "accepted" || previous?.status === "rejected")
|
|
283
|
+
return previous.receipt;
|
|
284
|
+
const physicalId = payload.request_id ?? requestId();
|
|
285
|
+
const attempt = {
|
|
286
|
+
snapshot,
|
|
287
|
+
instanceId: this.el.id,
|
|
288
|
+
instanceGeneration: generation,
|
|
289
|
+
createdAt: previous?.createdAt ?? Date.now(),
|
|
290
|
+
status: "pending",
|
|
291
|
+
requestIds: [...(previous?.requestIds || []), physicalId],
|
|
292
|
+
};
|
|
293
|
+
this.submissionPending = true;
|
|
294
|
+
try {
|
|
295
|
+
// The upstream controller may already have retained this first attempt.
|
|
296
|
+
if (
|
|
297
|
+
previous &&
|
|
298
|
+
(previous.status === "unknown" || previous.requestIds?.length) &&
|
|
299
|
+
(await adapter.authorizeRetry?.(previous)) !== true
|
|
300
|
+
)
|
|
301
|
+
throw new Error(
|
|
302
|
+
"Explicit application authorization is required to retry an unresolved submission",
|
|
303
|
+
);
|
|
304
|
+
// Handoff is synchronous and must succeed before dispatch becomes possible.
|
|
305
|
+
this.retain(adapter, attempt);
|
|
306
|
+
if (generation !== this.state.instanceGeneration || this.dead)
|
|
307
|
+
throw new Error("Chat context has changed");
|
|
308
|
+
return await this.transport(
|
|
309
|
+
"send",
|
|
310
|
+
{ ...snapshot, request_id: physicalId },
|
|
311
|
+
{
|
|
312
|
+
snapshot,
|
|
313
|
+
signal,
|
|
314
|
+
validate: (response, correlation) =>
|
|
315
|
+
correlatedReceipt(response, snapshot, correlation),
|
|
316
|
+
onReceipt: (receipt) =>
|
|
317
|
+
this.retain(adapter, {
|
|
318
|
+
...attempt,
|
|
319
|
+
status: receipt.outcome,
|
|
320
|
+
receipt,
|
|
321
|
+
}),
|
|
322
|
+
},
|
|
323
|
+
);
|
|
324
|
+
} catch (error) {
|
|
325
|
+
if (!(error instanceof ChatTransportError)) throw error;
|
|
326
|
+
const receipt = unknownReceipt(snapshot, error);
|
|
327
|
+
this.retain(adapter, { ...attempt, status: "unknown", receipt });
|
|
328
|
+
return receipt;
|
|
329
|
+
} finally {
|
|
330
|
+
if (generation === this.state.instanceGeneration)
|
|
331
|
+
this.submissionPending = false;
|
|
332
|
+
}
|
|
333
|
+
},
|
|
334
|
+
async reconcileSubmission(payload, signal) {
|
|
335
|
+
const snapshot = submissionSnapshot(payload, this.state);
|
|
336
|
+
const adapter = this.adapter;
|
|
337
|
+
const attempt = adapter.recovery
|
|
338
|
+
?.getSnapshot()
|
|
339
|
+
.find(
|
|
340
|
+
(a) => a.snapshot.clientSubmissionId === snapshot.clientSubmissionId,
|
|
341
|
+
);
|
|
342
|
+
if (!attempt || chatJSONKey(attempt.snapshot) !== chatJSONKey(snapshot))
|
|
343
|
+
throw new Error("No matching scoped recovery record for submission");
|
|
344
|
+
try {
|
|
345
|
+
return await this.transport(
|
|
346
|
+
"reconcile",
|
|
347
|
+
{
|
|
348
|
+
clientSubmissionId: snapshot.clientSubmissionId,
|
|
349
|
+
operation: snapshot.operation,
|
|
350
|
+
},
|
|
351
|
+
{
|
|
352
|
+
snapshot,
|
|
353
|
+
signal,
|
|
354
|
+
validate: (response, correlation) =>
|
|
355
|
+
correlatedReceipt(response, snapshot, correlation),
|
|
356
|
+
onReceipt: (receipt) =>
|
|
357
|
+
this.retain(adapter, {
|
|
358
|
+
...attempt,
|
|
359
|
+
status: receipt.outcome,
|
|
360
|
+
receipt,
|
|
361
|
+
}),
|
|
362
|
+
},
|
|
363
|
+
);
|
|
364
|
+
} catch (error) {
|
|
365
|
+
if (!(error instanceof ChatTransportError)) throw error;
|
|
366
|
+
const receipt = unknownReceipt(snapshot, error);
|
|
367
|
+
this.retain(adapter, { ...attempt, status: "unknown", receipt });
|
|
368
|
+
return receipt;
|
|
369
|
+
}
|
|
370
|
+
},
|
|
371
|
+
async mutateQueue(mutation, signal) {
|
|
372
|
+
assertChatJSON(mutation);
|
|
373
|
+
if (
|
|
374
|
+
!["edit", "remove", "promote"].includes(mutation.operation) ||
|
|
375
|
+
!mutation.requestId
|
|
376
|
+
)
|
|
377
|
+
throw new TypeError("Unsupported queue operation");
|
|
378
|
+
const { composer, scope } = this.state;
|
|
379
|
+
const item = composer?.queue?.items.find(
|
|
380
|
+
(item) => item.id === mutation.itemId,
|
|
90
381
|
);
|
|
382
|
+
const capability = composer?.capabilities?.queue?.[mutation.operation];
|
|
383
|
+
const itemCapability = item?.capabilities?.[mutation.operation];
|
|
384
|
+
if (
|
|
385
|
+
!sameChatScope(mutation.scope, scope) ||
|
|
386
|
+
!item ||
|
|
387
|
+
!capability ||
|
|
388
|
+
capability === "none" ||
|
|
389
|
+
!itemCapability ||
|
|
390
|
+
itemCapability === "none"
|
|
391
|
+
)
|
|
392
|
+
throw new TypeError("Unsupported queue operation or item");
|
|
393
|
+
if (mutation.expectedRevision !== composer.queue.revision)
|
|
394
|
+
throw new TypeError("Queue revision has changed");
|
|
395
|
+
if (mutation.operation === "promote" && !mutation.targetTurnId)
|
|
396
|
+
throw new TypeError("Promotion requires a target turn");
|
|
397
|
+
if (mutation.operation === "edit")
|
|
398
|
+
snapshotComposerDocument(mutation.document);
|
|
399
|
+
if (
|
|
400
|
+
mutation.operation === "edit" &&
|
|
401
|
+
(!mutation.document ||
|
|
402
|
+
([capability, itemCapability].includes("text") &&
|
|
403
|
+
(mutation.document.command ||
|
|
404
|
+
mutation.document.content?.some((node) => node.type !== "text"))))
|
|
405
|
+
)
|
|
406
|
+
throw new TypeError("Queue editing supports text documents only");
|
|
407
|
+
const generation = this.state.instanceGeneration;
|
|
408
|
+
return this.transport(`queue_${mutation.operation}`, mutation, {
|
|
409
|
+
signal,
|
|
410
|
+
validate: (response, correlation) => {
|
|
411
|
+
const result = response?.result;
|
|
412
|
+
assertChatJSON(result);
|
|
413
|
+
if (
|
|
414
|
+
response?.request_id !== correlation.request_id ||
|
|
415
|
+
response?.instanceGeneration !== generation ||
|
|
416
|
+
result?.requestId !== mutation.requestId ||
|
|
417
|
+
!["accepted", "rejected"].includes(result.outcome) ||
|
|
418
|
+
!validChatQueue(result.projection, scope) ||
|
|
419
|
+
(result.outcome === "rejected" && !result.error?.code)
|
|
420
|
+
)
|
|
421
|
+
throw new TypeError("Queue response correlation mismatch");
|
|
422
|
+
return result;
|
|
423
|
+
},
|
|
424
|
+
onReceipt: (result) => {
|
|
425
|
+
if (this.dead || generation !== this.state.instanceGeneration) return;
|
|
426
|
+
const projection = result.projection;
|
|
427
|
+
if (
|
|
428
|
+
Number.isSafeInteger(projection.revision) &&
|
|
429
|
+
projection.revision > this.state.composer.queue.revision &&
|
|
430
|
+
Array.isArray(projection.items)
|
|
431
|
+
) {
|
|
432
|
+
this.state = {
|
|
433
|
+
...this.state,
|
|
434
|
+
composer: { ...this.state.composer, queue: projection },
|
|
435
|
+
};
|
|
436
|
+
this.scheduleRender();
|
|
437
|
+
}
|
|
438
|
+
},
|
|
439
|
+
});
|
|
440
|
+
},
|
|
441
|
+
reconcileReceipts(receipts = []) {
|
|
442
|
+
if (!this.adapter.reconcileOnSync || !this.adapter.recovery) return;
|
|
443
|
+
if (!Array.isArray(receipts)) receipts = [];
|
|
444
|
+
for (const attempt of this.adapter.recovery.getSnapshot()) {
|
|
445
|
+
if (
|
|
446
|
+
!sameChatScope(attempt.snapshot.scope, this.state.scope) ||
|
|
447
|
+
!["pending", "unknown"].includes(attempt.status)
|
|
448
|
+
)
|
|
449
|
+
continue;
|
|
450
|
+
const input = receipts.find(
|
|
451
|
+
(r) => r.clientSubmissionId === attempt.snapshot.clientSubmissionId,
|
|
452
|
+
);
|
|
453
|
+
let receipt;
|
|
454
|
+
try {
|
|
455
|
+
receipt = input
|
|
456
|
+
? snapshotComposerReceipt(input, attempt.snapshot)
|
|
457
|
+
: unknownReceipt(
|
|
458
|
+
attempt.snapshot,
|
|
459
|
+
new Error("No authoritative receipt in snapshot"),
|
|
460
|
+
);
|
|
461
|
+
} catch (error) {
|
|
462
|
+
receipt = unknownReceipt(attempt.snapshot, error);
|
|
463
|
+
}
|
|
464
|
+
this.retain(this.adapter, {
|
|
465
|
+
...attempt,
|
|
466
|
+
status: receipt.outcome,
|
|
467
|
+
receipt,
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
},
|
|
471
|
+
requestSync() {
|
|
472
|
+
if (this.syncPending) return this.syncPending;
|
|
473
|
+
const generation = this.state.instanceGeneration;
|
|
474
|
+
const payload = { sequences: this.state.sequences };
|
|
475
|
+
if (this.adapter.reconcileOnSync && this.adapter.recovery)
|
|
476
|
+
payload.unresolvedSubmissionIds = this.adapter.recovery
|
|
477
|
+
.getSnapshot()
|
|
478
|
+
.filter(
|
|
479
|
+
(a) =>
|
|
480
|
+
sameChatScope(a.snapshot.scope, this.state.scope) &&
|
|
481
|
+
["pending", "unknown"].includes(a.status),
|
|
482
|
+
)
|
|
483
|
+
.map((a) => a.snapshot.clientSubmissionId);
|
|
484
|
+
const pending = this.sendChat("sync", payload)
|
|
485
|
+
.catch((error) => {
|
|
486
|
+
if (
|
|
487
|
+
!this.dead &&
|
|
488
|
+
generation === this.state.instanceGeneration &&
|
|
489
|
+
this.syncPending === pending
|
|
490
|
+
) {
|
|
491
|
+
this.state = { ...this.state, error: error.message };
|
|
492
|
+
this.scheduleRender();
|
|
493
|
+
}
|
|
494
|
+
})
|
|
495
|
+
.finally(() => {
|
|
496
|
+
if (this.syncPending === pending) this.syncPending = null;
|
|
497
|
+
});
|
|
498
|
+
this.syncPending = pending;
|
|
499
|
+
return pending;
|
|
91
500
|
},
|
|
92
501
|
scheduleRender() {
|
|
93
502
|
if (!this.frame)
|
|
@@ -104,12 +513,14 @@ export function createDuskmoonReactChat(options = {}) {
|
|
|
104
513
|
connected: this.connected,
|
|
105
514
|
label: this.config.label,
|
|
106
515
|
options,
|
|
516
|
+
adapter: this.adapter,
|
|
107
517
|
}),
|
|
108
518
|
);
|
|
109
519
|
},
|
|
110
520
|
disconnected() {
|
|
111
521
|
this.connected = false;
|
|
112
522
|
this.requests.forEach((request) => request.cancel());
|
|
523
|
+
this.syncPending = null;
|
|
113
524
|
this.renderChat();
|
|
114
525
|
},
|
|
115
526
|
reconnected() {
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ComposerUncertainTransportError,
|
|
3
|
+
snapshotComposerReceipt,
|
|
4
|
+
snapshotComposerSubmission,
|
|
5
|
+
} from "@duskmoon-dev/components/chat";
|
|
6
|
+
|
|
7
|
+
export function requestId() {
|
|
8
|
+
if (typeof crypto.randomUUID === "function") return crypto.randomUUID();
|
|
9
|
+
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
|
10
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
|
11
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
|
12
|
+
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0"));
|
|
13
|
+
return [
|
|
14
|
+
hex.slice(0, 4),
|
|
15
|
+
hex.slice(4, 6),
|
|
16
|
+
hex.slice(6, 8),
|
|
17
|
+
hex.slice(8, 10),
|
|
18
|
+
hex.slice(10),
|
|
19
|
+
]
|
|
20
|
+
.map((part) => part.join(""))
|
|
21
|
+
.join("-");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Compatible with the upstream session controller's uncertain-transport boundary.
|
|
25
|
+
export class ChatTransportError extends ComposerUncertainTransportError {
|
|
26
|
+
constructor(message, correlation, cause) {
|
|
27
|
+
super(message, { cause });
|
|
28
|
+
this.name = "ChatTransportError";
|
|
29
|
+
this.outcome = "unknown";
|
|
30
|
+
Object.assign(this, correlation);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function unknownReceipt(snapshot, error) {
|
|
35
|
+
return Object.freeze({
|
|
36
|
+
protocolVersion: 1,
|
|
37
|
+
clientSubmissionId: snapshot.clientSubmissionId,
|
|
38
|
+
scope: snapshot.scope,
|
|
39
|
+
operation: snapshot.operation,
|
|
40
|
+
outcome: "unknown",
|
|
41
|
+
error: {
|
|
42
|
+
code: error.code || "uncertain_transport",
|
|
43
|
+
message: error.message,
|
|
44
|
+
...(error.request_id
|
|
45
|
+
? {
|
|
46
|
+
details: {
|
|
47
|
+
request_id: error.request_id,
|
|
48
|
+
instanceGeneration: error.instanceGeneration,
|
|
49
|
+
},
|
|
50
|
+
}
|
|
51
|
+
: {}),
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Reject binary handles before any JSON serialization, including the legacy path.
|
|
57
|
+
export function assertChatJSON(value, ancestors = new Set()) {
|
|
58
|
+
if (value == null || ["string", "boolean"].includes(typeof value)) return;
|
|
59
|
+
if (typeof value === "number" && Number.isFinite(value)) return;
|
|
60
|
+
if (typeof value !== "object" || ancestors.has(value))
|
|
61
|
+
throw new TypeError("Chat payload must contain portable JSON only");
|
|
62
|
+
if (
|
|
63
|
+
!Array.isArray(value) &&
|
|
64
|
+
Object.getPrototypeOf(value) !== Object.prototype &&
|
|
65
|
+
Object.getPrototypeOf(value) !== null
|
|
66
|
+
)
|
|
67
|
+
throw new TypeError(
|
|
68
|
+
"Upload File/Blob objects through an application adapter before submitting JSON",
|
|
69
|
+
);
|
|
70
|
+
ancestors.add(value);
|
|
71
|
+
for (const item of Object.values(value)) assertChatJSON(item, ancestors);
|
|
72
|
+
ancestors.delete(value);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function submissionSnapshot(payload, state) {
|
|
76
|
+
if (state.protocolVersion !== 1)
|
|
77
|
+
throw new TypeError("Structured composer protocol is not enabled");
|
|
78
|
+
const { request_id, ...submission } = payload;
|
|
79
|
+
assertChatJSON(submission);
|
|
80
|
+
const snapshot = snapshotComposerSubmission(submission);
|
|
81
|
+
if (
|
|
82
|
+
snapshot.scope.contextKey !== state.scope.contextKey ||
|
|
83
|
+
snapshot.scope.conversationId !== state.scope.conversationId
|
|
84
|
+
)
|
|
85
|
+
throw new TypeError("Submission scope does not match this chat instance");
|
|
86
|
+
return snapshot;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function structuredSnapshot(payload, state) {
|
|
90
|
+
const snapshot = submissionSnapshot(payload, state);
|
|
91
|
+
const caps = state.composer?.capabilities;
|
|
92
|
+
if (
|
|
93
|
+
caps?.protocolVersion !== 1 ||
|
|
94
|
+
!caps.documentVersions?.includes(snapshot.document.schemaVersion)
|
|
95
|
+
)
|
|
96
|
+
throw new TypeError("Unsupported composer document version");
|
|
97
|
+
if (
|
|
98
|
+
snapshot.operation === "command"
|
|
99
|
+
? caps.commands !== true
|
|
100
|
+
: !caps.intents?.includes(snapshot.intent)
|
|
101
|
+
)
|
|
102
|
+
throw new TypeError("Unsupported composer operation or intent");
|
|
103
|
+
if (snapshot.attachments.length && caps.attachments !== true)
|
|
104
|
+
throw new TypeError("Attachments are not supported by this application");
|
|
105
|
+
return snapshot;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function chatJSONKey(value) {
|
|
109
|
+
if (Array.isArray(value)) return `[${value.map(chatJSONKey).join(",")}]`;
|
|
110
|
+
if (value && typeof value === "object")
|
|
111
|
+
return `{${Object.keys(value)
|
|
112
|
+
.sort()
|
|
113
|
+
.map((key) => `${JSON.stringify(key)}:${chatJSONKey(value[key])}`)
|
|
114
|
+
.join(",")}}`;
|
|
115
|
+
return JSON.stringify(value);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function correlatedReceipt(response, snapshot, correlation) {
|
|
119
|
+
if (
|
|
120
|
+
response?.request_id !== correlation.request_id ||
|
|
121
|
+
response?.instanceGeneration !== correlation.instanceGeneration
|
|
122
|
+
)
|
|
123
|
+
throw new TypeError("Chat response correlation mismatch");
|
|
124
|
+
return snapshotComposerReceipt(response.receipt, snapshot);
|
|
125
|
+
}
|