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