@richardmcquiston01/chat-and-react-core 0.5.0 → 0.6.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/LICENSE +201 -201
- package/dist/index.cjs +434 -75
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +208 -41
- package/dist/index.d.ts +208 -41
- package/dist/index.mjs +436 -75
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -1
package/dist/index.mjs
CHANGED
|
@@ -1,3 +1,53 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import { localStorageAdapter } from "@richardmcquiston01/storage-adapters/browser";
|
|
3
|
+
|
|
4
|
+
// src/adapters/browser-event-adapter.ts
|
|
5
|
+
var BrowserEventAdapter = class {
|
|
6
|
+
emit(event) {
|
|
7
|
+
if (typeof document === "undefined") return;
|
|
8
|
+
const name = event.type === "page:submit" ? "chat-page-submit" : "chat-form-complete";
|
|
9
|
+
document.dispatchEvent(new CustomEvent(name, { detail: event, bubbles: true }));
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
// src/adapters/console-error-adapter.ts
|
|
14
|
+
import {
|
|
15
|
+
ConsoleErrorAdapter as BaseConsoleErrorAdapter
|
|
16
|
+
} from "@richardmcquiston01/error-log-adapter";
|
|
17
|
+
var ConsoleErrorAdapter = class extends BaseConsoleErrorAdapter {
|
|
18
|
+
constructor(options = {}) {
|
|
19
|
+
super({ prefix: "[chat-and-react]", ...options });
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
// src/adapters/identity-input-adapter.ts
|
|
24
|
+
var IdentityInputAdapter = class {
|
|
25
|
+
transform(raw) {
|
|
26
|
+
return raw;
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// src/rule-engine.ts
|
|
31
|
+
import jsonLogic from "json-logic-js";
|
|
32
|
+
var RuleEngine = class {
|
|
33
|
+
/**
|
|
34
|
+
* Evaluates a JSON Logic rule against an answer map.
|
|
35
|
+
* @param rule The JSON Logic rule to evaluate
|
|
36
|
+
* @param answers The answer map to evaluate the rule against
|
|
37
|
+
* @returns The boolean result of the rule evaluation
|
|
38
|
+
* @throws {{ code: 'RULE_EVAL_FAILED', message: string, cause: unknown }} if rule evaluation fails
|
|
39
|
+
*/
|
|
40
|
+
evaluate(rule, answers) {
|
|
41
|
+
try {
|
|
42
|
+
return Boolean(
|
|
43
|
+
jsonLogic.apply(rule, answers)
|
|
44
|
+
);
|
|
45
|
+
} catch (cause) {
|
|
46
|
+
throw { code: "RULE_EVAL_FAILED", message: "Rule evaluation failed", cause };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
1
51
|
// src/schema-parser.ts
|
|
2
52
|
var VALID_INPUTS = /* @__PURE__ */ new Set(["text", "textarea", "dropdown", "radio", "checkbox"]);
|
|
3
53
|
var FORM_COMPLETED = "**FORM_COMPLETED**";
|
|
@@ -85,9 +135,7 @@ function validateQuestion(raw, pageId, questionIds, rules) {
|
|
|
85
135
|
const c = condition;
|
|
86
136
|
if ("ref" in c && typeof c["ref"] === "string") {
|
|
87
137
|
if (!(c["ref"] in rules)) {
|
|
88
|
-
throw makeError(
|
|
89
|
-
`Question "${q["id"]}" x-chat-condition refs unknown rule "${c["ref"]}"`
|
|
90
|
-
);
|
|
138
|
+
throw makeError(`Question "${q["id"]}" x-chat-condition refs unknown rule "${c["ref"]}"`);
|
|
91
139
|
}
|
|
92
140
|
}
|
|
93
141
|
}
|
|
@@ -96,25 +144,6 @@ function makeError(message) {
|
|
|
96
144
|
return { code: "SCHEMA_INVALID", message };
|
|
97
145
|
}
|
|
98
146
|
|
|
99
|
-
// src/rule-engine.ts
|
|
100
|
-
import jsonLogic from "json-logic-js";
|
|
101
|
-
var RuleEngine = class {
|
|
102
|
-
/**
|
|
103
|
-
* Evaluates a JSON Logic rule against an answer map.
|
|
104
|
-
* @param rule The JSON Logic rule to evaluate
|
|
105
|
-
* @param answers The answer map to evaluate the rule against
|
|
106
|
-
* @returns The boolean result of the rule evaluation
|
|
107
|
-
* @throws {{ code: 'RULE_EVAL_FAILED', message: string, cause: unknown }} if rule evaluation fails
|
|
108
|
-
*/
|
|
109
|
-
evaluate(rule, answers) {
|
|
110
|
-
try {
|
|
111
|
-
return Boolean(jsonLogic.apply(rule, answers));
|
|
112
|
-
} catch (cause) {
|
|
113
|
-
throw { code: "RULE_EVAL_FAILED", message: "Rule evaluation failed", cause };
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
};
|
|
117
|
-
|
|
118
147
|
// src/session-manager.ts
|
|
119
148
|
var KEY_SESSION = "car:session-id";
|
|
120
149
|
var keyProgress = (id) => `car:progress:${id}`;
|
|
@@ -133,10 +162,7 @@ var SessionManager = class {
|
|
|
133
162
|
}
|
|
134
163
|
saveProgress(currentPageId, answers) {
|
|
135
164
|
if (!this.available) return;
|
|
136
|
-
this.storage.setItem(
|
|
137
|
-
keyProgress(this.sessionId),
|
|
138
|
-
JSON.stringify({ currentPageId, answers })
|
|
139
|
-
);
|
|
165
|
+
this.storage.setItem(keyProgress(this.sessionId), JSON.stringify({ currentPageId, answers }));
|
|
140
166
|
}
|
|
141
167
|
loadProgress() {
|
|
142
168
|
if (!this.available) return null;
|
|
@@ -150,10 +176,7 @@ var SessionManager = class {
|
|
|
150
176
|
}
|
|
151
177
|
saveInput(questionId, value) {
|
|
152
178
|
if (!this.available) return;
|
|
153
|
-
this.storage.setItem(
|
|
154
|
-
keyInput(this.sessionId, questionId),
|
|
155
|
-
JSON.stringify(value)
|
|
156
|
-
);
|
|
179
|
+
this.storage.setItem(keyInput(this.sessionId, questionId), JSON.stringify(value));
|
|
157
180
|
}
|
|
158
181
|
loadInput(questionId) {
|
|
159
182
|
if (!this.available) return null;
|
|
@@ -198,35 +221,25 @@ var SessionManager = class {
|
|
|
198
221
|
}
|
|
199
222
|
};
|
|
200
223
|
|
|
201
|
-
// src/adapters/identity-input-adapter.ts
|
|
202
|
-
var IdentityInputAdapter = class {
|
|
203
|
-
transform(raw) {
|
|
204
|
-
return raw;
|
|
205
|
-
}
|
|
206
|
-
};
|
|
207
|
-
|
|
208
|
-
// src/adapters/browser-event-adapter.ts
|
|
209
|
-
var BrowserEventAdapter = class {
|
|
210
|
-
emit(event) {
|
|
211
|
-
if (typeof document === "undefined") return;
|
|
212
|
-
const name = event.type === "page:submit" ? "chat-page-submit" : "chat-form-complete";
|
|
213
|
-
document.dispatchEvent(new CustomEvent(name, { detail: event, bubbles: true }));
|
|
214
|
-
}
|
|
215
|
-
};
|
|
216
|
-
|
|
217
|
-
// src/adapters/console-error-adapter.ts
|
|
218
|
-
var ConsoleErrorAdapter = class {
|
|
219
|
-
log(error) {
|
|
220
|
-
console.error("[chat-and-react]", error);
|
|
221
|
-
}
|
|
222
|
-
};
|
|
223
|
-
|
|
224
224
|
// src/chat-controller.ts
|
|
225
225
|
var DEFAULT_OPTIONS = {
|
|
226
226
|
userResumable: true,
|
|
227
227
|
localStorage: true,
|
|
228
|
-
autoComplete: true
|
|
228
|
+
autoComplete: true,
|
|
229
|
+
allowEdit: false,
|
|
230
|
+
autoHideQuestions: 0,
|
|
231
|
+
autoHideAnswers: 0,
|
|
232
|
+
hideAnimation: "fade",
|
|
233
|
+
typingSpeed: 0,
|
|
234
|
+
thinkingDelay: 0,
|
|
235
|
+
thinkingText: "",
|
|
236
|
+
showContinueIcon: true
|
|
229
237
|
};
|
|
238
|
+
function stripUndefined(source) {
|
|
239
|
+
return Object.fromEntries(
|
|
240
|
+
Object.entries(source).filter(([, value]) => value !== void 0)
|
|
241
|
+
);
|
|
242
|
+
}
|
|
230
243
|
var ChatController = class {
|
|
231
244
|
/**
|
|
232
245
|
* @param config - Controller configuration including schema and optional adapters.
|
|
@@ -239,12 +252,19 @@ var ChatController = class {
|
|
|
239
252
|
this.currentPage = null;
|
|
240
253
|
this.allAnswers = {};
|
|
241
254
|
this.visitIndex = 0;
|
|
255
|
+
/** Pages submitted in this session, in visit order. Used for editing earlier answers. */
|
|
256
|
+
this.route = [];
|
|
257
|
+
/** Index in `route` of the page being edited, or null when not editing. */
|
|
258
|
+
this.editIndex = null;
|
|
259
|
+
/** The page the user was on before they started editing an earlier one. */
|
|
260
|
+
this.resumePageId = null;
|
|
242
261
|
this.listeners = /* @__PURE__ */ new Map();
|
|
243
262
|
this.config = {
|
|
244
263
|
inputAdapter: config.inputAdapter ?? new IdentityInputAdapter(),
|
|
245
264
|
outputAdapter: config.outputAdapter ?? new BrowserEventAdapter(),
|
|
246
265
|
errorAdapter: config.errorAdapter ?? new ConsoleErrorAdapter(),
|
|
247
|
-
schema: config.schema
|
|
266
|
+
schema: config.schema,
|
|
267
|
+
options: config.options ?? {}
|
|
248
268
|
};
|
|
249
269
|
this.session = sessionManager ?? new SessionManager();
|
|
250
270
|
}
|
|
@@ -259,27 +279,29 @@ var ChatController = class {
|
|
|
259
279
|
} catch (cause) {
|
|
260
280
|
this.handleFatal({ code: "ADAPTER_ERROR", message: "InputAdapter.transform() threw", cause });
|
|
261
281
|
}
|
|
282
|
+
let schema;
|
|
262
283
|
try {
|
|
263
|
-
|
|
284
|
+
schema = parseSchema(raw);
|
|
264
285
|
} catch (e) {
|
|
265
286
|
this.handleFatal(e);
|
|
266
287
|
}
|
|
288
|
+
this.schema = schema;
|
|
267
289
|
if (!this.session.isStorageAvailable) {
|
|
268
|
-
this.
|
|
290
|
+
this.logError({
|
|
269
291
|
code: "STORAGE_UNAVAILABLE",
|
|
270
292
|
message: "localStorage unavailable; running in memory-only mode"
|
|
271
293
|
});
|
|
272
294
|
}
|
|
273
|
-
const schemaOpts =
|
|
295
|
+
const schemaOpts = schema["x-chat-options"] ?? {};
|
|
274
296
|
this.options = {
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
297
|
+
...DEFAULT_OPTIONS,
|
|
298
|
+
...stripUndefined(schemaOpts),
|
|
299
|
+
...stripUndefined(this.config.options)
|
|
278
300
|
};
|
|
279
301
|
if (this.options.userResumable) {
|
|
280
302
|
const saved = this.session.loadProgress();
|
|
281
303
|
if (saved) {
|
|
282
|
-
const page =
|
|
304
|
+
const page = schema["x-chat-pages"].find((p) => p.id === saved.currentPageId);
|
|
283
305
|
if (page) {
|
|
284
306
|
this.allAnswers = saved.answers;
|
|
285
307
|
this.currentPage = page;
|
|
@@ -287,7 +309,7 @@ var ChatController = class {
|
|
|
287
309
|
}
|
|
288
310
|
}
|
|
289
311
|
}
|
|
290
|
-
this.currentPage =
|
|
312
|
+
this.currentPage = schema["x-chat-pages"][0];
|
|
291
313
|
}
|
|
292
314
|
/**
|
|
293
315
|
* Returns the page currently being presented to the user, or null if complete.
|
|
@@ -309,6 +331,38 @@ var ChatController = class {
|
|
|
309
331
|
getOptions() {
|
|
310
332
|
return { ...this.options };
|
|
311
333
|
}
|
|
334
|
+
/**
|
|
335
|
+
* Returns the pages the user has submitted in this session, in visit order.
|
|
336
|
+
* A session restored from storage starts with an empty route.
|
|
337
|
+
*/
|
|
338
|
+
getRoute() {
|
|
339
|
+
return this.route.map((entry) => ({ pageId: entry.pageId, answers: { ...entry.answers } }));
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Returns every answer given so far, for pre-filling inputs.
|
|
343
|
+
*/
|
|
344
|
+
getAnswers() {
|
|
345
|
+
return { ...this.allAnswers };
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* Re-opens a page the user already submitted so its answers can be changed.
|
|
349
|
+
* Call `submitPage` afterwards to save the new answers. Not available once the
|
|
350
|
+
* form is complete.
|
|
351
|
+
*
|
|
352
|
+
* @param routeIndex - Position of the page in `getRoute()`.
|
|
353
|
+
* @returns true when the page was re-opened.
|
|
354
|
+
*/
|
|
355
|
+
reopenPage(routeIndex) {
|
|
356
|
+
if (!this.schema || !this.currentPage) return false;
|
|
357
|
+
const entry = this.route[routeIndex];
|
|
358
|
+
if (!entry) return false;
|
|
359
|
+
const page = this.schema["x-chat-pages"].find((p) => p.id === entry.pageId);
|
|
360
|
+
if (!page) return false;
|
|
361
|
+
if (this.editIndex === null) this.resumePageId = this.currentPage.id;
|
|
362
|
+
this.editIndex = routeIndex;
|
|
363
|
+
this.currentPage = page;
|
|
364
|
+
return true;
|
|
365
|
+
}
|
|
312
366
|
/**
|
|
313
367
|
* Submits the answers for the current page and advances the session.
|
|
314
368
|
* - Accumulates answers into `allAnswers`.
|
|
@@ -316,28 +370,67 @@ var ChatController = class {
|
|
|
316
370
|
* - Resolves branching to find the next page.
|
|
317
371
|
* - Emits `form:complete` and clears session when `**FORM_COMPLETED**` is reached.
|
|
318
372
|
*
|
|
373
|
+
* When the page was re-opened with `reopenPage`, the new answers replace the
|
|
374
|
+
* old ones and `page:submit` is emitted with `edited: true`. If the page's
|
|
375
|
+
* branching still leads to the same next page, the form returns to the page the
|
|
376
|
+
* user was on. If it leads somewhere else, the pages that followed it are
|
|
377
|
+
* dropped, along with their answers, and the form continues from the new page.
|
|
378
|
+
*
|
|
319
379
|
* @param pageAnswers - Answers collected for the current page.
|
|
320
380
|
*/
|
|
321
381
|
submitPage(pageAnswers) {
|
|
322
|
-
if (!this.currentPage || !this.schema) return;
|
|
323
|
-
this.allAnswers = { ...this.allAnswers, ...pageAnswers };
|
|
382
|
+
if (!this.currentPage || !this.schema) return "advanced";
|
|
324
383
|
const pageId = this.currentPage.id;
|
|
325
384
|
const currentVisitIndex = this.visitIndex;
|
|
326
|
-
const
|
|
385
|
+
const editedIndex = this.editIndex;
|
|
386
|
+
const isEdit = editedIndex !== null;
|
|
387
|
+
let outcome = "advanced";
|
|
388
|
+
let nextPageId;
|
|
389
|
+
let resumePageId = null;
|
|
390
|
+
if (isEdit) {
|
|
391
|
+
const entry = this.route[editedIndex];
|
|
392
|
+
const remaining = { ...this.allAnswers };
|
|
393
|
+
for (const questionId of Object.keys(entry.answers)) delete remaining[questionId];
|
|
394
|
+
this.allAnswers = { ...remaining, ...pageAnswers };
|
|
395
|
+
entry.answers = { ...pageAnswers };
|
|
396
|
+
nextPageId = this.resolveBranching(this.currentPage, this.allAnswers);
|
|
397
|
+
const expectedNextPageId = this.route[editedIndex + 1]?.pageId ?? this.resumePageId;
|
|
398
|
+
if (nextPageId === expectedNextPageId) {
|
|
399
|
+
outcome = "edit-resumed";
|
|
400
|
+
resumePageId = this.resumePageId;
|
|
401
|
+
} else {
|
|
402
|
+
outcome = "edit-rerouted";
|
|
403
|
+
for (const later of this.route.slice(editedIndex + 1)) {
|
|
404
|
+
for (const questionId of Object.keys(later.answers)) delete this.allAnswers[questionId];
|
|
405
|
+
}
|
|
406
|
+
this.route.length = editedIndex + 1;
|
|
407
|
+
}
|
|
408
|
+
this.editIndex = null;
|
|
409
|
+
this.resumePageId = null;
|
|
410
|
+
} else {
|
|
411
|
+
this.allAnswers = { ...this.allAnswers, ...pageAnswers };
|
|
412
|
+
this.route.push({ pageId, answers: { ...pageAnswers } });
|
|
413
|
+
nextPageId = this.resolveBranching(this.currentPage, this.allAnswers);
|
|
414
|
+
}
|
|
327
415
|
if (this.options.userResumable) {
|
|
328
|
-
const progressPageId = nextPageId === "**FORM_COMPLETED**" ? pageId : nextPageId;
|
|
416
|
+
const progressPageId = outcome === "edit-resumed" && resumePageId ? resumePageId : nextPageId === "**FORM_COMPLETED**" ? pageId : nextPageId;
|
|
329
417
|
this.session.saveProgress(progressPageId, this.allAnswers);
|
|
330
418
|
const submitEvent = {
|
|
331
419
|
type: "page:submit",
|
|
332
420
|
sessionId: this.session.getSessionId(),
|
|
333
421
|
pageId,
|
|
334
422
|
visitIndex: currentVisitIndex,
|
|
335
|
-
answers: pageAnswers
|
|
423
|
+
answers: pageAnswers,
|
|
424
|
+
...isEdit ? { edited: true } : {}
|
|
336
425
|
};
|
|
337
426
|
this.safeEmit(submitEvent);
|
|
338
427
|
this.emit("page:submit", submitEvent);
|
|
339
428
|
}
|
|
340
429
|
this.visitIndex++;
|
|
430
|
+
if (outcome === "edit-resumed" && resumePageId) {
|
|
431
|
+
this.currentPage = this.schema["x-chat-pages"].find((p) => p.id === resumePageId) ?? null;
|
|
432
|
+
return outcome;
|
|
433
|
+
}
|
|
341
434
|
if (nextPageId === "**FORM_COMPLETED**") {
|
|
342
435
|
this.session.clearAll();
|
|
343
436
|
const completeEvent = {
|
|
@@ -351,6 +444,7 @@ var ChatController = class {
|
|
|
351
444
|
} else {
|
|
352
445
|
this.currentPage = this.schema["x-chat-pages"].find((p) => p.id === nextPageId) ?? null;
|
|
353
446
|
}
|
|
447
|
+
return outcome;
|
|
354
448
|
}
|
|
355
449
|
/**
|
|
356
450
|
* Registers an event listener for the given event type.
|
|
@@ -359,8 +453,12 @@ var ChatController = class {
|
|
|
359
453
|
* @param listener - The callback to invoke when the event fires.
|
|
360
454
|
*/
|
|
361
455
|
on(event, listener) {
|
|
362
|
-
|
|
363
|
-
|
|
456
|
+
let listenersForEvent = this.listeners.get(event);
|
|
457
|
+
if (!listenersForEvent) {
|
|
458
|
+
listenersForEvent = /* @__PURE__ */ new Set();
|
|
459
|
+
this.listeners.set(event, listenersForEvent);
|
|
460
|
+
}
|
|
461
|
+
listenersForEvent.add(listener);
|
|
364
462
|
}
|
|
365
463
|
/**
|
|
366
464
|
* Removes a previously registered event listener.
|
|
@@ -375,6 +473,7 @@ var ChatController = class {
|
|
|
375
473
|
* Tears down the controller: removes all listeners and nulls internal state.
|
|
376
474
|
*/
|
|
377
475
|
destroy() {
|
|
476
|
+
this.session.dispose?.();
|
|
378
477
|
this.listeners.clear();
|
|
379
478
|
this.currentPage = null;
|
|
380
479
|
this.schema = null;
|
|
@@ -396,12 +495,24 @@ var ChatController = class {
|
|
|
396
495
|
rule = condition;
|
|
397
496
|
}
|
|
398
497
|
try {
|
|
399
|
-
return this.ruleEngine.evaluate(rule, this.
|
|
498
|
+
return this.ruleEngine.evaluate(rule, this.answersForRules());
|
|
400
499
|
} catch (e) {
|
|
401
|
-
this.
|
|
500
|
+
this.logError(e);
|
|
402
501
|
return false;
|
|
403
502
|
}
|
|
404
503
|
}
|
|
504
|
+
/**
|
|
505
|
+
* The answers question conditions are checked against. While an earlier page is
|
|
506
|
+
* being edited, that page's own answers are left out, so its questions are
|
|
507
|
+
* shown exactly as they were the first time the page was asked.
|
|
508
|
+
*/
|
|
509
|
+
answersForRules() {
|
|
510
|
+
if (this.editIndex === null) return this.allAnswers;
|
|
511
|
+
const rest = { ...this.allAnswers };
|
|
512
|
+
for (const questionId of Object.keys(this.route[this.editIndex].answers))
|
|
513
|
+
delete rest[questionId];
|
|
514
|
+
return rest;
|
|
515
|
+
}
|
|
405
516
|
/**
|
|
406
517
|
* Walks the branching rules top-to-bottom. Returns the first match's destination_id.
|
|
407
518
|
* Falls back to `**FORM_COMPLETED**` if nothing matches (should not happen with valid schemas).
|
|
@@ -422,7 +533,7 @@ var ChatController = class {
|
|
|
422
533
|
try {
|
|
423
534
|
this.config.outputAdapter.emit(event);
|
|
424
535
|
} catch (cause) {
|
|
425
|
-
this.
|
|
536
|
+
this.logError({
|
|
426
537
|
code: "ADAPTER_ERROR",
|
|
427
538
|
message: `OutputAdapter.emit() threw for ${event.type}`,
|
|
428
539
|
cause
|
|
@@ -433,24 +544,274 @@ var ChatController = class {
|
|
|
433
544
|
* Notifies all registered listeners for the given event.
|
|
434
545
|
*/
|
|
435
546
|
emit(event, payload) {
|
|
436
|
-
this.listeners.get(event)?.forEach((l) =>
|
|
547
|
+
this.listeners.get(event)?.forEach((l) => {
|
|
548
|
+
l(payload);
|
|
549
|
+
});
|
|
437
550
|
}
|
|
438
551
|
/**
|
|
439
552
|
* Logs the error via the error adapter and then throws it.
|
|
440
553
|
* Used for unrecoverable errors (schema invalid, adapter failure on start).
|
|
441
554
|
*/
|
|
442
555
|
handleFatal(error) {
|
|
443
|
-
this.
|
|
556
|
+
this.logError(error);
|
|
444
557
|
throw error;
|
|
445
558
|
}
|
|
559
|
+
/**
|
|
560
|
+
* Sends the error to the error adapter without waiting for it. If the adapter
|
|
561
|
+
* reports a failure, rejects, or throws, that is written to `console.error` so
|
|
562
|
+
* the original error is never lost and the form keeps running.
|
|
563
|
+
*/
|
|
564
|
+
logError(entry) {
|
|
565
|
+
const reportFailure = (reason) => {
|
|
566
|
+
console.error("[chat-and-react] ErrorLogAdapter failed to record an error:", reason, entry);
|
|
567
|
+
};
|
|
568
|
+
try {
|
|
569
|
+
void Promise.resolve(this.config.errorAdapter.log(entry)).then(
|
|
570
|
+
(result) => {
|
|
571
|
+
if (result?.ok === false) {
|
|
572
|
+
reportFailure(result.error);
|
|
573
|
+
}
|
|
574
|
+
},
|
|
575
|
+
reportFailure
|
|
576
|
+
);
|
|
577
|
+
} catch (cause) {
|
|
578
|
+
reportFailure(cause);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
};
|
|
582
|
+
|
|
583
|
+
// src/storage/adapter-session-manager.ts
|
|
584
|
+
var KEY_SESSION2 = "car:session-id";
|
|
585
|
+
var keyRecord = (sessionId) => `car:session:${sessionId}`;
|
|
586
|
+
var MAX_CONFLICT_RETRIES = 3;
|
|
587
|
+
var emptyRecord = () => ({ progress: null, inputs: {} });
|
|
588
|
+
function parseRecord(raw) {
|
|
589
|
+
try {
|
|
590
|
+
const parsed = JSON.parse(raw);
|
|
591
|
+
return {
|
|
592
|
+
progress: parsed.progress ?? null,
|
|
593
|
+
inputs: parsed.inputs && typeof parsed.inputs === "object" ? parsed.inputs : {}
|
|
594
|
+
};
|
|
595
|
+
} catch {
|
|
596
|
+
return null;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
var AdapterSessionManager = class _AdapterSessionManager {
|
|
600
|
+
constructor(adapter, sessionId, available, initial, options) {
|
|
601
|
+
this.adapter = adapter;
|
|
602
|
+
this.sessionId = sessionId;
|
|
603
|
+
this.available = available;
|
|
604
|
+
this.options = options;
|
|
605
|
+
this.dirtyInputs = /* @__PURE__ */ new Set();
|
|
606
|
+
this.dirtyProgress = false;
|
|
607
|
+
this.pending = false;
|
|
608
|
+
this.persisting = null;
|
|
609
|
+
this.signal = new AbortController().signal;
|
|
610
|
+
this.unsubscribe = null;
|
|
611
|
+
const parsed = initial ? parseRecord(initial.value) : null;
|
|
612
|
+
this.record = parsed ?? emptyRecord();
|
|
613
|
+
this.revision = initial?.revision;
|
|
614
|
+
if (initial && !parsed) {
|
|
615
|
+
this.report(
|
|
616
|
+
`Saved session ${sessionId} could not be read as JSON; starting with an empty session`
|
|
617
|
+
);
|
|
618
|
+
}
|
|
619
|
+
if (available) {
|
|
620
|
+
this.unsubscribe = adapter.subscribe?.(keyRecord(sessionId), (stored) => this.applyRemote(stored)) ?? null;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
/** Loads (or creates) the session id and its saved record, then returns the manager. */
|
|
624
|
+
static async create(adapter, options = {}) {
|
|
625
|
+
const signal = new AbortController().signal;
|
|
626
|
+
const fail = (message) => options.onError?.({ code: "STORAGE_UNAVAILABLE", message });
|
|
627
|
+
let sessionId = null;
|
|
628
|
+
let available = true;
|
|
629
|
+
const idResult = await adapter.get(KEY_SESSION2, { signal });
|
|
630
|
+
if (idResult.ok) {
|
|
631
|
+
sessionId = idResult.value?.value ?? null;
|
|
632
|
+
} else {
|
|
633
|
+
available = false;
|
|
634
|
+
fail(`Could not read the session id: ${idResult.error}. Running in memory-only mode`);
|
|
635
|
+
}
|
|
636
|
+
if (available && sessionId === null) {
|
|
637
|
+
sessionId = crypto.randomUUID();
|
|
638
|
+
const saved = await adapter.set(KEY_SESSION2, sessionId, {
|
|
639
|
+
signal
|
|
640
|
+
});
|
|
641
|
+
if (!saved.ok) {
|
|
642
|
+
available = false;
|
|
643
|
+
fail(`Could not save the session id: ${saved.error}. Running in memory-only mode`);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
const id = sessionId ?? crypto.randomUUID();
|
|
647
|
+
let initial = null;
|
|
648
|
+
if (available) {
|
|
649
|
+
const recordResult = await adapter.get(keyRecord(id), {
|
|
650
|
+
signal
|
|
651
|
+
});
|
|
652
|
+
if (recordResult.ok) {
|
|
653
|
+
initial = recordResult.value;
|
|
654
|
+
} else {
|
|
655
|
+
available = false;
|
|
656
|
+
fail(`Could not read saved progress: ${recordResult.error}. Running in memory-only mode`);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
return new _AdapterSessionManager(adapter, id, available, initial, options);
|
|
660
|
+
}
|
|
661
|
+
getSessionId() {
|
|
662
|
+
return this.sessionId;
|
|
663
|
+
}
|
|
664
|
+
get isStorageAvailable() {
|
|
665
|
+
return this.available;
|
|
666
|
+
}
|
|
667
|
+
saveProgress(currentPageId, answers) {
|
|
668
|
+
this.record.progress = { currentPageId, answers };
|
|
669
|
+
this.dirtyProgress = true;
|
|
670
|
+
this.schedulePersist();
|
|
671
|
+
}
|
|
672
|
+
loadProgress() {
|
|
673
|
+
return this.record.progress;
|
|
674
|
+
}
|
|
675
|
+
saveInput(questionId, value) {
|
|
676
|
+
this.record.inputs[questionId] = value;
|
|
677
|
+
this.dirtyInputs.add(questionId);
|
|
678
|
+
this.schedulePersist();
|
|
679
|
+
}
|
|
680
|
+
loadInput(questionId) {
|
|
681
|
+
return this.record.inputs[questionId] ?? null;
|
|
682
|
+
}
|
|
683
|
+
clearAll() {
|
|
684
|
+
this.record = emptyRecord();
|
|
685
|
+
this.dirtyInputs.clear();
|
|
686
|
+
this.dirtyProgress = false;
|
|
687
|
+
this.pending = false;
|
|
688
|
+
if (!this.available) return;
|
|
689
|
+
void this.removeRecord();
|
|
690
|
+
}
|
|
691
|
+
dispose() {
|
|
692
|
+
this.unsubscribe?.();
|
|
693
|
+
this.unsubscribe = null;
|
|
694
|
+
}
|
|
695
|
+
/** Resolves when every save made so far has finished. Mostly for tests. */
|
|
696
|
+
async flush() {
|
|
697
|
+
while (this.persisting) {
|
|
698
|
+
await this.persisting;
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
report(message, cause) {
|
|
702
|
+
this.options.onError?.({ code: "STORAGE_UNAVAILABLE", message, cause });
|
|
703
|
+
}
|
|
704
|
+
schedulePersist() {
|
|
705
|
+
if (!this.available) return;
|
|
706
|
+
this.pending = true;
|
|
707
|
+
this.persisting ?? (this.persisting = this.drain());
|
|
708
|
+
}
|
|
709
|
+
/** Saves until nothing is left to save. Never rejects. */
|
|
710
|
+
async drain() {
|
|
711
|
+
let conflicts = 0;
|
|
712
|
+
try {
|
|
713
|
+
while (this.pending) {
|
|
714
|
+
this.pending = false;
|
|
715
|
+
const outcome = await this.persistOnce();
|
|
716
|
+
conflicts = outcome === "conflict" ? conflicts + 1 : 0;
|
|
717
|
+
if (outcome === "conflict") {
|
|
718
|
+
if (conflicts >= MAX_CONFLICT_RETRIES) {
|
|
719
|
+
this.report(
|
|
720
|
+
`Gave up saving session ${this.sessionId} after ${conflicts} revision conflicts in a row`
|
|
721
|
+
);
|
|
722
|
+
break;
|
|
723
|
+
}
|
|
724
|
+
this.pending = true;
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
} finally {
|
|
728
|
+
this.persisting = null;
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
async persistOnce() {
|
|
732
|
+
const sentInputs = new Set(this.dirtyInputs);
|
|
733
|
+
const sentProgress = this.dirtyProgress;
|
|
734
|
+
this.dirtyInputs.clear();
|
|
735
|
+
this.dirtyProgress = false;
|
|
736
|
+
const result = await this.adapter.set(
|
|
737
|
+
keyRecord(this.sessionId),
|
|
738
|
+
JSON.stringify(this.record),
|
|
739
|
+
{ signal: this.signal, ifRevision: this.revision }
|
|
740
|
+
);
|
|
741
|
+
if (result.ok) {
|
|
742
|
+
this.revision = result.value.revision;
|
|
743
|
+
return "saved";
|
|
744
|
+
}
|
|
745
|
+
for (const questionId of sentInputs) this.dirtyInputs.add(questionId);
|
|
746
|
+
this.dirtyProgress = this.dirtyProgress || sentProgress;
|
|
747
|
+
if (result.code !== "conflict") {
|
|
748
|
+
this.report(`Could not save session ${this.sessionId}: ${result.error}`);
|
|
749
|
+
return "failed";
|
|
750
|
+
}
|
|
751
|
+
const latest = await this.adapter.get(
|
|
752
|
+
keyRecord(this.sessionId),
|
|
753
|
+
{ signal: this.signal }
|
|
754
|
+
);
|
|
755
|
+
if (!latest.ok) {
|
|
756
|
+
this.report(
|
|
757
|
+
`Session ${this.sessionId} changed elsewhere, but the new version could not be read: ${latest.error}`
|
|
758
|
+
);
|
|
759
|
+
return "failed";
|
|
760
|
+
}
|
|
761
|
+
this.applyRemote(latest.value);
|
|
762
|
+
return "conflict";
|
|
763
|
+
}
|
|
764
|
+
/** Merges a version written elsewhere with this tab's unsaved changes. */
|
|
765
|
+
applyRemote(stored) {
|
|
766
|
+
const before = JSON.stringify(this.record.progress);
|
|
767
|
+
if (!stored) {
|
|
768
|
+
if (!this.dirtyProgress && this.dirtyInputs.size === 0) {
|
|
769
|
+
this.record = emptyRecord();
|
|
770
|
+
}
|
|
771
|
+
this.revision = void 0;
|
|
772
|
+
} else {
|
|
773
|
+
const remote = parseRecord(stored.value);
|
|
774
|
+
if (!remote) {
|
|
775
|
+
this.report(
|
|
776
|
+
`Session ${this.sessionId} was changed elsewhere to a value that is not valid JSON; ignoring it`
|
|
777
|
+
);
|
|
778
|
+
this.revision = stored.revision;
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
const inputs = { ...remote.inputs };
|
|
782
|
+
for (const questionId of this.dirtyInputs) {
|
|
783
|
+
const mine = this.record.inputs[questionId];
|
|
784
|
+
if (mine !== void 0) inputs[questionId] = mine;
|
|
785
|
+
}
|
|
786
|
+
this.record = {
|
|
787
|
+
progress: this.dirtyProgress ? this.record.progress : remote.progress,
|
|
788
|
+
inputs
|
|
789
|
+
};
|
|
790
|
+
this.revision = stored.revision;
|
|
791
|
+
}
|
|
792
|
+
if (JSON.stringify(this.record.progress) !== before) {
|
|
793
|
+
this.options.onExternalChange?.(this.record.progress);
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
async removeRecord() {
|
|
797
|
+
const key = keyRecord(this.sessionId);
|
|
798
|
+
const result = this.adapter.remove ? await this.adapter.remove(key, { signal: this.signal }) : await this.adapter.set(key, JSON.stringify(emptyRecord()), { signal: this.signal });
|
|
799
|
+
if (result.ok) {
|
|
800
|
+
this.revision = void 0;
|
|
801
|
+
} else {
|
|
802
|
+
this.report(`Could not clear session ${this.sessionId}: ${result.error}`);
|
|
803
|
+
}
|
|
804
|
+
}
|
|
446
805
|
};
|
|
447
806
|
export {
|
|
807
|
+
AdapterSessionManager,
|
|
448
808
|
BrowserEventAdapter,
|
|
449
809
|
ChatController,
|
|
450
810
|
ConsoleErrorAdapter,
|
|
451
811
|
IdentityInputAdapter,
|
|
452
812
|
RuleEngine,
|
|
453
813
|
SessionManager,
|
|
814
|
+
localStorageAdapter,
|
|
454
815
|
parseSchema
|
|
455
816
|
};
|
|
456
817
|
//# sourceMappingURL=index.mjs.map
|