phoenix_duskmoon 10.0.12 → 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 +29 -0
- 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 +19 -5
- 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-4291d9872af4341865a2a4ff6537d5ea.css +0 -2
- package/priv/static/assets/css/app-4291d9872af4341865a2a4ff6537d5ea.css.gz +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,32 @@
|
|
|
1
|
+
# [10.1.0](https://github.com/duskmoon-dev/phoenix-duskmoon-ui/compare/v10.0.12...v10.1.0) (2026-11-07)
|
|
2
|
+
|
|
3
|
+
### Added
|
|
4
|
+
|
|
5
|
+
- Add opt-in structured composer protocol v1 to React Chat, including semantic
|
|
6
|
+
documents, context-scoped projections, explicit admission receipts, uncertain
|
|
7
|
+
submission recovery, and queue removal through LiveView events.
|
|
8
|
+
- Demonstrate composer permissions, commands, steering, queued follow-ups, and
|
|
9
|
+
reconnect recovery in Storybook while preserving the legacy text submission API.
|
|
10
|
+
- Expose the optional rich editor through lazy imports, with packaged consumer
|
|
11
|
+
checks that keep Lexical out of ordinary chat and hooks bundles.
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
|
|
15
|
+
- Adopt Core/CSS Art 1.21.0, Elements/Art Elements 1.10.2, and React Components
|
|
16
|
+
0.6.0 with optional Lexical 0.53.0 peers.
|
|
17
|
+
- Update the published Bundler cohort to 10.1.2, the HTTP clients to 1.0.0-rc.5,
|
|
18
|
+
Gaoxy HTTP and its WebSock adapter to 0.15.1, and Tailwind to 4.3.5.
|
|
19
|
+
- Build Storybook images on Ubuntu 26.04 to meet the published native
|
|
20
|
+
toolchain's glibc 2.43 requirement.
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- Restore production HTTP admission in private cgroup namespaces by adopting
|
|
25
|
+
Gaoxy's mount visibility fix for overmounted memory controllers.
|
|
26
|
+
- Preserve React-owned values for disabled native form controls instead of
|
|
27
|
+
removing them when the browser omits disabled fields, resolving
|
|
28
|
+
[#210](https://github.com/duskmoon-dev/phoenix-duskmoon-ui/issues/210).
|
|
29
|
+
|
|
1
30
|
# [10.0.12](https://github.com/duskmoon-dev/phoenix-duskmoon-ui/compare/v10.0.11...v10.0.12) (2026-11-01)
|
|
2
31
|
|
|
3
32
|
### Fixed
|
|
@@ -3,6 +3,7 @@ import { Markdown } from "@duskmoon-dev/components/markdown";
|
|
|
3
3
|
import { Input } from "@duskmoon-dev/components/input";
|
|
4
4
|
import { Button } from "@duskmoon-dev/components/button";
|
|
5
5
|
import { Chat } from "@duskmoon-dev/components/chat";
|
|
6
|
+
import { requestId } from "./protocol.js";
|
|
6
7
|
const h = React.createElement;
|
|
7
8
|
const defaultOptions = {};
|
|
8
9
|
|
|
@@ -131,21 +132,6 @@ const Message = memo(function Message(props) {
|
|
|
131
132
|
);
|
|
132
133
|
});
|
|
133
134
|
|
|
134
|
-
function requestId() {
|
|
135
|
-
if (typeof crypto.randomUUID === "function") return crypto.randomUUID();
|
|
136
|
-
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
|
137
|
-
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
|
138
|
-
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
|
139
|
-
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0"));
|
|
140
|
-
return [
|
|
141
|
-
hex.slice(0, 4).join(""),
|
|
142
|
-
hex.slice(4, 6).join(""),
|
|
143
|
-
hex.slice(6, 8).join(""),
|
|
144
|
-
hex.slice(8, 10).join(""),
|
|
145
|
-
hex.slice(10).join(""),
|
|
146
|
-
].join("-");
|
|
147
|
-
}
|
|
148
|
-
|
|
149
135
|
// A composer owns its draft/attachments. This helper only owns admission and UI requests.
|
|
150
136
|
export function useChatSubmission({ bridge, canSubmit }) {
|
|
151
137
|
const [busy, setBusy] = useState(false);
|
|
@@ -165,6 +151,9 @@ export function useChatSubmission({ bridge, canSubmit }) {
|
|
|
165
151
|
setBusy(true);
|
|
166
152
|
setNotice("");
|
|
167
153
|
try {
|
|
154
|
+
const structured =
|
|
155
|
+
payload.protocolVersion !== undefined || payload.document !== undefined;
|
|
156
|
+
if (structured) return await bridge.submit(payload);
|
|
168
157
|
return await bridge.send("send", {
|
|
169
158
|
request_id: requestId(),
|
|
170
159
|
...payload,
|
|
@@ -237,6 +226,7 @@ export function ReactChat({
|
|
|
237
226
|
connected,
|
|
238
227
|
label,
|
|
239
228
|
options = defaultOptions,
|
|
229
|
+
adapter,
|
|
240
230
|
}) {
|
|
241
231
|
const transcriptId = useId();
|
|
242
232
|
const scroll = useRef(null);
|
|
@@ -258,6 +248,11 @@ export function ReactChat({
|
|
|
258
248
|
state,
|
|
259
249
|
bridge,
|
|
260
250
|
connected,
|
|
251
|
+
adapter,
|
|
252
|
+
scope: state.scope,
|
|
253
|
+
instanceGeneration: state.instanceGeneration,
|
|
254
|
+
composer: state.composer,
|
|
255
|
+
providers: adapter?.providers,
|
|
261
256
|
synchronizing: state.needsSync,
|
|
262
257
|
metadata: state.metadata,
|
|
263
258
|
runtime: state.runtime,
|
|
@@ -344,9 +339,13 @@ export function ReactChat({
|
|
|
344
339
|
: "Disconnected. Draft preserved.",
|
|
345
340
|
),
|
|
346
341
|
options.renderStatus && h(options.renderStatus, context),
|
|
342
|
+
state.error && h("p", { role: "alert" }, state.error),
|
|
347
343
|
h(options.Composer || DefaultComposer, {
|
|
348
344
|
...context,
|
|
349
|
-
key:
|
|
345
|
+
key:
|
|
346
|
+
state.protocolVersion === 1
|
|
347
|
+
? state.instanceGeneration
|
|
348
|
+
: state.conversationId,
|
|
350
349
|
}),
|
|
351
350
|
);
|
|
352
351
|
}
|
|
@@ -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() {
|