@vinaverse/client 0.0.0-stage → 0.1.0-alpha.2
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 +202 -0
- package/README.md +94 -2
- package/dist/chunk-AWHPWRGT.js +1318 -0
- package/dist/chunk-AWHPWRGT.js.map +1 -0
- package/dist/conversation/index.cjs +2275 -0
- package/dist/conversation/index.cjs.map +1 -0
- package/dist/conversation/index.d.cts +371 -0
- package/dist/conversation/index.d.ts +371 -0
- package/dist/conversation/index.js +952 -0
- package/dist/conversation/index.js.map +1 -0
- package/dist/index.cjs +1328 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +10 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/session--W6JXfwo.d.cts +2028 -0
- package/dist/session--W6JXfwo.d.ts +2028 -0
- package/package.json +80 -3
|
@@ -0,0 +1,1318 @@
|
|
|
1
|
+
// src/contract.ts
|
|
2
|
+
var CONTRACT = {
|
|
3
|
+
api: "v1-draft.17",
|
|
4
|
+
commit: "fe4fb7d2945e1ced2bce7d44e85a4b28005e46df"
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
// src/engine.ts
|
|
8
|
+
var UNLOCKS_KEEP_LOCKED = /* @__PURE__ */ new Set([
|
|
9
|
+
"card_superseded",
|
|
10
|
+
"client_event_id_reused"
|
|
11
|
+
]);
|
|
12
|
+
var RESEND = /* @__PURE__ */ new Set(["turn_in_progress", "screen_unavailable"]);
|
|
13
|
+
var FLOW_RESULTS = /* @__PURE__ */ new Set(["ok", "refused", "failed"]);
|
|
14
|
+
var OUTCOMES = /* @__PURE__ */ new Set(["confirmed", "declined", "failed", "expired"]);
|
|
15
|
+
var RECONNECTABLE = /* @__PURE__ */ new Set(["server_restart"]);
|
|
16
|
+
var NEW_SESSION = /* @__PURE__ */ new Set(["idle", "expired", "limit_reached"]);
|
|
17
|
+
var str = (v) => typeof v === "string" ? v : void 0;
|
|
18
|
+
var ConversationEngine = class {
|
|
19
|
+
sessionId = null;
|
|
20
|
+
conversationId = null;
|
|
21
|
+
connection = null;
|
|
22
|
+
reconnect = "allowed";
|
|
23
|
+
messages = [];
|
|
24
|
+
cards = /* @__PURE__ */ new Map();
|
|
25
|
+
unacknowledged = /* @__PURE__ */ new Map();
|
|
26
|
+
resend = [];
|
|
27
|
+
held = [];
|
|
28
|
+
refused = {};
|
|
29
|
+
flow = null;
|
|
30
|
+
turn = null;
|
|
31
|
+
tools = [];
|
|
32
|
+
handoffs = [];
|
|
33
|
+
errors = [];
|
|
34
|
+
expiring = null;
|
|
35
|
+
streaming = null;
|
|
36
|
+
takeover = null;
|
|
37
|
+
rejections = {};
|
|
38
|
+
disclosure = null;
|
|
39
|
+
turnsLeft = null;
|
|
40
|
+
/** The next arrival number for a message or a card. */
|
|
41
|
+
seq = 0;
|
|
42
|
+
/** The cards drawn before a resumed flow's transcript replaced them: proposal outcomes carry over. */
|
|
43
|
+
replaced = null;
|
|
44
|
+
/** client_event_id → the first event the app sent under it, until it is settled. */
|
|
45
|
+
inputs = /* @__PURE__ */ new Map();
|
|
46
|
+
// ------------------------------------------------------------ what happens around the frames
|
|
47
|
+
/** The tenant's backend created a session (and named its conversation). */
|
|
48
|
+
sessionCreated(sessionId, conversationId) {
|
|
49
|
+
this.sessionId = sessionId;
|
|
50
|
+
this.conversationId = conversationId ?? null;
|
|
51
|
+
}
|
|
52
|
+
/** A connection opened: the user's own action, or an allowed reconnect, so it lifts a hold on reconnecting. */
|
|
53
|
+
connected(name) {
|
|
54
|
+
this.connection = { name, state: "open" };
|
|
55
|
+
this.reconnect = "allowed";
|
|
56
|
+
}
|
|
57
|
+
/** A connection closed with its code. `4403` (revoked) and `4409` (replaced) hold reconnecting for a user action. */
|
|
58
|
+
closed(name, code) {
|
|
59
|
+
if (this.connection?.name !== name) return;
|
|
60
|
+
this.connection.state = "closed";
|
|
61
|
+
this.connection.code = code;
|
|
62
|
+
if (this.connection.reason === void 0) {
|
|
63
|
+
if (code === 4403 || code === 4409) this.reconnect = "user_action_only";
|
|
64
|
+
else if (code === 4408) this.reconnect = "new_session_on_next_action";
|
|
65
|
+
}
|
|
66
|
+
this.cutOff("interrupted");
|
|
67
|
+
this.turn = null;
|
|
68
|
+
}
|
|
69
|
+
/** The app was killed: what it drew is gone; what it sent and never saw acknowledged it kept, to resend. */
|
|
70
|
+
killed() {
|
|
71
|
+
this.messages = [];
|
|
72
|
+
this.cards = /* @__PURE__ */ new Map();
|
|
73
|
+
this.turn = null;
|
|
74
|
+
this.streaming = null;
|
|
75
|
+
this.takeover = null;
|
|
76
|
+
}
|
|
77
|
+
// ------------------------------------------------------------ what the app sends
|
|
78
|
+
sent(event) {
|
|
79
|
+
const id = str(event.client_event_id);
|
|
80
|
+
switch (event.type) {
|
|
81
|
+
case "ui.answer": {
|
|
82
|
+
if (id === void 0) return;
|
|
83
|
+
if (!this.inputs.has(id)) this.inputs.set(id, event);
|
|
84
|
+
this.unqueue(id);
|
|
85
|
+
this.unacknowledged.set(id, event);
|
|
86
|
+
const card = this.cards.get(str(event.card_id) ?? "");
|
|
87
|
+
const questionId = str(event.question_id);
|
|
88
|
+
if (card && questionId) lock(card, questionId);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
case "input.text": {
|
|
92
|
+
if (id === void 0) return;
|
|
93
|
+
if (!this.inputs.has(id)) this.inputs.set(id, event);
|
|
94
|
+
this.unqueue(id);
|
|
95
|
+
delete this.refused[id];
|
|
96
|
+
this.unacknowledged.set(id, event);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
case "ui.action": {
|
|
100
|
+
if (id !== void 0) {
|
|
101
|
+
if (!this.inputs.has(id)) this.inputs.set(id, event);
|
|
102
|
+
this.unqueue(id);
|
|
103
|
+
this.unacknowledged.set(id, event);
|
|
104
|
+
}
|
|
105
|
+
const card = this.cards.get(str(event.card_id) ?? "");
|
|
106
|
+
const action = str(event.action);
|
|
107
|
+
if (card?.kind === "proposal" && action && OUTCOMES.has(action) && card.outcome === null) {
|
|
108
|
+
card.outcome = action;
|
|
109
|
+
if (id !== void 0) card.outcomeBy = id;
|
|
110
|
+
}
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
default:
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
// ------------------------------------------------------------ what the server sends
|
|
118
|
+
received(event) {
|
|
119
|
+
switch (event.type) {
|
|
120
|
+
case "session.ready":
|
|
121
|
+
this.ready(event);
|
|
122
|
+
return;
|
|
123
|
+
case "turn.started": {
|
|
124
|
+
this.turn = str(event.turn_id) ?? null;
|
|
125
|
+
const id = str(event.client_event_id);
|
|
126
|
+
if (id !== void 0) {
|
|
127
|
+
const sent = this.inputs.get(id);
|
|
128
|
+
this.settle(id);
|
|
129
|
+
if (sent?.type === "input.text") {
|
|
130
|
+
this.say("person", str(sent.text) ?? "", id);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
case "message.delta": {
|
|
136
|
+
const turnId = str(event.turn_id) ?? "";
|
|
137
|
+
const text = str(event.text) ?? "";
|
|
138
|
+
if (this.streaming?.turnId === turnId)
|
|
139
|
+
this.streaming = {
|
|
140
|
+
...this.streaming,
|
|
141
|
+
text: this.streaming.text + text
|
|
142
|
+
};
|
|
143
|
+
else {
|
|
144
|
+
this.cutOff("interrupted");
|
|
145
|
+
this.streaming = { turnId, text, seq: this.seq++ };
|
|
146
|
+
}
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
case "message.completed": {
|
|
150
|
+
const turnId = str(event.turn_id) ?? "";
|
|
151
|
+
const streaming = this.streaming?.turnId === turnId ? this.streaming : null;
|
|
152
|
+
if (streaming) this.streaming = null;
|
|
153
|
+
this.say(
|
|
154
|
+
"assistant",
|
|
155
|
+
str(event.text) ?? "",
|
|
156
|
+
`a:${turnId}`,
|
|
157
|
+
{},
|
|
158
|
+
streaming?.seq
|
|
159
|
+
);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
case "ui.cards":
|
|
163
|
+
for (const card of asArray(event.cards))
|
|
164
|
+
this.hold(card, str(event.turn_id));
|
|
165
|
+
return;
|
|
166
|
+
case "tool.status": {
|
|
167
|
+
const line = {
|
|
168
|
+
turn_id: str(event.turn_id) ?? "",
|
|
169
|
+
label: str(event.label) ?? "",
|
|
170
|
+
state: str(event.state) ?? ""
|
|
171
|
+
};
|
|
172
|
+
const at = this.tools.findIndex(
|
|
173
|
+
(t) => t.turn_id === line.turn_id && t.label === line.label
|
|
174
|
+
);
|
|
175
|
+
if (at === -1) this.tools = [...this.tools, line].slice(-100);
|
|
176
|
+
else this.tools[at] = line;
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
case "handoff.created":
|
|
180
|
+
this.handoffs.push(event.ticket);
|
|
181
|
+
return;
|
|
182
|
+
case "turn.completed":
|
|
183
|
+
this.turn = null;
|
|
184
|
+
return;
|
|
185
|
+
case "turn.failed": {
|
|
186
|
+
const code = str(event.code) ?? "";
|
|
187
|
+
if (this.streaming?.turnId === str(event.turn_id))
|
|
188
|
+
this.cutOff(
|
|
189
|
+
code === "output_blocked" ? "blocked" : code === "cancelled" ? "interrupted" : "failed"
|
|
190
|
+
);
|
|
191
|
+
this.turn = null;
|
|
192
|
+
this.error(code);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
case "takeover.started":
|
|
196
|
+
this.takeover = {
|
|
197
|
+
by: str(event.by) ?? null,
|
|
198
|
+
at: str(event.at) ?? ""
|
|
199
|
+
};
|
|
200
|
+
return;
|
|
201
|
+
case "takeover.ended":
|
|
202
|
+
this.takeover = null;
|
|
203
|
+
return;
|
|
204
|
+
case "message.staff": {
|
|
205
|
+
const id = str(event.message_id);
|
|
206
|
+
if (id !== void 0)
|
|
207
|
+
this.say("staff", str(event.text) ?? "", `s:${id}`);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
case "input.received": {
|
|
211
|
+
const id = str(event.client_event_id);
|
|
212
|
+
if (id === void 0) return;
|
|
213
|
+
const sent = this.inputs.get(id);
|
|
214
|
+
this.settle(id);
|
|
215
|
+
if (sent?.type === "input.text")
|
|
216
|
+
this.say("person", str(sent.text) ?? "", id, { received: true });
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
case "ui.answer.recorded":
|
|
220
|
+
this.recorded(event);
|
|
221
|
+
return;
|
|
222
|
+
case "ui.answer.rejected":
|
|
223
|
+
this.rejected(event);
|
|
224
|
+
return;
|
|
225
|
+
case "input.rejected":
|
|
226
|
+
this.inputRejected(event);
|
|
227
|
+
return;
|
|
228
|
+
case "flow.completed": {
|
|
229
|
+
const result = str(event.result) ?? "";
|
|
230
|
+
this.flow = {
|
|
231
|
+
flow: event.flow,
|
|
232
|
+
result: FLOW_RESULTS.has(result) ? result : "failed"
|
|
233
|
+
};
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
case "session.closed": {
|
|
237
|
+
const reason = str(event.reason);
|
|
238
|
+
if (reason === void 0) return;
|
|
239
|
+
if (NEW_SESSION.has(reason))
|
|
240
|
+
this.reconnect = "new_session_on_next_action";
|
|
241
|
+
else if (!RECONNECTABLE.has(reason))
|
|
242
|
+
this.reconnect = "user_action_only";
|
|
243
|
+
if (this.connection) this.connection.reason = reason;
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
case "session.expiring":
|
|
247
|
+
this.expiring = typeof event.seconds_left === "number" ? event.seconds_left : null;
|
|
248
|
+
return;
|
|
249
|
+
case "error": {
|
|
250
|
+
this.error(str(event.code) ?? "");
|
|
251
|
+
const id = str(event.client_event_id);
|
|
252
|
+
if (id !== void 0 && event.code === "screen_unavailable") {
|
|
253
|
+
this.inputRejected(event);
|
|
254
|
+
} else if (id !== void 0) {
|
|
255
|
+
this.settle(id);
|
|
256
|
+
}
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
default:
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
ready(event) {
|
|
264
|
+
const session = event.session;
|
|
265
|
+
this.sessionId = str(session?.id) ?? this.sessionId;
|
|
266
|
+
this.turn = null;
|
|
267
|
+
this.expiring = null;
|
|
268
|
+
this.disclosure = str(event.disclosure) ?? this.disclosure;
|
|
269
|
+
const limits = event.limits;
|
|
270
|
+
this.turnsLeft = typeof limits?.turns_left === "number" ? limits.turns_left : null;
|
|
271
|
+
this.takeover = null;
|
|
272
|
+
this.cutOff("interrupted");
|
|
273
|
+
if (event.resumed !== true) return;
|
|
274
|
+
if (event.transcript !== void 0) {
|
|
275
|
+
this.messages = [];
|
|
276
|
+
this.replaced = this.cards;
|
|
277
|
+
this.cards = /* @__PURE__ */ new Map();
|
|
278
|
+
for (const item of asArray(event.transcript)) {
|
|
279
|
+
if (item.role === "card")
|
|
280
|
+
this.hold(item.card);
|
|
281
|
+
else {
|
|
282
|
+
const role = str(item.role) ?? "";
|
|
283
|
+
this.say(
|
|
284
|
+
role,
|
|
285
|
+
str(item.text) ?? "",
|
|
286
|
+
transcriptKey(role, str(item.id), str(item.at)),
|
|
287
|
+
item.interrupted === true ? { partial: "interrupted" } : {}
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
for (const card of asArray(event.pending_cards))
|
|
293
|
+
this.hold(card, str(card.turn_id));
|
|
294
|
+
this.replaced = null;
|
|
295
|
+
}
|
|
296
|
+
hold(raw, turnId) {
|
|
297
|
+
if (!raw) return;
|
|
298
|
+
const id = str(raw.id);
|
|
299
|
+
if (id === void 0) return;
|
|
300
|
+
const kind = str(raw.kind) ?? "";
|
|
301
|
+
const question = raw.question;
|
|
302
|
+
const card = {
|
|
303
|
+
id,
|
|
304
|
+
kind,
|
|
305
|
+
card: raw,
|
|
306
|
+
...question ? { question: structuredCopy(question) } : {},
|
|
307
|
+
locked: lockedFrom(question),
|
|
308
|
+
...turnId !== void 0 ? { turnId } : {},
|
|
309
|
+
// A card still held keeps its place when it is drawn again (a reconnect's pending cards, a repeated ui.cards).
|
|
310
|
+
seq: this.cards.get(id)?.seq ?? this.seq++
|
|
311
|
+
};
|
|
312
|
+
for (const held of this.held) {
|
|
313
|
+
const sent = this.inputs.get(held);
|
|
314
|
+
if (sent?.type === "ui.answer" && sent.card_id === id) {
|
|
315
|
+
const questionId = str(sent.question_id);
|
|
316
|
+
if (questionId !== void 0) lock(card, questionId);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
if (kind === "proposal") {
|
|
320
|
+
const held = this.cards.get(id) ?? this.replaced?.get(id);
|
|
321
|
+
card.outcome = held?.outcome ?? null;
|
|
322
|
+
if (held?.outcomeBy !== void 0) card.outcomeBy = held.outcomeBy;
|
|
323
|
+
}
|
|
324
|
+
this.cards.delete(id);
|
|
325
|
+
this.cards.set(id, card);
|
|
326
|
+
}
|
|
327
|
+
recorded(event) {
|
|
328
|
+
const id = str(event.client_event_id) ?? "";
|
|
329
|
+
const card = this.cards.get(str(event.card_id) ?? "");
|
|
330
|
+
const sent = this.inputs.get(id);
|
|
331
|
+
this.settle(id);
|
|
332
|
+
if (!card?.question || !sent) return;
|
|
333
|
+
const q = card.question;
|
|
334
|
+
const questionId = str(event.question_id) ?? "";
|
|
335
|
+
lock(card, questionId);
|
|
336
|
+
if (q.kind === "rate_each") {
|
|
337
|
+
const rows = (q.row_answers ?? []).filter(
|
|
338
|
+
(r) => r.question_id !== questionId
|
|
339
|
+
);
|
|
340
|
+
q.row_answers = [
|
|
341
|
+
...rows,
|
|
342
|
+
{ question_id: questionId, answer: sent.answer }
|
|
343
|
+
];
|
|
344
|
+
const answered = q.row_answers;
|
|
345
|
+
if ((q.rows ?? []).every(
|
|
346
|
+
(r) => answered.some((a) => a.question_id === r.question_id)
|
|
347
|
+
)) {
|
|
348
|
+
q.state = "answered";
|
|
349
|
+
lock(card, q.question_id);
|
|
350
|
+
}
|
|
351
|
+
} else {
|
|
352
|
+
q.answer = sent.answer;
|
|
353
|
+
q.state = "answered";
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
rejected(event) {
|
|
357
|
+
this.settle(str(event.client_event_id) ?? "");
|
|
358
|
+
const card = this.cards.get(str(event.card_id) ?? "");
|
|
359
|
+
const questionId = str(event.question_id);
|
|
360
|
+
if (!card || questionId === void 0) return;
|
|
361
|
+
if (UNLOCKS_KEEP_LOCKED.has(str(event.code) ?? "")) lock(card, questionId);
|
|
362
|
+
else unlock(card, questionId);
|
|
363
|
+
}
|
|
364
|
+
inputRejected(event) {
|
|
365
|
+
const id = str(event.client_event_id);
|
|
366
|
+
if (id === void 0) return;
|
|
367
|
+
this.unacknowledged.delete(id);
|
|
368
|
+
const code = str(event.code) ?? "";
|
|
369
|
+
this.rejections[id] = { code, message: str(event.message) ?? "" };
|
|
370
|
+
if (RESEND.has(code)) {
|
|
371
|
+
if (!this.resend.includes(id)) this.resend.push(id);
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
if (code === "limit_reached") {
|
|
375
|
+
this.unqueue(id);
|
|
376
|
+
const kind = this.inputs.get(id)?.type;
|
|
377
|
+
if (kind === "input.text" || kind === "ui.answer") {
|
|
378
|
+
if (!this.held.includes(id)) this.held.push(id);
|
|
379
|
+
} else this.inputs.delete(id);
|
|
380
|
+
this.reconnect = "new_session_on_next_action";
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
if (code !== "duplicate") this.refused[id] = code;
|
|
384
|
+
const sent = this.inputs.get(id);
|
|
385
|
+
if (code === "guardrail" && sent?.type === "input.text") {
|
|
386
|
+
this.say("person", str(sent.text) ?? "", id);
|
|
387
|
+
this.say("assistant", str(event.message) ?? "", `r:${id}`, {
|
|
388
|
+
refusal: true
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
this.inputs.delete(id);
|
|
392
|
+
}
|
|
393
|
+
/** No longer to resend, nor held for the next session (sent again, or settled). */
|
|
394
|
+
unqueue(id) {
|
|
395
|
+
this.resend = this.resend.filter((r) => r !== id);
|
|
396
|
+
this.held = this.held.filter((r) => r !== id);
|
|
397
|
+
}
|
|
398
|
+
/** An input acknowledged or refused for good: nothing more is resent under its id. */
|
|
399
|
+
settle(id) {
|
|
400
|
+
delete this.rejections[id];
|
|
401
|
+
this.unacknowledged.delete(id);
|
|
402
|
+
this.inputs.delete(id);
|
|
403
|
+
this.unqueue(id);
|
|
404
|
+
}
|
|
405
|
+
/** Adds a message once: a key already held (a frame seen twice) changes nothing. */
|
|
406
|
+
say(role, text, key, extra = {}, seq = this.seq++) {
|
|
407
|
+
if (this.messages.some((m) => m.key === key)) return;
|
|
408
|
+
this.messages.push({ role, text, key, seq, ...extra });
|
|
409
|
+
}
|
|
410
|
+
/** The turn's streamed text, kept as the agent's message when the turn ends without its `message.completed`. */
|
|
411
|
+
cutOff(partial) {
|
|
412
|
+
const streaming = this.streaming;
|
|
413
|
+
this.streaming = null;
|
|
414
|
+
const text = streaming?.text.trim();
|
|
415
|
+
if (streaming && text)
|
|
416
|
+
this.say(
|
|
417
|
+
"assistant",
|
|
418
|
+
text,
|
|
419
|
+
`a:${streaming.turnId}`,
|
|
420
|
+
{ partial },
|
|
421
|
+
streaming.seq
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
error(code) {
|
|
425
|
+
this.errors = [...this.errors, code].slice(-100);
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* An input that could not be delivered, or whose retries are spent (`screen_unavailable` after the last try): it is
|
|
429
|
+
* no longer resent. A typed message is shown as not sent, with `code`; an answer's question unlocks so the person
|
|
430
|
+
* can answer again; a proposal outcome it reported is taken back, so it can be reported again.
|
|
431
|
+
*/
|
|
432
|
+
giveUp(clientEventId, code) {
|
|
433
|
+
const sent = this.inputs.get(clientEventId);
|
|
434
|
+
if (!sent) return;
|
|
435
|
+
const rejection = this.rejections[clientEventId];
|
|
436
|
+
this.resend = this.resend.filter((r) => r !== clientEventId);
|
|
437
|
+
this.settle(clientEventId);
|
|
438
|
+
const card = this.cards.get(str(sent.card_id) ?? "");
|
|
439
|
+
if (sent.type === "input.text") {
|
|
440
|
+
this.refused[clientEventId] = code;
|
|
441
|
+
if (rejection) this.rejections[clientEventId] = rejection;
|
|
442
|
+
} else if (sent.type === "ui.answer") {
|
|
443
|
+
const questionId = str(sent.question_id);
|
|
444
|
+
if (card && questionId !== void 0) unlock(card, questionId);
|
|
445
|
+
} else if (card?.outcomeBy === clientEventId) {
|
|
446
|
+
card.outcome = null;
|
|
447
|
+
delete card.outcomeBy;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
/** A turn the app stopped where no event will say so (an SSE turn cancelled): none is in progress. */
|
|
451
|
+
turnEnded() {
|
|
452
|
+
this.turn = null;
|
|
453
|
+
}
|
|
454
|
+
// ------------------------------------------------------------ reading it
|
|
455
|
+
/** The event first sent under a `client_event_id`, to resend it unchanged. */
|
|
456
|
+
input(clientEventId) {
|
|
457
|
+
return this.inputs.get(clientEventId);
|
|
458
|
+
}
|
|
459
|
+
state() {
|
|
460
|
+
const cards = [...this.cards.values()].map(freeze);
|
|
461
|
+
const open = cards.filter(
|
|
462
|
+
(c) => c.kind === "question" && c.question?.state === "open"
|
|
463
|
+
);
|
|
464
|
+
return {
|
|
465
|
+
sessionId: this.sessionId,
|
|
466
|
+
conversationId: this.conversationId,
|
|
467
|
+
connection: this.connection ? { ...this.connection } : null,
|
|
468
|
+
reconnect: this.reconnect,
|
|
469
|
+
messages: [...this.messages],
|
|
470
|
+
cards,
|
|
471
|
+
openQuestion: open.length ? open[open.length - 1].id : null,
|
|
472
|
+
unacknowledged: [...this.unacknowledged.values()],
|
|
473
|
+
resend: [...this.resend],
|
|
474
|
+
held: [...this.held],
|
|
475
|
+
refused: { ...this.refused },
|
|
476
|
+
flow: this.flow,
|
|
477
|
+
turn: this.turn,
|
|
478
|
+
tools: [...this.tools],
|
|
479
|
+
handoffs: [...this.handoffs],
|
|
480
|
+
errors: [...this.errors],
|
|
481
|
+
streaming: this.streaming,
|
|
482
|
+
takeover: this.takeover,
|
|
483
|
+
rejections: { ...this.rejections },
|
|
484
|
+
disclosure: this.disclosure,
|
|
485
|
+
turnsLeft: this.turnsLeft,
|
|
486
|
+
heldForNextSession: this.reconnect === "new_session_on_next_action" ? [
|
|
487
|
+
.../* @__PURE__ */ new Set([
|
|
488
|
+
...this.unacknowledged.keys(),
|
|
489
|
+
...this.resend,
|
|
490
|
+
...this.held
|
|
491
|
+
])
|
|
492
|
+
] : [],
|
|
493
|
+
expiring: this.expiring
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
};
|
|
497
|
+
function transcriptKey(role, id, at) {
|
|
498
|
+
if (id === void 0) return `t:${at ?? ""}:${role}`;
|
|
499
|
+
if (role === "assistant") return `a:${id}`;
|
|
500
|
+
if (role === "staff") return `s:${id}`;
|
|
501
|
+
return id;
|
|
502
|
+
}
|
|
503
|
+
function lockedFrom(question) {
|
|
504
|
+
if (!question) return [];
|
|
505
|
+
if (question.state !== "open") {
|
|
506
|
+
return [
|
|
507
|
+
question.question_id,
|
|
508
|
+
...(question.rows ?? []).map((r) => r.question_id)
|
|
509
|
+
];
|
|
510
|
+
}
|
|
511
|
+
return (question.row_answers ?? []).map((r) => r.question_id);
|
|
512
|
+
}
|
|
513
|
+
function lock(card, id) {
|
|
514
|
+
if (!card.locked.includes(id)) card.locked.push(id);
|
|
515
|
+
}
|
|
516
|
+
function unlock(card, id) {
|
|
517
|
+
card.locked = card.locked.filter((q) => q !== id);
|
|
518
|
+
}
|
|
519
|
+
function freeze(card) {
|
|
520
|
+
return {
|
|
521
|
+
id: card.id,
|
|
522
|
+
kind: card.kind,
|
|
523
|
+
card: card.card,
|
|
524
|
+
...card.question ? { question: { ...card.question } } : {},
|
|
525
|
+
locked: [...card.locked],
|
|
526
|
+
...card.turnId !== void 0 ? { turnId: card.turnId } : {},
|
|
527
|
+
...card.outcome !== void 0 ? { outcome: card.outcome } : {},
|
|
528
|
+
seq: card.seq
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
function asArray(value) {
|
|
532
|
+
return Array.isArray(value) ? value : [];
|
|
533
|
+
}
|
|
534
|
+
function structuredCopy(value) {
|
|
535
|
+
return JSON.parse(JSON.stringify(value));
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// src/errors.ts
|
|
539
|
+
var VinaverseClientError = class extends Error {
|
|
540
|
+
name = "VinaverseClientError";
|
|
541
|
+
code;
|
|
542
|
+
status;
|
|
543
|
+
constructor(code, message, status) {
|
|
544
|
+
super(message);
|
|
545
|
+
this.code = code;
|
|
546
|
+
this.status = status;
|
|
547
|
+
}
|
|
548
|
+
};
|
|
549
|
+
|
|
550
|
+
// src/events.ts
|
|
551
|
+
var CARD_KINDS = {
|
|
552
|
+
professional: true,
|
|
553
|
+
workspace: true,
|
|
554
|
+
service: true,
|
|
555
|
+
time_slots: true,
|
|
556
|
+
booking: true,
|
|
557
|
+
quote: true,
|
|
558
|
+
policy: true,
|
|
559
|
+
handoff: true,
|
|
560
|
+
metric_chart: true,
|
|
561
|
+
metric_table: true,
|
|
562
|
+
gap_list: true,
|
|
563
|
+
cluster: true,
|
|
564
|
+
claim: true,
|
|
565
|
+
document: true,
|
|
566
|
+
question: true,
|
|
567
|
+
proposal: true,
|
|
568
|
+
next_step: true
|
|
569
|
+
};
|
|
570
|
+
function isKnownCard(card) {
|
|
571
|
+
return Object.hasOwn(CARD_KINDS, card.kind);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// src/secret.ts
|
|
575
|
+
var CLIENT_SECRET = /^vv_cs_[A-Za-z0-9_-]{16,}$/;
|
|
576
|
+
function clientSecret(value) {
|
|
577
|
+
const secret = typeof value === "object" && value !== null ? value.value : value;
|
|
578
|
+
if (typeof secret === "string" && secret.startsWith("vv_sk_")) {
|
|
579
|
+
throw new VinaverseClientError(
|
|
580
|
+
"service_key",
|
|
581
|
+
"A Vinaverse service key belongs on your backend only. Pass the session client secret (vv_cs_\u2026) to the app."
|
|
582
|
+
);
|
|
583
|
+
}
|
|
584
|
+
if (typeof secret !== "string" || !CLIENT_SECRET.test(secret)) {
|
|
585
|
+
throw new VinaverseClientError(
|
|
586
|
+
"invalid_secret",
|
|
587
|
+
"Expected a session client secret (vv_cs_\u2026)."
|
|
588
|
+
);
|
|
589
|
+
}
|
|
590
|
+
return secret;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// src/sse.ts
|
|
594
|
+
var MAX_EVENT_CHARS = 1024 * 1024;
|
|
595
|
+
function turnSse(target, input) {
|
|
596
|
+
return post(target, target.turnsUrl, input);
|
|
597
|
+
}
|
|
598
|
+
function cardActionSse(target, turnId, input) {
|
|
599
|
+
return post(
|
|
600
|
+
target,
|
|
601
|
+
`${target.turnsUrl.replace(/\/+$/, "")}/${encodeURIComponent(turnId)}/actions`,
|
|
602
|
+
input
|
|
603
|
+
);
|
|
604
|
+
}
|
|
605
|
+
async function* post(target, url, body) {
|
|
606
|
+
const doFetch = target.fetch ?? globalThis.fetch;
|
|
607
|
+
const response = await doFetch(url, {
|
|
608
|
+
method: "POST",
|
|
609
|
+
headers: {
|
|
610
|
+
authorization: `Bearer ${target.secret}`,
|
|
611
|
+
"content-type": "application/json",
|
|
612
|
+
accept: "text/event-stream"
|
|
613
|
+
},
|
|
614
|
+
body: JSON.stringify(body),
|
|
615
|
+
...target.signal ? { signal: target.signal } : {}
|
|
616
|
+
});
|
|
617
|
+
if (!response.ok) {
|
|
618
|
+
throw new VinaverseClientError(
|
|
619
|
+
"http_error",
|
|
620
|
+
`The turn request failed with HTTP ${response.status}.`,
|
|
621
|
+
response.status
|
|
622
|
+
);
|
|
623
|
+
}
|
|
624
|
+
const chunks = response.body ? decode(response.body) : once(await response.text());
|
|
625
|
+
yield* sseFrames(chunks);
|
|
626
|
+
}
|
|
627
|
+
async function* sseFrames(chunks) {
|
|
628
|
+
let buffer = "";
|
|
629
|
+
for await (const chunk of chunks) {
|
|
630
|
+
buffer += chunk;
|
|
631
|
+
if (buffer.length > MAX_EVENT_CHARS && boundary(buffer) === null) {
|
|
632
|
+
throw new VinaverseClientError(
|
|
633
|
+
"http_error",
|
|
634
|
+
"An SSE event is larger than 1 MiB."
|
|
635
|
+
);
|
|
636
|
+
}
|
|
637
|
+
let end = boundary(buffer);
|
|
638
|
+
while (end !== null) {
|
|
639
|
+
const frame = parseEvent(buffer.slice(0, end.at));
|
|
640
|
+
buffer = buffer.slice(end.at + end.length);
|
|
641
|
+
if (frame) yield frame;
|
|
642
|
+
end = boundary(buffer);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
const last = parseEvent(buffer);
|
|
646
|
+
if (last) yield last;
|
|
647
|
+
}
|
|
648
|
+
function boundary(buffer) {
|
|
649
|
+
const match = /\r?\n\r?\n|\r\r/.exec(buffer);
|
|
650
|
+
return match ? { at: match.index, length: match[0].length } : null;
|
|
651
|
+
}
|
|
652
|
+
function parseEvent(block) {
|
|
653
|
+
let type;
|
|
654
|
+
const data = [];
|
|
655
|
+
for (const line of block.split(/\r?\n|\r/)) {
|
|
656
|
+
if (line === "" || line.startsWith(":")) continue;
|
|
657
|
+
const colon = line.indexOf(":");
|
|
658
|
+
const field = colon === -1 ? line : line.slice(0, colon);
|
|
659
|
+
let value = colon === -1 ? "" : line.slice(colon + 1);
|
|
660
|
+
if (value.startsWith(" ")) value = value.slice(1);
|
|
661
|
+
if (field === "event") type = value;
|
|
662
|
+
else if (field === "data") data.push(value);
|
|
663
|
+
}
|
|
664
|
+
if (data.length === 0) return null;
|
|
665
|
+
let parsed;
|
|
666
|
+
try {
|
|
667
|
+
parsed = JSON.parse(data.join("\n"));
|
|
668
|
+
} catch {
|
|
669
|
+
return null;
|
|
670
|
+
}
|
|
671
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
|
|
672
|
+
return null;
|
|
673
|
+
const frame = parsed;
|
|
674
|
+
const kind = typeof frame.type === "string" ? frame.type : type;
|
|
675
|
+
return kind === void 0 ? null : { ...frame, type: kind };
|
|
676
|
+
}
|
|
677
|
+
async function* decode(body) {
|
|
678
|
+
const reader = body.getReader();
|
|
679
|
+
const decoder = new TextDecoder();
|
|
680
|
+
try {
|
|
681
|
+
for (; ; ) {
|
|
682
|
+
const { done, value } = await reader.read();
|
|
683
|
+
if (done) break;
|
|
684
|
+
yield decoder.decode(value, { stream: true });
|
|
685
|
+
}
|
|
686
|
+
const rest = decoder.decode();
|
|
687
|
+
if (rest) yield rest;
|
|
688
|
+
} finally {
|
|
689
|
+
reader.releaseLock();
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
async function* once(text) {
|
|
693
|
+
yield text;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// src/session.ts
|
|
697
|
+
var OPEN = 1;
|
|
698
|
+
var SUBPROTOCOL = "vinaverse.v1";
|
|
699
|
+
var DEFAULT_RECONNECT = [1e3, 2e3, 4e3, 8e3, 16e3, 3e4];
|
|
700
|
+
var DEFAULT_SCREEN_RETRY = [2e3, 4e3, 8e3];
|
|
701
|
+
var NO_RECONNECT = /* @__PURE__ */ new Set([4403, 4409]);
|
|
702
|
+
var FALLBACK_AFTER = 2;
|
|
703
|
+
var STABLE_MS = 3e4;
|
|
704
|
+
var VinaverseSession = class {
|
|
705
|
+
engine;
|
|
706
|
+
info;
|
|
707
|
+
secret;
|
|
708
|
+
options;
|
|
709
|
+
socket = null;
|
|
710
|
+
/** The engine's name for the connection in `socket`, once it opened. */
|
|
711
|
+
opened = null;
|
|
712
|
+
connection = 0;
|
|
713
|
+
ready = false;
|
|
714
|
+
current = "idle";
|
|
715
|
+
everOpened = false;
|
|
716
|
+
firstFailures = 0;
|
|
717
|
+
attempts = 0;
|
|
718
|
+
/** The reason of the `session.closed` that preceded the close, on the current connection. */
|
|
719
|
+
closeReason = null;
|
|
720
|
+
stableTimer;
|
|
721
|
+
/** SSE requests in flight. */
|
|
722
|
+
streams = /* @__PURE__ */ new Map();
|
|
723
|
+
reconnectTimer;
|
|
724
|
+
pingTimer;
|
|
725
|
+
foreground = true;
|
|
726
|
+
refreshed = false;
|
|
727
|
+
/** client_event_id → why it waits: after the turn, or after a backoff. */
|
|
728
|
+
waiting = /* @__PURE__ */ new Map();
|
|
729
|
+
/** client_event_id → the session that refused it for its turn limit: resent only on another session. */
|
|
730
|
+
heldForSession = /* @__PURE__ */ new Map();
|
|
731
|
+
screenTries = /* @__PURE__ */ new Map();
|
|
732
|
+
screenTimers = /* @__PURE__ */ new Map();
|
|
733
|
+
listeners = {
|
|
734
|
+
event: /* @__PURE__ */ new Set(),
|
|
735
|
+
state: /* @__PURE__ */ new Set(),
|
|
736
|
+
status: /* @__PURE__ */ new Set(),
|
|
737
|
+
error: /* @__PURE__ */ new Set()
|
|
738
|
+
};
|
|
739
|
+
constructor(options) {
|
|
740
|
+
this.options = options;
|
|
741
|
+
this.info = options.session;
|
|
742
|
+
this.secret = clientSecret(options.session.client_secret);
|
|
743
|
+
this.engine = options.engine ?? new ConversationEngine();
|
|
744
|
+
this.named(options.session);
|
|
745
|
+
}
|
|
746
|
+
/** The engine names the session, and keeps the conversation it already knew when this one does not say. */
|
|
747
|
+
named(session) {
|
|
748
|
+
this.engine.sessionCreated(
|
|
749
|
+
session.id,
|
|
750
|
+
session.conversation_id ?? this.engine.state().conversationId
|
|
751
|
+
);
|
|
752
|
+
}
|
|
753
|
+
get status() {
|
|
754
|
+
return this.current;
|
|
755
|
+
}
|
|
756
|
+
get state() {
|
|
757
|
+
return this.engine.state();
|
|
758
|
+
}
|
|
759
|
+
on(name, listener) {
|
|
760
|
+
this.listeners[name].add(listener);
|
|
761
|
+
return () => this.listeners[name].delete(listener);
|
|
762
|
+
}
|
|
763
|
+
// ------------------------------------------------------------ connecting
|
|
764
|
+
/**
|
|
765
|
+
* Connect, or reconnect: the user's own action, so it also resumes after the conversation continued elsewhere
|
|
766
|
+
* or the secret was refused.
|
|
767
|
+
*/
|
|
768
|
+
connect() {
|
|
769
|
+
if (this.current === "closed") throw ended();
|
|
770
|
+
if (this.engine.state().reconnect === "new_session_on_next_action") {
|
|
771
|
+
throw new VinaverseClientError(
|
|
772
|
+
"needs_new_session",
|
|
773
|
+
"This session has ended; create a new session for the conversation and call resume() with it."
|
|
774
|
+
);
|
|
775
|
+
}
|
|
776
|
+
this.start();
|
|
777
|
+
}
|
|
778
|
+
start() {
|
|
779
|
+
this.cancelReconnect();
|
|
780
|
+
this.attempts = 0;
|
|
781
|
+
this.refreshed = false;
|
|
782
|
+
this.open();
|
|
783
|
+
}
|
|
784
|
+
/**
|
|
785
|
+
* Continue the conversation on a new session the tenant's backend created for it (by `conversation_key` or
|
|
786
|
+
* `conversation_id`): its `session.ready` brings the transcript and pending cards, and what was never
|
|
787
|
+
* acknowledged is resent there. A user action, like `connect()`.
|
|
788
|
+
*/
|
|
789
|
+
resume(session) {
|
|
790
|
+
const secret = clientSecret(session.client_secret);
|
|
791
|
+
if (this.current === "closed") throw ended();
|
|
792
|
+
this.info = session;
|
|
793
|
+
this.secret = secret;
|
|
794
|
+
this.named(session);
|
|
795
|
+
this.drop(1e3);
|
|
796
|
+
this.start();
|
|
797
|
+
}
|
|
798
|
+
/** Close for good: the connection, any SSE turn still streaming, and every timer. */
|
|
799
|
+
close() {
|
|
800
|
+
this.cancelReconnect();
|
|
801
|
+
this.drop(1e3);
|
|
802
|
+
this.stopSse("close");
|
|
803
|
+
for (const timer of this.screenTimers.values()) clearTimeout(timer);
|
|
804
|
+
this.screenTimers.clear();
|
|
805
|
+
this.setStatus("closed");
|
|
806
|
+
}
|
|
807
|
+
/** Ping only while the app is in the foreground (every 25 s); in the background the connection may idle out. */
|
|
808
|
+
setForeground(foreground) {
|
|
809
|
+
this.foreground = foreground;
|
|
810
|
+
if (foreground && this.ready) this.startPing();
|
|
811
|
+
else this.stopPing();
|
|
812
|
+
}
|
|
813
|
+
open() {
|
|
814
|
+
this.drop(1e3);
|
|
815
|
+
const factory = this.options.webSocket ?? defaultWebSocket();
|
|
816
|
+
if (!factory) {
|
|
817
|
+
this.fallBack();
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
const auth = this.options.auth ?? "subprotocol";
|
|
821
|
+
const protocols = auth === "subprotocol" ? [SUBPROTOCOL, `bearer.${this.secret}`] : [SUBPROTOCOL];
|
|
822
|
+
const headers = auth === "header" ? { authorization: `Bearer ${this.secret}` } : void 0;
|
|
823
|
+
const name = `c${++this.connection}`;
|
|
824
|
+
this.closeReason = null;
|
|
825
|
+
let socket;
|
|
826
|
+
try {
|
|
827
|
+
socket = factory(this.info.realtime_url, protocols, headers);
|
|
828
|
+
} catch {
|
|
829
|
+
this.fallBack();
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
this.socket = socket;
|
|
833
|
+
this.ready = false;
|
|
834
|
+
this.setStatus(this.attempts > 0 ? "reconnecting" : "connecting");
|
|
835
|
+
socket.onopen = () => {
|
|
836
|
+
if (this.socket !== socket) return;
|
|
837
|
+
this.opened = name;
|
|
838
|
+
this.everOpened = true;
|
|
839
|
+
this.engine.connected(name);
|
|
840
|
+
this.emitState();
|
|
841
|
+
if (auth === "first_frame")
|
|
842
|
+
socket.send(
|
|
843
|
+
JSON.stringify({ type: "session.auth", client_secret: this.secret })
|
|
844
|
+
);
|
|
845
|
+
};
|
|
846
|
+
socket.onmessage = (event) => {
|
|
847
|
+
if (this.socket === socket) this.frame(event.data);
|
|
848
|
+
};
|
|
849
|
+
socket.onerror = () => {
|
|
850
|
+
};
|
|
851
|
+
socket.onclose = (event) => {
|
|
852
|
+
if (this.socket !== socket) return;
|
|
853
|
+
const opened = this.opened !== null;
|
|
854
|
+
this.forget(event.code);
|
|
855
|
+
this.afterClose(event.code, opened);
|
|
856
|
+
};
|
|
857
|
+
}
|
|
858
|
+
afterClose(code, opened) {
|
|
859
|
+
if (this.current === "closed") return;
|
|
860
|
+
if (this.engine.state().reconnect !== "allowed" || NO_RECONNECT.has(code)) {
|
|
861
|
+
this.setStatus("ended");
|
|
862
|
+
return;
|
|
863
|
+
}
|
|
864
|
+
if (code === 4401) {
|
|
865
|
+
this.refresh();
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
868
|
+
if (!opened && !this.everOpened) {
|
|
869
|
+
this.firstFailures++;
|
|
870
|
+
if (this.firstFailures >= FALLBACK_AFTER && this.options.sseFallback !== false) {
|
|
871
|
+
this.fallBack();
|
|
872
|
+
return;
|
|
873
|
+
}
|
|
874
|
+
this.scheduleReconnect();
|
|
875
|
+
return;
|
|
876
|
+
}
|
|
877
|
+
if (this.closeReason === "server_restart") {
|
|
878
|
+
this.scheduleReconnect();
|
|
879
|
+
return;
|
|
880
|
+
}
|
|
881
|
+
if (code === 1e3) {
|
|
882
|
+
this.setStatus("idle");
|
|
883
|
+
return;
|
|
884
|
+
}
|
|
885
|
+
this.scheduleReconnect();
|
|
886
|
+
}
|
|
887
|
+
/** After a 4401: one fresh secret for the session in use, unless it moved on or was closed meanwhile. */
|
|
888
|
+
refresh() {
|
|
889
|
+
const refreshSecret = this.options.refreshSecret;
|
|
890
|
+
if (!refreshSecret || this.refreshed) {
|
|
891
|
+
this.setStatus("unauthorized");
|
|
892
|
+
return;
|
|
893
|
+
}
|
|
894
|
+
this.refreshed = true;
|
|
895
|
+
const sessionId = this.info.id;
|
|
896
|
+
const current = () => this.current !== "closed" && this.info.id === sessionId;
|
|
897
|
+
refreshSecret(sessionId).then((fresh) => {
|
|
898
|
+
if (!current()) return;
|
|
899
|
+
this.secret = clientSecret(fresh);
|
|
900
|
+
this.open();
|
|
901
|
+
}).catch((error) => {
|
|
902
|
+
if (!current()) return;
|
|
903
|
+
this.setStatus("unauthorized");
|
|
904
|
+
this.emit("error", asClientError(error, "invalid_secret"));
|
|
905
|
+
});
|
|
906
|
+
}
|
|
907
|
+
scheduleReconnect() {
|
|
908
|
+
const max = this.options.maxReconnects ?? 8;
|
|
909
|
+
if (this.attempts >= max) {
|
|
910
|
+
this.setStatus("idle");
|
|
911
|
+
return;
|
|
912
|
+
}
|
|
913
|
+
const delays = this.options.reconnectDelays ?? DEFAULT_RECONNECT;
|
|
914
|
+
const delay = delays[Math.min(this.attempts, delays.length - 1)] ?? 0;
|
|
915
|
+
this.attempts++;
|
|
916
|
+
this.setStatus("reconnecting");
|
|
917
|
+
this.reconnectTimer = setTimeout(() => {
|
|
918
|
+
this.reconnectTimer = void 0;
|
|
919
|
+
this.open();
|
|
920
|
+
}, delay);
|
|
921
|
+
}
|
|
922
|
+
cancelReconnect() {
|
|
923
|
+
if (this.reconnectTimer !== void 0) clearTimeout(this.reconnectTimer);
|
|
924
|
+
this.reconnectTimer = void 0;
|
|
925
|
+
}
|
|
926
|
+
fallBack() {
|
|
927
|
+
this.socket = null;
|
|
928
|
+
if (this.options.sseFallback === false) {
|
|
929
|
+
this.setStatus("idle");
|
|
930
|
+
return;
|
|
931
|
+
}
|
|
932
|
+
this.setStatus("sse");
|
|
933
|
+
for (const event of this.engine.state().unacknowledged) this.overSse(event);
|
|
934
|
+
if (this.streams.size === 0) this.resendWaiting("turn");
|
|
935
|
+
}
|
|
936
|
+
/** Close the current connection, if any, without reacting to its close event. */
|
|
937
|
+
drop(code) {
|
|
938
|
+
const socket = this.socket;
|
|
939
|
+
this.forget(code);
|
|
940
|
+
if (socket && socket.readyState <= OPEN) {
|
|
941
|
+
try {
|
|
942
|
+
socket.close(code);
|
|
943
|
+
} catch {
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
forget(code) {
|
|
948
|
+
this.socket = null;
|
|
949
|
+
this.ready = false;
|
|
950
|
+
this.stopPing();
|
|
951
|
+
if (this.stableTimer !== void 0) clearTimeout(this.stableTimer);
|
|
952
|
+
this.stableTimer = void 0;
|
|
953
|
+
if (this.opened !== null) {
|
|
954
|
+
this.engine.closed(this.opened, code);
|
|
955
|
+
this.opened = null;
|
|
956
|
+
this.emitState();
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
// ------------------------------------------------------------ receiving
|
|
960
|
+
frame(data) {
|
|
961
|
+
if (typeof data !== "string") return;
|
|
962
|
+
let parsed;
|
|
963
|
+
try {
|
|
964
|
+
parsed = JSON.parse(data);
|
|
965
|
+
} catch {
|
|
966
|
+
return;
|
|
967
|
+
}
|
|
968
|
+
if (typeof parsed !== "object" || parsed === null || typeof parsed.type !== "string")
|
|
969
|
+
return;
|
|
970
|
+
this.receive(parsed);
|
|
971
|
+
}
|
|
972
|
+
receive(event) {
|
|
973
|
+
if (this.current === "closed") return;
|
|
974
|
+
this.engine.received(event);
|
|
975
|
+
this.emit("event", event);
|
|
976
|
+
const id = typeof event.client_event_id === "string" ? event.client_event_id : void 0;
|
|
977
|
+
switch (event.type) {
|
|
978
|
+
case "session.ready":
|
|
979
|
+
this.ready = true;
|
|
980
|
+
this.refreshed = false;
|
|
981
|
+
this.firstFailures = 0;
|
|
982
|
+
this.stableTimer = setTimeout(() => {
|
|
983
|
+
this.stableTimer = void 0;
|
|
984
|
+
this.attempts = 0;
|
|
985
|
+
}, STABLE_MS);
|
|
986
|
+
this.setStatus("ready");
|
|
987
|
+
if (this.foreground) this.startPing();
|
|
988
|
+
this.resendUnacknowledged();
|
|
989
|
+
this.resendWaiting("turn");
|
|
990
|
+
this.resendHeldForSession();
|
|
991
|
+
break;
|
|
992
|
+
case "turn.completed":
|
|
993
|
+
case "turn.failed":
|
|
994
|
+
// A tap or an answer refused with turn_in_progress during a takeover waits for no turn: the hand-back frees it.
|
|
995
|
+
case "takeover.ended":
|
|
996
|
+
this.resendWaiting("turn");
|
|
997
|
+
break;
|
|
998
|
+
case "turn.started":
|
|
999
|
+
case "ui.answer.recorded":
|
|
1000
|
+
case "ui.answer.rejected":
|
|
1001
|
+
if (id !== void 0) this.screenTries.delete(id);
|
|
1002
|
+
break;
|
|
1003
|
+
case "input.rejected":
|
|
1004
|
+
case "error":
|
|
1005
|
+
this.refusedForNow(event);
|
|
1006
|
+
break;
|
|
1007
|
+
case "session.closed":
|
|
1008
|
+
this.closeReason = typeof event.reason === "string" ? event.reason : "";
|
|
1009
|
+
if (this.engine.state().reconnect !== "allowed")
|
|
1010
|
+
this.setStatus("ended");
|
|
1011
|
+
break;
|
|
1012
|
+
}
|
|
1013
|
+
this.emitState();
|
|
1014
|
+
}
|
|
1015
|
+
refusedForNow(event) {
|
|
1016
|
+
const id = typeof event.client_event_id === "string" ? event.client_event_id : void 0;
|
|
1017
|
+
if (id === void 0) return;
|
|
1018
|
+
if (event.code === "limit_reached") {
|
|
1019
|
+
this.heldForSession.set(id, this.info.id);
|
|
1020
|
+
this.setStatus("ended");
|
|
1021
|
+
return;
|
|
1022
|
+
}
|
|
1023
|
+
if (!this.engine.state().resend.includes(id)) return;
|
|
1024
|
+
if (event.code === "turn_in_progress") {
|
|
1025
|
+
this.waiting.set(id, "turn");
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
const tries = this.screenTries.get(id) ?? 0;
|
|
1029
|
+
const delays = this.options.screenRetryDelays ?? DEFAULT_SCREEN_RETRY;
|
|
1030
|
+
if (tries >= delays.length) {
|
|
1031
|
+
this.screenTries.delete(id);
|
|
1032
|
+
this.waiting.delete(id);
|
|
1033
|
+
this.engine.giveUp(id, "screen_unavailable");
|
|
1034
|
+
return;
|
|
1035
|
+
}
|
|
1036
|
+
this.screenTries.set(id, tries + 1);
|
|
1037
|
+
this.waiting.set(id, "screen");
|
|
1038
|
+
this.screenTimers.set(
|
|
1039
|
+
id,
|
|
1040
|
+
setTimeout(() => {
|
|
1041
|
+
this.screenTimers.delete(id);
|
|
1042
|
+
if (this.waiting.get(id) === "screen") this.resend(id);
|
|
1043
|
+
}, delays[tries])
|
|
1044
|
+
);
|
|
1045
|
+
}
|
|
1046
|
+
resendUnacknowledged() {
|
|
1047
|
+
for (const event of this.engine.state().unacknowledged)
|
|
1048
|
+
this.transmit(event);
|
|
1049
|
+
}
|
|
1050
|
+
/** What a turn limit held, on a session other than the one that refused it. */
|
|
1051
|
+
resendHeldForSession() {
|
|
1052
|
+
for (const [id, refusedIn] of [...this.heldForSession]) {
|
|
1053
|
+
if (refusedIn === this.info.id) continue;
|
|
1054
|
+
this.heldForSession.delete(id);
|
|
1055
|
+
this.resend(id);
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
resendWaiting(reason) {
|
|
1059
|
+
for (const [id, why] of [...this.waiting])
|
|
1060
|
+
if (why === reason) this.resend(id);
|
|
1061
|
+
}
|
|
1062
|
+
resend(id) {
|
|
1063
|
+
this.waiting.delete(id);
|
|
1064
|
+
const event = this.engine.input(id);
|
|
1065
|
+
if (!event) return;
|
|
1066
|
+
if (this.live() || this.awaitingNewSession()) this.deliver(event);
|
|
1067
|
+
else this.hold(event);
|
|
1068
|
+
}
|
|
1069
|
+
/** Keep an input for a later session: unacknowledged, so the next `session.ready` (after resume()) sends it. */
|
|
1070
|
+
hold(event) {
|
|
1071
|
+
if (this.current === "closed") return;
|
|
1072
|
+
this.engine.sent(event);
|
|
1073
|
+
this.emitState();
|
|
1074
|
+
}
|
|
1075
|
+
/** The session ended: retries waiting on a timer would go nowhere, so their inputs are held for the next session. */
|
|
1076
|
+
holdRetries() {
|
|
1077
|
+
for (const [id, timer] of [...this.screenTimers]) {
|
|
1078
|
+
clearTimeout(timer);
|
|
1079
|
+
this.screenTimers.delete(id);
|
|
1080
|
+
this.waiting.delete(id);
|
|
1081
|
+
const event = this.engine.input(id);
|
|
1082
|
+
if (event) this.hold(event);
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
// ------------------------------------------------------------ sending
|
|
1086
|
+
/** Send a typed message; returns its `client_event_id`. Over SSE it runs the turn there. */
|
|
1087
|
+
sendText(text) {
|
|
1088
|
+
const id = this.newId();
|
|
1089
|
+
this.deliver({ type: "input.text", text, client_event_id: id });
|
|
1090
|
+
return id;
|
|
1091
|
+
}
|
|
1092
|
+
/** Answer a question card (or a `rate_each` row, by the row's `question_id`); returns the `client_event_id`. */
|
|
1093
|
+
answer(cardId, questionId, answer) {
|
|
1094
|
+
if (this.current === "sse") {
|
|
1095
|
+
throw new VinaverseClientError(
|
|
1096
|
+
"needs_websocket",
|
|
1097
|
+
"Question cards are answered over the WebSocket."
|
|
1098
|
+
);
|
|
1099
|
+
}
|
|
1100
|
+
const id = this.newId();
|
|
1101
|
+
this.deliver({
|
|
1102
|
+
type: "ui.answer",
|
|
1103
|
+
client_event_id: id,
|
|
1104
|
+
card_id: cardId,
|
|
1105
|
+
question_id: questionId,
|
|
1106
|
+
answer
|
|
1107
|
+
});
|
|
1108
|
+
return id;
|
|
1109
|
+
}
|
|
1110
|
+
/**
|
|
1111
|
+
* Report a proposal's outcome after the tenant's backend answered (§6): `confirmed`, `declined`, `failed` or
|
|
1112
|
+
* `expired`. The first outcome per card is final. Returns the `client_event_id`.
|
|
1113
|
+
*/
|
|
1114
|
+
reportOutcome(cardId, outcome) {
|
|
1115
|
+
return this.action(cardId, outcome);
|
|
1116
|
+
}
|
|
1117
|
+
/** A card action (`ui.action`), named by the card's `id`; the card's turn is taken from the state. */
|
|
1118
|
+
action(cardId, action) {
|
|
1119
|
+
const turnId = this.engine.state().cards.find((c) => c.id === cardId)?.turnId;
|
|
1120
|
+
if (turnId === void 0)
|
|
1121
|
+
throw new RangeError(`No turn is known for card ${cardId}.`);
|
|
1122
|
+
const id = this.newId();
|
|
1123
|
+
this.deliver({
|
|
1124
|
+
type: "ui.action",
|
|
1125
|
+
client_event_id: id,
|
|
1126
|
+
turn_id: turnId,
|
|
1127
|
+
card_id: cardId,
|
|
1128
|
+
action
|
|
1129
|
+
});
|
|
1130
|
+
return id;
|
|
1131
|
+
}
|
|
1132
|
+
/** Stop the turn in progress: `input.cancel` on the WebSocket; over SSE, the turn's request is stopped. */
|
|
1133
|
+
cancel(turnId) {
|
|
1134
|
+
if (this.current === "sse") {
|
|
1135
|
+
this.stopSse("cancel");
|
|
1136
|
+
this.engine.turnEnded();
|
|
1137
|
+
this.emitState();
|
|
1138
|
+
return;
|
|
1139
|
+
}
|
|
1140
|
+
this.transmit({ type: "input.cancel", turn_id: turnId });
|
|
1141
|
+
}
|
|
1142
|
+
/** Ended idle: inputs are held for the new session that `resume()` brings. */
|
|
1143
|
+
awaitingNewSession() {
|
|
1144
|
+
return this.current === "ended" && this.engine.state().reconnect === "new_session_on_next_action";
|
|
1145
|
+
}
|
|
1146
|
+
live() {
|
|
1147
|
+
return this.current !== "closed" && this.current !== "ended" && this.current !== "unauthorized";
|
|
1148
|
+
}
|
|
1149
|
+
/**
|
|
1150
|
+
* Every input goes through here: recorded as sent (unacknowledged until the server answers it), then over SSE,
|
|
1151
|
+
* or on the WebSocket when the session is ready, or held and resent once `session.ready` arrives.
|
|
1152
|
+
*/
|
|
1153
|
+
deliver(event) {
|
|
1154
|
+
if (!this.live() && !this.awaitingNewSession()) throw ended();
|
|
1155
|
+
this.engine.sent(event);
|
|
1156
|
+
this.emitState();
|
|
1157
|
+
if (this.current === "sse") this.overSse(event);
|
|
1158
|
+
else if (this.ready) this.transmit(event);
|
|
1159
|
+
else if (this.current === "idle") this.connect();
|
|
1160
|
+
}
|
|
1161
|
+
transmit(event) {
|
|
1162
|
+
const socket = this.socket;
|
|
1163
|
+
if (!socket || socket.readyState !== OPEN) return;
|
|
1164
|
+
socket.send(JSON.stringify(event));
|
|
1165
|
+
}
|
|
1166
|
+
/** One input over SSE: a typed message or a card action; an answer cannot go, and is shown as not sent. */
|
|
1167
|
+
overSse(event) {
|
|
1168
|
+
const id = typeof event.client_event_id === "string" ? event.client_event_id : void 0;
|
|
1169
|
+
const notSent = (error) => {
|
|
1170
|
+
if (id !== void 0) this.engine.giveUp(id, error.code);
|
|
1171
|
+
this.emitState();
|
|
1172
|
+
this.emit("error", error);
|
|
1173
|
+
};
|
|
1174
|
+
if (id !== void 0 && this.inFlight(id)) return;
|
|
1175
|
+
const controller = new AbortController();
|
|
1176
|
+
const target = { ...this.sseTarget(), signal: controller.signal };
|
|
1177
|
+
let frames;
|
|
1178
|
+
if (event.type === "input.text") {
|
|
1179
|
+
frames = turnSse(target, {
|
|
1180
|
+
text: String(event.text),
|
|
1181
|
+
...id !== void 0 ? { client_event_id: id } : {}
|
|
1182
|
+
});
|
|
1183
|
+
} else if (event.type === "ui.action") {
|
|
1184
|
+
frames = cardActionSse(target, String(event.turn_id), {
|
|
1185
|
+
card_id: String(event.card_id),
|
|
1186
|
+
action: String(event.action),
|
|
1187
|
+
...id !== void 0 ? { client_event_id: id } : {}
|
|
1188
|
+
});
|
|
1189
|
+
} else {
|
|
1190
|
+
notSent(
|
|
1191
|
+
new VinaverseClientError(
|
|
1192
|
+
"needs_websocket",
|
|
1193
|
+
"Question cards are answered over the WebSocket."
|
|
1194
|
+
)
|
|
1195
|
+
);
|
|
1196
|
+
return;
|
|
1197
|
+
}
|
|
1198
|
+
const stream = { id };
|
|
1199
|
+
this.streams.set(controller, stream);
|
|
1200
|
+
void (async () => {
|
|
1201
|
+
try {
|
|
1202
|
+
for await (const frame of frames) {
|
|
1203
|
+
if (controller.signal.aborted) break;
|
|
1204
|
+
this.receive(frame);
|
|
1205
|
+
}
|
|
1206
|
+
} catch (error) {
|
|
1207
|
+
if (!controller.signal.aborted) {
|
|
1208
|
+
notSent(asClientError(error, "http_error"));
|
|
1209
|
+
return;
|
|
1210
|
+
}
|
|
1211
|
+
} finally {
|
|
1212
|
+
this.streams.delete(controller);
|
|
1213
|
+
}
|
|
1214
|
+
if (controller.signal.aborted) {
|
|
1215
|
+
if (stream.stop === "cancel" && id !== void 0) {
|
|
1216
|
+
this.engine.giveUp(id, "cancelled");
|
|
1217
|
+
this.emitState();
|
|
1218
|
+
}
|
|
1219
|
+
return;
|
|
1220
|
+
}
|
|
1221
|
+
if (id !== void 0 && this.isUnacknowledged(id)) {
|
|
1222
|
+
notSent(
|
|
1223
|
+
new VinaverseClientError(
|
|
1224
|
+
"http_error",
|
|
1225
|
+
"The turn ended without taking the input."
|
|
1226
|
+
)
|
|
1227
|
+
);
|
|
1228
|
+
}
|
|
1229
|
+
})();
|
|
1230
|
+
}
|
|
1231
|
+
isUnacknowledged(id) {
|
|
1232
|
+
return this.engine.state().unacknowledged.some((e) => e.client_event_id === id);
|
|
1233
|
+
}
|
|
1234
|
+
inFlight(id) {
|
|
1235
|
+
for (const stream of this.streams.values())
|
|
1236
|
+
if (stream.id === id) return true;
|
|
1237
|
+
return false;
|
|
1238
|
+
}
|
|
1239
|
+
stopSse(why) {
|
|
1240
|
+
for (const [controller, stream] of [...this.streams]) {
|
|
1241
|
+
stream.stop = why;
|
|
1242
|
+
controller.abort();
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
sseTarget() {
|
|
1246
|
+
return {
|
|
1247
|
+
turnsUrl: this.info.turns_url,
|
|
1248
|
+
secret: this.secret,
|
|
1249
|
+
...this.options.fetch ? { fetch: this.options.fetch } : {}
|
|
1250
|
+
};
|
|
1251
|
+
}
|
|
1252
|
+
newId() {
|
|
1253
|
+
return (this.options.createId ?? randomId)();
|
|
1254
|
+
}
|
|
1255
|
+
// ------------------------------------------------------------ pings and listeners
|
|
1256
|
+
startPing() {
|
|
1257
|
+
this.stopPing();
|
|
1258
|
+
this.pingTimer = setInterval(
|
|
1259
|
+
() => this.transmit({ type: "ping" }),
|
|
1260
|
+
this.options.pingInterval ?? 25e3
|
|
1261
|
+
);
|
|
1262
|
+
}
|
|
1263
|
+
stopPing() {
|
|
1264
|
+
if (this.pingTimer !== void 0) clearInterval(this.pingTimer);
|
|
1265
|
+
this.pingTimer = void 0;
|
|
1266
|
+
}
|
|
1267
|
+
setStatus(status) {
|
|
1268
|
+
if (this.current === status) return;
|
|
1269
|
+
this.current = status;
|
|
1270
|
+
if (status === "ended") this.holdRetries();
|
|
1271
|
+
this.emit("status", status);
|
|
1272
|
+
}
|
|
1273
|
+
emitState() {
|
|
1274
|
+
if (this.listeners.state.size) this.emit("state", this.engine.state());
|
|
1275
|
+
}
|
|
1276
|
+
emit(name, value) {
|
|
1277
|
+
for (const listener of this.listeners[name]) listener(value);
|
|
1278
|
+
}
|
|
1279
|
+
};
|
|
1280
|
+
function ended() {
|
|
1281
|
+
return new VinaverseClientError(
|
|
1282
|
+
"session_ended",
|
|
1283
|
+
"The session has ended; resume it on a new session."
|
|
1284
|
+
);
|
|
1285
|
+
}
|
|
1286
|
+
function asClientError(error, code) {
|
|
1287
|
+
if (error instanceof VinaverseClientError) return error;
|
|
1288
|
+
return new VinaverseClientError(
|
|
1289
|
+
code,
|
|
1290
|
+
error instanceof Error ? error.message : String(error)
|
|
1291
|
+
);
|
|
1292
|
+
}
|
|
1293
|
+
function defaultWebSocket() {
|
|
1294
|
+
const Ctor = globalThis.WebSocket;
|
|
1295
|
+
if (!Ctor) return null;
|
|
1296
|
+
const scope = globalThis;
|
|
1297
|
+
const node = typeof scope.process?.versions?.node === "string" && scope.document === void 0;
|
|
1298
|
+
return (url, protocols, headers) => {
|
|
1299
|
+
if (!headers) return new Ctor(url, protocols);
|
|
1300
|
+
return node ? new Ctor(url, { protocols, headers }) : new Ctor(url, protocols, { headers });
|
|
1301
|
+
};
|
|
1302
|
+
}
|
|
1303
|
+
var ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
|
1304
|
+
function randomId() {
|
|
1305
|
+
const bytes = new Uint8Array(22);
|
|
1306
|
+
const crypto = globalThis.crypto;
|
|
1307
|
+
if (crypto?.getRandomValues) crypto.getRandomValues(bytes);
|
|
1308
|
+
else
|
|
1309
|
+
for (let i = 0; i < bytes.length; i++)
|
|
1310
|
+
bytes[i] = Math.floor(Math.random() * 256);
|
|
1311
|
+
let id = "ce_";
|
|
1312
|
+
for (const byte of bytes) id += ALPHABET[byte % ALPHABET.length];
|
|
1313
|
+
return id;
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
export { CONTRACT, ConversationEngine, VinaverseClientError, VinaverseSession, cardActionSse, clientSecret, isKnownCard, randomId, sseFrames, turnSse };
|
|
1317
|
+
//# sourceMappingURL=chunk-AWHPWRGT.js.map
|
|
1318
|
+
//# sourceMappingURL=chunk-AWHPWRGT.js.map
|