@vinaverse/client 0.1.0-alpha.4 → 0.1.0-alpha.5
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/README.md +46 -0
- package/dist/agent/index.cjs +2852 -0
- package/dist/agent/index.cjs.map +1 -0
- package/dist/agent/index.d.cts +57 -0
- package/dist/agent/index.d.ts +57 -0
- package/dist/agent/index.js +99 -0
- package/dist/agent/index.js.map +1 -0
- package/dist/chunk-3WR2SUG2.js +416 -0
- package/dist/chunk-3WR2SUG2.js.map +1 -0
- package/dist/{chunk-MKUNSAMT.js → chunk-GWLLUOFY.js} +3 -9
- package/dist/chunk-GWLLUOFY.js.map +1 -0
- package/dist/chunk-VSJDVLCP.js +9 -0
- package/dist/chunk-VSJDVLCP.js.map +1 -0
- package/dist/chunk-ZWG2TMUL.js +975 -0
- package/dist/chunk-ZWG2TMUL.js.map +1 -0
- package/dist/contract-_NMdPjgN.d.cts +6 -0
- package/dist/contract-_NMdPjgN.d.ts +6 -0
- package/dist/conversation/index.cjs +1 -1
- package/dist/conversation/index.cjs.map +1 -1
- package/dist/conversation/index.d.cts +6 -377
- package/dist/conversation/index.d.ts +6 -377
- package/dist/conversation/index.js +5 -973
- package/dist/conversation/index.js.map +1 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +2 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/runtime-B4nKwOTF.d.ts +232 -0
- package/dist/runtime-DDxwWBBr.d.cts +232 -0
- package/dist/{session-C3qZwGPG.d.cts → session-8W3fRNeb.d.cts} +1514 -253
- package/dist/{session-C3qZwGPG.d.ts → session-8W3fRNeb.d.ts} +1514 -253
- package/dist/store-C7FtS5AY.d.cts +384 -0
- package/dist/store-DFk0ayh8.d.ts +384 -0
- package/dist/testing/index.cjs +237 -0
- package/dist/testing/index.cjs.map +1 -0
- package/dist/testing/index.d.cts +49 -0
- package/dist/testing/index.d.ts +49 -0
- package/dist/testing/index.js +222 -0
- package/dist/testing/index.js.map +1 -0
- package/package.json +33 -1
- package/dist/chunk-MKUNSAMT.js.map +0 -1
|
@@ -0,0 +1,975 @@
|
|
|
1
|
+
import { ConversationEngine, randomId, VinaverseSession, VinaverseClientError, transcriptHasIds, isKnownCard } from './chunk-GWLLUOFY.js';
|
|
2
|
+
|
|
3
|
+
// src/conversation/links.ts
|
|
4
|
+
var PREFIX = /^[a-z][a-z0-9+.-]*:(\/\/\.*[A-Za-z0-9_~-][A-Za-z0-9._~-]*(:(6553[0-5]|655[0-2][0-9]|65[0-4][0-9]{2}|6[0-4][0-9]{3}|[1-5][0-9]{4}|[1-9][0-9]{0,3}))?\/|\.*[A-Za-z0-9_~-][A-Za-z0-9._~-]*\/)(\.*[A-Za-z0-9_~-][A-Za-z0-9._~-]*\/)*$/;
|
|
5
|
+
var PREFIX_HTTPS = /^https?:\/\/[a-z0-9-]+(\.[a-z0-9-]+)*(:(6553[0-5]|655[0-2][0-9]|65[0-4][0-9]{2}|6[0-4][0-9]{3}|[1-5][0-9]{4}|[1-9][0-9]{0,3}))?\/\.*[A-Za-z0-9_~-][A-Za-z0-9._~-]*\//;
|
|
6
|
+
var REFUSED_SCHEMES = /^(javascript|vbscript|data|blob|file|filesystem|about|view-source|intent|android-app|content|jar|mhtml|itms-services|ftp|ws|wss):/;
|
|
7
|
+
var CONTROLS = /[\x00-\x1f\x7f]/;
|
|
8
|
+
var REMAINDER = /^(\.*[A-Za-z0-9_~-][A-Za-z0-9._~-]*(\/\.*[A-Za-z0-9_~-][A-Za-z0-9._~-]*)*\/?)?(\?([A-Za-z0-9._~=&-]|%(2[0-46-9A-Ea-e]|3[0-9B-Fb-f]|[46][0-9A-Fa-f]|5[0-9ABD-Fabd-f]|7[0-9A-Ea-e]))*)?$/;
|
|
9
|
+
function isValidLinkPrefix(prefix) {
|
|
10
|
+
if (CONTROLS.test(prefix) || REFUSED_SCHEMES.test(prefix)) return false;
|
|
11
|
+
if (/^https?:/.test(prefix) && !PREFIX_HTTPS.test(prefix)) return false;
|
|
12
|
+
return PREFIX.test(prefix);
|
|
13
|
+
}
|
|
14
|
+
function isAllowedLink(link, prefixes) {
|
|
15
|
+
return prefixes.some(
|
|
16
|
+
(prefix) => isValidLinkPrefix(prefix) && link.startsWith(prefix) && REMAINDER.test(link.slice(prefix.length))
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// src/conversation/timeline.ts
|
|
21
|
+
function lastOrder(state) {
|
|
22
|
+
let last = -1;
|
|
23
|
+
for (const m of state.messages) last = Math.max(last, m.seq);
|
|
24
|
+
for (const c of state.cards) last = Math.max(last, c.seq);
|
|
25
|
+
if (state.streaming) last = Math.max(last, state.streaming.seq);
|
|
26
|
+
return last;
|
|
27
|
+
}
|
|
28
|
+
function pendingState(id, input) {
|
|
29
|
+
const { state } = input;
|
|
30
|
+
if (input.retried.has(id)) return null;
|
|
31
|
+
if (input.early.has(id)) return "sending";
|
|
32
|
+
if (state.messages.some((m) => m.key === id)) return null;
|
|
33
|
+
const refused = state.refused[id];
|
|
34
|
+
if (refused !== void 0) return refused === "guardrail" ? null : "not_sent";
|
|
35
|
+
if (state.heldForNextSession.includes(id)) return "held";
|
|
36
|
+
if (state.resend.includes(id) || state.held.includes(id) || state.unacknowledged.some((f) => f.client_event_id === id))
|
|
37
|
+
return "sending";
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
function buildTimeline(input, previous) {
|
|
41
|
+
const before = new Map(previous.map((item) => [item.key, item]));
|
|
42
|
+
const live = [];
|
|
43
|
+
const { state } = input;
|
|
44
|
+
for (const m of state.messages)
|
|
45
|
+
live.push({ item: messageItem(m), order: m.seq });
|
|
46
|
+
if (state.streaming) {
|
|
47
|
+
live.push({
|
|
48
|
+
item: {
|
|
49
|
+
type: "message",
|
|
50
|
+
key: `a:${state.streaming.turnId}`,
|
|
51
|
+
role: "agent",
|
|
52
|
+
text: "",
|
|
53
|
+
state: "streaming"
|
|
54
|
+
},
|
|
55
|
+
order: state.streaming.seq
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
for (const card of state.cards) {
|
|
59
|
+
if (!isKnownCard(card.card)) continue;
|
|
60
|
+
live.push({
|
|
61
|
+
item: {
|
|
62
|
+
type: "card",
|
|
63
|
+
key: card.id,
|
|
64
|
+
card,
|
|
65
|
+
busy: !input.inert?.has(card.id) && input.busy.has(card.id),
|
|
66
|
+
...input.inert?.has(card.id) ? { restored: true } : {},
|
|
67
|
+
...input.decisionErrors?.has(card.id) ? { decisionError: true } : {}
|
|
68
|
+
},
|
|
69
|
+
order: input.cardOrder?.get(card.id) ?? card.seq
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
for (const { item, order } of input.system) live.push({ item, order });
|
|
73
|
+
const held = new Set(state.cards.map((c) => c.id));
|
|
74
|
+
for (const { item, order } of input.kept ?? [])
|
|
75
|
+
if (!held.has(item.key)) live.push({ item, order });
|
|
76
|
+
for (const [id, sent] of input.sent) {
|
|
77
|
+
const pending = pendingState(id, input);
|
|
78
|
+
if (pending === null) continue;
|
|
79
|
+
const rejection = state.rejections[id];
|
|
80
|
+
live.push({
|
|
81
|
+
item: {
|
|
82
|
+
type: "message",
|
|
83
|
+
key: id,
|
|
84
|
+
role: "person",
|
|
85
|
+
text: sent.text,
|
|
86
|
+
state: pending,
|
|
87
|
+
...pending === "not_sent" ? {
|
|
88
|
+
rejection: rejection ?? {
|
|
89
|
+
code: state.refused[id] ?? "",
|
|
90
|
+
message: ""
|
|
91
|
+
}
|
|
92
|
+
} : {}
|
|
93
|
+
},
|
|
94
|
+
order: sent.order
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
live.sort((a, b) => a.order - b.order);
|
|
98
|
+
const items = mergeByKey(
|
|
99
|
+
input.transcriptSeen === true ? [] : input.history,
|
|
100
|
+
live.map((l) => l.item)
|
|
101
|
+
);
|
|
102
|
+
let same = items.length === previous.length;
|
|
103
|
+
const shared = items.map((item, i) => {
|
|
104
|
+
const prev = before.get(item.key);
|
|
105
|
+
const kept = prev !== void 0 && equal(prev, item) ? prev : item;
|
|
106
|
+
if (kept !== previous[i]) same = false;
|
|
107
|
+
return kept;
|
|
108
|
+
});
|
|
109
|
+
return same ? previous : shared;
|
|
110
|
+
}
|
|
111
|
+
function mergeByKey(history, live) {
|
|
112
|
+
if (!history.length) return [...live];
|
|
113
|
+
const items = [...history];
|
|
114
|
+
const at = (key) => items.findIndex((item) => item.key === key);
|
|
115
|
+
let last = -1;
|
|
116
|
+
live.forEach((item, i) => {
|
|
117
|
+
const found = at(item.key);
|
|
118
|
+
if (found >= 0) {
|
|
119
|
+
items[found] = item;
|
|
120
|
+
last = found;
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const next = live.slice(i + 1).map((l) => at(l.key)).find((p) => p >= 0);
|
|
124
|
+
last = next === void 0 ? items.length : last >= 0 ? last + 1 : next;
|
|
125
|
+
items.splice(last, 0, item);
|
|
126
|
+
});
|
|
127
|
+
return items;
|
|
128
|
+
}
|
|
129
|
+
function messageItem(m) {
|
|
130
|
+
const role = m.role === "assistant" ? "agent" : m.role;
|
|
131
|
+
return {
|
|
132
|
+
type: "message",
|
|
133
|
+
key: m.key,
|
|
134
|
+
role,
|
|
135
|
+
text: m.text,
|
|
136
|
+
state: m.received ? "delivered" : "sent",
|
|
137
|
+
...m.partial ? { partial: m.partial } : {},
|
|
138
|
+
...m.refusal ? { refusal: true } : {}
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
function equal(a, b) {
|
|
142
|
+
if (a.type !== b.type || a.key !== b.key) return false;
|
|
143
|
+
if (a.type === "message" && b.type === "message")
|
|
144
|
+
return a.role === b.role && a.text === b.text && a.state === b.state && a.partial === b.partial && a.refusal === b.refusal && a.rejection?.code === b.rejection?.code && a.rejection?.message === b.rejection?.message;
|
|
145
|
+
if (a.type === "card" && b.type === "card")
|
|
146
|
+
return a.busy === b.busy && a.restored === b.restored && a.decisionError === b.decisionError && sameCard(a.card, b.card);
|
|
147
|
+
return a === b;
|
|
148
|
+
}
|
|
149
|
+
function sameCard(a, b) {
|
|
150
|
+
return a.card === b.card && a.seq === b.seq && a.turnId === b.turnId && a.outcome === b.outcome && a.locked.length === b.locked.length && a.locked.every((q, i) => q === b.locked[i]) && a.question?.state === b.question?.state && JSON.stringify(a.question?.answer) === JSON.stringify(b.question?.answer) && JSON.stringify(a.question?.row_answers) === JSON.stringify(b.question?.row_answers);
|
|
151
|
+
}
|
|
152
|
+
function persistable(item) {
|
|
153
|
+
if (item.type === "message")
|
|
154
|
+
return item.state === "sent" || item.state === "delivered";
|
|
155
|
+
return item.type === "card";
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// src/conversation/store.ts
|
|
159
|
+
var MAX_TEXT = 2e3;
|
|
160
|
+
var TURN_INPUTS = 50;
|
|
161
|
+
var PERSIST_DELAY = 500;
|
|
162
|
+
var PERSIST_VERSION = 1;
|
|
163
|
+
var NO_ITEMS = [];
|
|
164
|
+
var OUTCOMES = /* @__PURE__ */ new Set([
|
|
165
|
+
"confirmed",
|
|
166
|
+
"declined",
|
|
167
|
+
"failed",
|
|
168
|
+
"expired"
|
|
169
|
+
]);
|
|
170
|
+
var ConversationStore = class {
|
|
171
|
+
options;
|
|
172
|
+
engine = new ConversationEngine();
|
|
173
|
+
session = null;
|
|
174
|
+
info = null;
|
|
175
|
+
getting = null;
|
|
176
|
+
failed = false;
|
|
177
|
+
disposed = false;
|
|
178
|
+
online = null;
|
|
179
|
+
foreground = true;
|
|
180
|
+
lastStatus = "idle";
|
|
181
|
+
offlineAfterRetries = false;
|
|
182
|
+
/** The id the next send gets, so a text typed before the session keeps its key. */
|
|
183
|
+
nextId = null;
|
|
184
|
+
sent = /* @__PURE__ */ new Map();
|
|
185
|
+
early = /* @__PURE__ */ new Map();
|
|
186
|
+
retried = /* @__PURE__ */ new Set();
|
|
187
|
+
busy = /* @__PURE__ */ new Set();
|
|
188
|
+
/** Proposals whose tenant decision threw: shown as recoverable, the person may decide again. */
|
|
189
|
+
decisionErrors = /* @__PURE__ */ new Set();
|
|
190
|
+
system = [];
|
|
191
|
+
/** The person's text that started each recent turn, by `turn_id` (`turn.started` echoes its `client_event_id`). */
|
|
192
|
+
turnInputs = /* @__PURE__ */ new Map();
|
|
193
|
+
/** Numbers system rows that carry no id of their own. */
|
|
194
|
+
systemSeq = 0;
|
|
195
|
+
history = NO_ITEMS;
|
|
196
|
+
historyState = "none";
|
|
197
|
+
/** A resumed session.ready carried a transcript: the server's history replaced what the device kept. */
|
|
198
|
+
transcriptSeen = false;
|
|
199
|
+
/** The session of the last session.ready: a card of another one takes no tap (§6). */
|
|
200
|
+
readySession = null;
|
|
201
|
+
cardOrder = /* @__PURE__ */ new Map();
|
|
202
|
+
kept = [];
|
|
203
|
+
/** Cards an earlier session drew, kept by a merged transcript: drawn as restored, taking no tap. */
|
|
204
|
+
inert = /* @__PURE__ */ new Set();
|
|
205
|
+
locale;
|
|
206
|
+
/** The request for a session in flight, to abort when it is no longer wanted. */
|
|
207
|
+
fetching = null;
|
|
208
|
+
view;
|
|
209
|
+
listeners = /* @__PURE__ */ new Set();
|
|
210
|
+
streaming = null;
|
|
211
|
+
streamListeners = /* @__PURE__ */ new Set();
|
|
212
|
+
/** The frame that will notify, and what it will notify about. */
|
|
213
|
+
frame = void 0;
|
|
214
|
+
framePending = false;
|
|
215
|
+
viewDirty = false;
|
|
216
|
+
streamDirty = false;
|
|
217
|
+
persistTimer;
|
|
218
|
+
unsubscribe = [];
|
|
219
|
+
constructor(options) {
|
|
220
|
+
this.options = options;
|
|
221
|
+
this.locale = options.locale;
|
|
222
|
+
this.view = this.compute(null);
|
|
223
|
+
this.restore();
|
|
224
|
+
}
|
|
225
|
+
// ------------------------------------------------------------ reading
|
|
226
|
+
getSnapshot = () => this.view;
|
|
227
|
+
subscribe = (listener) => {
|
|
228
|
+
this.listeners.add(listener);
|
|
229
|
+
return () => this.listeners.delete(listener);
|
|
230
|
+
};
|
|
231
|
+
getStreaming = () => this.streaming;
|
|
232
|
+
subscribeStreaming = (listener) => {
|
|
233
|
+
this.streamListeners.add(listener);
|
|
234
|
+
return () => this.streamListeners.delete(listener);
|
|
235
|
+
};
|
|
236
|
+
/** Whether a card's link may be opened (the deep-link rule, against `deepLinkPrefixes`). */
|
|
237
|
+
checkLink(link) {
|
|
238
|
+
return isAllowedLink(link, this.options.deepLinkPrefixes ?? []);
|
|
239
|
+
}
|
|
240
|
+
// ------------------------------------------------------------ the conversation's life
|
|
241
|
+
/** Ask for a session and connect, early: a client secret must open its first connection within two minutes. */
|
|
242
|
+
start() {
|
|
243
|
+
this.live();
|
|
244
|
+
if (this.session) {
|
|
245
|
+
if (this.session.status === "idle") this.session.connect();
|
|
246
|
+
else if (this.session.status === "unauthorized")
|
|
247
|
+
void this.resumeWith("resume");
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
void this.ensureSession("open");
|
|
251
|
+
}
|
|
252
|
+
/** Close for good. */
|
|
253
|
+
dispose() {
|
|
254
|
+
if (this.disposed) return;
|
|
255
|
+
this.disposed = true;
|
|
256
|
+
this.fetching?.abort();
|
|
257
|
+
this.fetching = null;
|
|
258
|
+
this.session?.close();
|
|
259
|
+
for (const off of this.unsubscribe) off();
|
|
260
|
+
this.unsubscribe = [];
|
|
261
|
+
clearTimeout(this.persistTimer);
|
|
262
|
+
this.update();
|
|
263
|
+
this.flush();
|
|
264
|
+
}
|
|
265
|
+
/** Close for good and forget the conversation kept on the device (sign-out). Never throws for the storage. */
|
|
266
|
+
clear() {
|
|
267
|
+
this.dispose();
|
|
268
|
+
this.history = NO_ITEMS;
|
|
269
|
+
try {
|
|
270
|
+
const removed = this.options.persistence?.removeItem(this.key());
|
|
271
|
+
if (removed) removed.catch(() => void 0);
|
|
272
|
+
} catch {
|
|
273
|
+
}
|
|
274
|
+
this.update();
|
|
275
|
+
this.flush();
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* The person switched the app's language: the UI's words change at once (the kit's own locale); the next session
|
|
279
|
+
* asks for this locale (`subject.locale`). The open session keeps its language: the agent answers in the new one
|
|
280
|
+
* from the next session, and the store never starts a session for it.
|
|
281
|
+
*/
|
|
282
|
+
setLocale(locale) {
|
|
283
|
+
this.locale = locale;
|
|
284
|
+
}
|
|
285
|
+
/** The app came to the foreground or went to the background (AppState, `visibilitychange`). */
|
|
286
|
+
setForeground(foreground) {
|
|
287
|
+
this.foreground = foreground;
|
|
288
|
+
if (this.options.closeInBackground && !this.disposed) {
|
|
289
|
+
if (!foreground) {
|
|
290
|
+
this.suspend();
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
if (this.suspended) {
|
|
294
|
+
void this.wake();
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
this.session?.setForeground(foreground);
|
|
299
|
+
if (foreground) this.reconnectIfDown();
|
|
300
|
+
}
|
|
301
|
+
/** In the background (`closeInBackground`): the connection is closed; the engine keeps what was sent, to resend. */
|
|
302
|
+
suspended = false;
|
|
303
|
+
suspend() {
|
|
304
|
+
const session = this.session;
|
|
305
|
+
if (!session || this.suspended || session.status === "ended") return;
|
|
306
|
+
this.suspended = true;
|
|
307
|
+
for (const off of this.unsubscribe) off();
|
|
308
|
+
this.unsubscribe = [];
|
|
309
|
+
session.close();
|
|
310
|
+
this.update();
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Back in the foreground, or the person acted while away: a new connection to the same session, with a fresh secret
|
|
314
|
+
* when the backend gives one. A session that passed its end meanwhile is never reopened (a fresh secret cannot bring
|
|
315
|
+
* back an expired tool token): the person's action continues the conversation in a new session from `getSession`,
|
|
316
|
+
* and until then nothing connects.
|
|
317
|
+
*/
|
|
318
|
+
async wake(byPerson = false) {
|
|
319
|
+
const info = this.info;
|
|
320
|
+
if (info && this.sessionOver()) {
|
|
321
|
+
if (byPerson) await this.resumeWith("resume");
|
|
322
|
+
else this.update();
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
this.suspended = false;
|
|
326
|
+
if (!info) return;
|
|
327
|
+
let next = info;
|
|
328
|
+
if (this.options.refreshSecret) {
|
|
329
|
+
try {
|
|
330
|
+
next = {
|
|
331
|
+
...info,
|
|
332
|
+
client_secret: await this.options.refreshSecret(info.id)
|
|
333
|
+
};
|
|
334
|
+
} catch (error) {
|
|
335
|
+
this.options.onError?.(error);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
if (this.disposed || this.suspended) return;
|
|
339
|
+
this.open(next);
|
|
340
|
+
}
|
|
341
|
+
/** The network as the app sees it; null when unknown. Back online reconnects a connection that gave up. */
|
|
342
|
+
setOnline(online) {
|
|
343
|
+
this.online = online;
|
|
344
|
+
if (online) this.reconnectIfDown();
|
|
345
|
+
this.update();
|
|
346
|
+
}
|
|
347
|
+
// ------------------------------------------------------------ the person's actions
|
|
348
|
+
/** Send a typed message; returns its `client_event_id`. Typed before a session exists, it is sent once one does. */
|
|
349
|
+
send(text) {
|
|
350
|
+
this.live();
|
|
351
|
+
if (!text.trim()) throw new RangeError("A message needs some text.");
|
|
352
|
+
if ([...text].length > MAX_TEXT)
|
|
353
|
+
throw new RangeError(`A message is at most ${MAX_TEXT} characters.`);
|
|
354
|
+
const id = (this.options.createId ?? randomId)();
|
|
355
|
+
this.sent.set(id, { text, order: lastOrder(this.engine.state()) + 0.5 });
|
|
356
|
+
if (!this.session) {
|
|
357
|
+
this.early.set(id, text);
|
|
358
|
+
void this.ensureSession("open");
|
|
359
|
+
this.update();
|
|
360
|
+
return id;
|
|
361
|
+
}
|
|
362
|
+
if (this.suspended) {
|
|
363
|
+
this.early.set(id, text);
|
|
364
|
+
void this.wake(true);
|
|
365
|
+
this.update();
|
|
366
|
+
return id;
|
|
367
|
+
}
|
|
368
|
+
if (this.nextFrom(this.engine.state()) === "continue_here") {
|
|
369
|
+
this.early.set(id, text);
|
|
370
|
+
void this.resumeWith("continue_here");
|
|
371
|
+
this.update();
|
|
372
|
+
return id;
|
|
373
|
+
}
|
|
374
|
+
if (this.sessionOver()) {
|
|
375
|
+
this.early.set(id, text);
|
|
376
|
+
void this.resumeWith("resume");
|
|
377
|
+
this.update();
|
|
378
|
+
return id;
|
|
379
|
+
}
|
|
380
|
+
this.nextId = id;
|
|
381
|
+
this.session.sendText(text);
|
|
382
|
+
this.nextId = null;
|
|
383
|
+
this.continueIfEnded();
|
|
384
|
+
return id;
|
|
385
|
+
}
|
|
386
|
+
/** Answer a question card (a `rate_each` row by its `question_id`); returns the `client_event_id`. */
|
|
387
|
+
answer(cardId, questionId, answer) {
|
|
388
|
+
const session = this.required();
|
|
389
|
+
const id = session.answer(cardId, questionId, answer);
|
|
390
|
+
this.continueIfEnded();
|
|
391
|
+
return id;
|
|
392
|
+
}
|
|
393
|
+
/** A card action (`ui.action`) other than a proposal's outcome. */
|
|
394
|
+
cardAction(cardId, action) {
|
|
395
|
+
return this.required().action(cardId, action);
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* Decide a proposal: `decide` is the tenant's own call (its backend, the person's token), which confirms or
|
|
399
|
+
* declines it and says the outcome; the store then reports it. One decision per card; the card is busy meanwhile.
|
|
400
|
+
* Resolves to the outcome reported, or null when the card was busy or already decided.
|
|
401
|
+
*
|
|
402
|
+
* Only an outcome `decide` resolves with is reported (§6: the first is final). When `decide` throws, or resolves
|
|
403
|
+
* with anything but `confirmed`, `declined`, `failed` or `expired`, nothing is reported: the backend may have
|
|
404
|
+
* applied the change, so the outcome stays unknown; the card shows `decisionError` and the person may decide
|
|
405
|
+
* again. When the conversation is disposed meanwhile (sign-out), the outcome is not reported either: the tenant's
|
|
406
|
+
* backend holds it, and the session takes it as unknown.
|
|
407
|
+
*/
|
|
408
|
+
async decideProposal(cardId, decide) {
|
|
409
|
+
this.live();
|
|
410
|
+
const card = this.engine.state().cards.find((c) => c.id === cardId);
|
|
411
|
+
if (card?.kind !== "proposal") return null;
|
|
412
|
+
if (this.busy.has(cardId) || (card.outcome ?? null) !== null) return null;
|
|
413
|
+
this.busy.add(cardId);
|
|
414
|
+
this.decisionErrors.delete(cardId);
|
|
415
|
+
this.update();
|
|
416
|
+
try {
|
|
417
|
+
const outcome = await decide();
|
|
418
|
+
if (typeof outcome !== "string" || !OUTCOMES.has(outcome))
|
|
419
|
+
throw new TypeError(
|
|
420
|
+
`decide() resolved with ${JSON.stringify(outcome)}, not confirmed, declined, failed or expired`
|
|
421
|
+
);
|
|
422
|
+
if (this.disposed) return outcome;
|
|
423
|
+
if (this.view.next === "continue") await this.resumeWith("resume");
|
|
424
|
+
this.required().reportOutcome(cardId, outcome);
|
|
425
|
+
return outcome;
|
|
426
|
+
} catch (error) {
|
|
427
|
+
this.decisionErrors.add(cardId);
|
|
428
|
+
throw error;
|
|
429
|
+
} finally {
|
|
430
|
+
this.busy.delete(cardId);
|
|
431
|
+
this.update();
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
/** Send a "not sent" message again, as a new message (the person's action). Returns the new id. */
|
|
435
|
+
retry(key) {
|
|
436
|
+
const sent = this.sent.get(key);
|
|
437
|
+
if (!sent || this.engine.state().refused[key] === void 0) return null;
|
|
438
|
+
this.retried.add(key);
|
|
439
|
+
this.sent.delete(key);
|
|
440
|
+
return this.send(sent.text);
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* Send again the person's text whose turn failed before any reply (a `turn_failed` row with `data.retry`), as a new
|
|
444
|
+
* message (the person's action). Once per row: its `retry` turns false. Returns the new id, or null when the row
|
|
445
|
+
* allows no retry.
|
|
446
|
+
*/
|
|
447
|
+
retryTurn(key) {
|
|
448
|
+
const at = this.system.findIndex(
|
|
449
|
+
(s) => s.item.key === key && s.item.event === "turn_failed"
|
|
450
|
+
);
|
|
451
|
+
const entry = this.system[at];
|
|
452
|
+
if (entry?.item.data.retry !== true) return null;
|
|
453
|
+
const text = this.turnText(String(entry.item.data.turn ?? ""));
|
|
454
|
+
if (text === void 0) return null;
|
|
455
|
+
this.system = this.system.map(
|
|
456
|
+
(s, i) => i === at ? { ...s, item: { ...s.item, data: { ...s.item.data, retry: false } } } : s
|
|
457
|
+
);
|
|
458
|
+
return this.send(text);
|
|
459
|
+
}
|
|
460
|
+
/** After the conversation continued on another device: continue it here (the person's choice). */
|
|
461
|
+
continueHere() {
|
|
462
|
+
return this.resumeWith("continue_here");
|
|
463
|
+
}
|
|
464
|
+
/** Stop the turn in progress. */
|
|
465
|
+
cancel() {
|
|
466
|
+
const turn = this.engine.state().turn;
|
|
467
|
+
if (this.session && turn) this.session.cancel(turn);
|
|
468
|
+
}
|
|
469
|
+
// ------------------------------------------------------------ sessions
|
|
470
|
+
async ensureSession(reason) {
|
|
471
|
+
if (this.session) return this.session;
|
|
472
|
+
if (this.getting) return this.getting;
|
|
473
|
+
this.getting = this.fetchSession(reason).then((info) => {
|
|
474
|
+
this.getting = null;
|
|
475
|
+
if (!info || this.disposed) return null;
|
|
476
|
+
this.open(info);
|
|
477
|
+
return this.session;
|
|
478
|
+
});
|
|
479
|
+
this.update();
|
|
480
|
+
return this.getting;
|
|
481
|
+
}
|
|
482
|
+
async fetchSession(reason) {
|
|
483
|
+
this.failed = false;
|
|
484
|
+
this.fetching?.abort();
|
|
485
|
+
const Controller = globalThis.AbortController;
|
|
486
|
+
const controller = Controller ? new Controller() : null;
|
|
487
|
+
const mine = { abort: () => controller?.abort() };
|
|
488
|
+
this.fetching = mine;
|
|
489
|
+
let aborted = false;
|
|
490
|
+
const signal = controller?.signal;
|
|
491
|
+
signal?.addEventListener("abort", () => {
|
|
492
|
+
aborted = true;
|
|
493
|
+
});
|
|
494
|
+
try {
|
|
495
|
+
const info = await this.options.getSession({
|
|
496
|
+
reason,
|
|
497
|
+
conversationId: this.engine.state().conversationId,
|
|
498
|
+
...this.locale !== void 0 ? { locale: this.locale } : {},
|
|
499
|
+
...this.options.timeZone !== void 0 ? { timeZone: this.options.timeZone } : {},
|
|
500
|
+
...signal ? { signal } : {}
|
|
501
|
+
});
|
|
502
|
+
return aborted || this.fetching !== mine ? null : info;
|
|
503
|
+
} catch (error) {
|
|
504
|
+
if (aborted || this.fetching !== mine) return null;
|
|
505
|
+
this.failed = true;
|
|
506
|
+
this.getting = null;
|
|
507
|
+
this.options.onError?.(error);
|
|
508
|
+
this.update();
|
|
509
|
+
return null;
|
|
510
|
+
} finally {
|
|
511
|
+
if (this.fetching === mine) this.fetching = null;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
open(info) {
|
|
515
|
+
this.info = info;
|
|
516
|
+
this.suspended = false;
|
|
517
|
+
const refreshSecret = this.options.refreshSecret;
|
|
518
|
+
const session = new VinaverseSession({
|
|
519
|
+
...this.options.transport,
|
|
520
|
+
session: info,
|
|
521
|
+
engine: this.engine,
|
|
522
|
+
...refreshSecret ? {
|
|
523
|
+
// Past the session's end a fresh secret cannot help: refused here, and the store rolls over instead.
|
|
524
|
+
refreshSecret: (id) => this.sessionOver() ? Promise.reject(
|
|
525
|
+
new VinaverseClientError(
|
|
526
|
+
"needs_new_session",
|
|
527
|
+
"This session has ended."
|
|
528
|
+
)
|
|
529
|
+
) : refreshSecret(id)
|
|
530
|
+
} : {},
|
|
531
|
+
createId: () => this.nextId ?? (this.options.createId ?? randomId)()
|
|
532
|
+
});
|
|
533
|
+
this.session = session;
|
|
534
|
+
this.unsubscribe.push(
|
|
535
|
+
session.on("state", () => this.update()),
|
|
536
|
+
session.on("status", (status) => this.statusChanged(status)),
|
|
537
|
+
session.on("event", (event) => this.event(event)),
|
|
538
|
+
session.on("error", (error) => {
|
|
539
|
+
if (!this.sessionOver()) this.options.onError?.(error);
|
|
540
|
+
})
|
|
541
|
+
);
|
|
542
|
+
session.setForeground(this.foreground);
|
|
543
|
+
session.connect();
|
|
544
|
+
this.flushEarly();
|
|
545
|
+
}
|
|
546
|
+
/** What was typed before there was a session to take it. */
|
|
547
|
+
flushEarly() {
|
|
548
|
+
const session = this.session;
|
|
549
|
+
if (!session) return;
|
|
550
|
+
for (const [id, text] of this.early) {
|
|
551
|
+
this.nextId = id;
|
|
552
|
+
session.sendText(text);
|
|
553
|
+
}
|
|
554
|
+
this.nextId = null;
|
|
555
|
+
this.early.clear();
|
|
556
|
+
this.update();
|
|
557
|
+
}
|
|
558
|
+
/** A new session for the conversation, then `resume()`: once per person action, never in a loop. */
|
|
559
|
+
resuming = null;
|
|
560
|
+
resumeWith(reason) {
|
|
561
|
+
this.live();
|
|
562
|
+
if (this.resuming) return this.resuming;
|
|
563
|
+
if (!this.session) return this.ensureSession(reason).then(() => void 0);
|
|
564
|
+
this.resuming = this.fetchSession(reason).then((info) => {
|
|
565
|
+
this.resuming = null;
|
|
566
|
+
if (!info || this.disposed || !this.session) return;
|
|
567
|
+
if (this.session.status === "closed") {
|
|
568
|
+
this.open(info);
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
this.info = info;
|
|
572
|
+
this.session.resume(info);
|
|
573
|
+
this.flushEarly();
|
|
574
|
+
});
|
|
575
|
+
return this.resuming;
|
|
576
|
+
}
|
|
577
|
+
/** The person acted after the session ended idle, expired or at its limit: the next session takes what they sent. */
|
|
578
|
+
continueIfEnded() {
|
|
579
|
+
if (this.nextFrom(this.engine.state()) === "continue")
|
|
580
|
+
void this.resumeWith("resume");
|
|
581
|
+
this.update();
|
|
582
|
+
}
|
|
583
|
+
reconnectIfDown() {
|
|
584
|
+
const session = this.session;
|
|
585
|
+
if (session && session.status === "idle" && !this.sessionOver() && // past its end: the person's next action gets a new session
|
|
586
|
+
this.nextFrom(this.engine.state()) === null)
|
|
587
|
+
session.connect();
|
|
588
|
+
}
|
|
589
|
+
/** A refusal already led to a new session, and none has been ready since: another refusal is no cue. */
|
|
590
|
+
rolled = false;
|
|
591
|
+
statusChanged(status) {
|
|
592
|
+
if (status === "ready") this.rolled = false;
|
|
593
|
+
if (status === "unauthorized" && !this.rolled) {
|
|
594
|
+
this.rolled = true;
|
|
595
|
+
void this.resumeWith("resume");
|
|
596
|
+
}
|
|
597
|
+
if (status === "idle")
|
|
598
|
+
this.offlineAfterRetries = this.lastStatus === "reconnecting";
|
|
599
|
+
else if (status !== "reconnecting") this.offlineAfterRetries = false;
|
|
600
|
+
this.lastStatus = status;
|
|
601
|
+
this.update();
|
|
602
|
+
}
|
|
603
|
+
event(event) {
|
|
604
|
+
const frame = event;
|
|
605
|
+
const str = (v) => typeof v === "string" ? v : null;
|
|
606
|
+
const add = (key, kind, data) => {
|
|
607
|
+
if (this.system.some((s) => s.item.key === key)) return;
|
|
608
|
+
this.system = [
|
|
609
|
+
...this.system,
|
|
610
|
+
{
|
|
611
|
+
item: { type: "system", key, event: kind, data },
|
|
612
|
+
order: lastOrder(this.engine.state()) + 0.75
|
|
613
|
+
}
|
|
614
|
+
];
|
|
615
|
+
};
|
|
616
|
+
switch (frame.type) {
|
|
617
|
+
case "session.ready": {
|
|
618
|
+
const session = frame.session?.id;
|
|
619
|
+
const moved = typeof session === "string" && session !== this.readySession;
|
|
620
|
+
if (typeof session === "string") this.readySession = session;
|
|
621
|
+
if (frame.resumed === true && Array.isArray(frame.transcript) && !transcriptHasIds(frame.transcript)) {
|
|
622
|
+
this.transcriptSeen = true;
|
|
623
|
+
this.anchorCards(moved);
|
|
624
|
+
} else if (moved && frame.resumed === true) {
|
|
625
|
+
const pending = new Set(
|
|
626
|
+
(Array.isArray(frame.pending_cards) ? frame.pending_cards : []).map(
|
|
627
|
+
(c) => str(c.id)
|
|
628
|
+
)
|
|
629
|
+
);
|
|
630
|
+
for (const card of this.engine.state().cards)
|
|
631
|
+
if (pending.has(card.id)) this.inert.delete(card.id);
|
|
632
|
+
else this.inert.add(card.id);
|
|
633
|
+
}
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
// `at` is required (v1-draft.15); the same takeover announced again after a reconnect has the same `at`, so it
|
|
637
|
+
// draws one row. A frame without one still gets a row of its own.
|
|
638
|
+
case "takeover.started":
|
|
639
|
+
add(
|
|
640
|
+
`takeover:${str(frame.at) ?? `#${++this.systemSeq}`}`,
|
|
641
|
+
"takeover_started",
|
|
642
|
+
{ by: str(frame.by) }
|
|
643
|
+
);
|
|
644
|
+
return;
|
|
645
|
+
case "takeover.ended":
|
|
646
|
+
add(
|
|
647
|
+
`takeover-ended:${str(frame.at) ?? `#${++this.systemSeq}`}`,
|
|
648
|
+
"takeover_ended",
|
|
649
|
+
{}
|
|
650
|
+
);
|
|
651
|
+
return;
|
|
652
|
+
case "handoff.created": {
|
|
653
|
+
const ticket = frame.ticket;
|
|
654
|
+
add(`handoff:${str(ticket?.id) ?? ""}`, "handoff_created", {
|
|
655
|
+
expected_response: str(ticket?.expected_response)
|
|
656
|
+
});
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
case "flow.completed":
|
|
660
|
+
add(`flow:#${++this.systemSeq}`, "flow_completed", {
|
|
661
|
+
result: str(frame.result),
|
|
662
|
+
code: str(frame.code)
|
|
663
|
+
});
|
|
664
|
+
return;
|
|
665
|
+
case "turn.started": {
|
|
666
|
+
const turn = str(frame.turn_id);
|
|
667
|
+
const input = str(frame.client_event_id);
|
|
668
|
+
if (turn === null || input === null) return;
|
|
669
|
+
this.turnInputs.set(turn, input);
|
|
670
|
+
if (this.turnInputs.size > TURN_INPUTS) {
|
|
671
|
+
const oldest = this.turnInputs.keys().next().value;
|
|
672
|
+
if (oldest !== void 0) this.turnInputs.delete(oldest);
|
|
673
|
+
}
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
case "turn.failed": {
|
|
677
|
+
const turn = str(frame.turn_id) ?? "";
|
|
678
|
+
if (this.engine.state().messages.some((m) => m.key === `a:${turn}`))
|
|
679
|
+
return;
|
|
680
|
+
if (frame.code === "cancelled") return;
|
|
681
|
+
add(`failed:${turn}`, "turn_failed", {
|
|
682
|
+
code: str(frame.code),
|
|
683
|
+
message: str(frame.message),
|
|
684
|
+
retryable: frame.retryable === true,
|
|
685
|
+
retry: frame.retryable === true && this.turnText(turn) !== void 0,
|
|
686
|
+
turn
|
|
687
|
+
});
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
default:
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
/** The person's text that started a turn, while its message is on screen. */
|
|
695
|
+
turnText(turn) {
|
|
696
|
+
const input = this.turnInputs.get(turn);
|
|
697
|
+
if (input === void 0) return void 0;
|
|
698
|
+
const message = this.engine.state().messages.find((m) => m.key === input && m.role === "person");
|
|
699
|
+
return message?.text || void 0;
|
|
700
|
+
}
|
|
701
|
+
/**
|
|
702
|
+
* A resumed session's transcript replaced the messages, and a chat transcript carries text only: the engine drops
|
|
703
|
+
* the cards it drew, and draws the pending ones again at the end. Each card on screen is anchored just after the
|
|
704
|
+
* message it followed (by key; the transcript keeps the live keys), so a pending proposal stays where it was, and a
|
|
705
|
+
* card the transcript dropped stays drawn there; taking no tap when it belongs to an earlier session.
|
|
706
|
+
*/
|
|
707
|
+
anchorCards(sessionChanged) {
|
|
708
|
+
const state = this.engine.state();
|
|
709
|
+
const seqOf = new Map(state.messages.map((m) => [m.key, m.seq]));
|
|
710
|
+
const first = Math.min(...state.messages.map((m) => m.seq), 0);
|
|
711
|
+
const held = new Set(state.cards.map((c) => c.id));
|
|
712
|
+
const order = /* @__PURE__ */ new Map();
|
|
713
|
+
const kept = [];
|
|
714
|
+
let anchor = first - 1;
|
|
715
|
+
let step = 0;
|
|
716
|
+
for (const item of this.view.items) {
|
|
717
|
+
if (item.type === "message" && seqOf.has(item.key)) {
|
|
718
|
+
anchor = seqOf.get(item.key);
|
|
719
|
+
step = 0;
|
|
720
|
+
} else if (item.type === "card") {
|
|
721
|
+
const at = anchor + 0.5 + ++step / 1e3;
|
|
722
|
+
order.set(item.key, at);
|
|
723
|
+
if (!held.has(item.key))
|
|
724
|
+
kept.push({
|
|
725
|
+
item: sessionChanged ? { ...item, busy: false, restored: true } : item,
|
|
726
|
+
order: at
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
this.cardOrder = order;
|
|
731
|
+
this.kept = kept;
|
|
732
|
+
}
|
|
733
|
+
// ------------------------------------------------------------ the view
|
|
734
|
+
/**
|
|
735
|
+
* Recomputes now (getSnapshot and getStreaming are always current) and notifies from the scheduler, at most once
|
|
736
|
+
* per frame, both channels in the same flush: a streamed reply that completes becomes its message in one frame.
|
|
737
|
+
*/
|
|
738
|
+
update() {
|
|
739
|
+
const state = this.engine.state();
|
|
740
|
+
this.stream(state);
|
|
741
|
+
const view = this.compute(state);
|
|
742
|
+
if (view !== this.view) {
|
|
743
|
+
this.view = view;
|
|
744
|
+
this.viewDirty = true;
|
|
745
|
+
this.schedulePersist();
|
|
746
|
+
}
|
|
747
|
+
if (this.viewDirty || this.streamDirty) this.scheduleNotify();
|
|
748
|
+
}
|
|
749
|
+
scheduleNotify() {
|
|
750
|
+
if (this.framePending || this.disposed) return;
|
|
751
|
+
if (this.listeners.size === 0 && this.streamListeners.size === 0) return;
|
|
752
|
+
this.framePending = true;
|
|
753
|
+
this.frame = this.scheduler().request(() => {
|
|
754
|
+
this.frame = void 0;
|
|
755
|
+
this.framePending = false;
|
|
756
|
+
this.flush();
|
|
757
|
+
});
|
|
758
|
+
}
|
|
759
|
+
/** Calls the listeners of what changed since the last flush. */
|
|
760
|
+
flush() {
|
|
761
|
+
if (this.frame !== void 0) {
|
|
762
|
+
this.scheduler().cancel(this.frame);
|
|
763
|
+
this.frame = void 0;
|
|
764
|
+
this.framePending = false;
|
|
765
|
+
}
|
|
766
|
+
const view = this.viewDirty;
|
|
767
|
+
const stream = this.streamDirty;
|
|
768
|
+
this.viewDirty = false;
|
|
769
|
+
this.streamDirty = false;
|
|
770
|
+
if (stream) for (const l of [...this.streamListeners]) l();
|
|
771
|
+
if (view) for (const l of [...this.listeners]) l();
|
|
772
|
+
}
|
|
773
|
+
compute(state) {
|
|
774
|
+
const s = state ?? this.engine.state();
|
|
775
|
+
const prev = this.view;
|
|
776
|
+
for (const id of [...this.sent.keys()])
|
|
777
|
+
if (!this.early.has(id) && s.messages.some((m) => m.key === id))
|
|
778
|
+
this.sent.delete(id);
|
|
779
|
+
const items = buildTimeline(
|
|
780
|
+
{
|
|
781
|
+
state: s,
|
|
782
|
+
history: this.history,
|
|
783
|
+
sent: this.sent,
|
|
784
|
+
early: new Set(this.early.keys()),
|
|
785
|
+
retried: this.retried,
|
|
786
|
+
busy: this.busy,
|
|
787
|
+
decisionErrors: this.decisionErrors,
|
|
788
|
+
transcriptSeen: this.transcriptSeen,
|
|
789
|
+
cardOrder: this.cardOrder,
|
|
790
|
+
kept: this.kept,
|
|
791
|
+
inert: this.inert,
|
|
792
|
+
system: this.system
|
|
793
|
+
},
|
|
794
|
+
prev?.items ?? NO_ITEMS
|
|
795
|
+
);
|
|
796
|
+
const status = this.status();
|
|
797
|
+
const next = this.nextFrom(s);
|
|
798
|
+
const activity = s.turn === null ? [] : s.tools.filter((t) => t.turn_id === s.turn);
|
|
799
|
+
const name = this.info?.agent?.display_name ?? null;
|
|
800
|
+
const avatarUrl = this.info?.agent?.avatar_url ?? null;
|
|
801
|
+
const view = {
|
|
802
|
+
status,
|
|
803
|
+
conversationId: s.conversationId,
|
|
804
|
+
banner: banner(status, next),
|
|
805
|
+
next,
|
|
806
|
+
agent: sameAgent(prev?.agent, name, avatarUrl) ? prev.agent : { name, avatarUrl },
|
|
807
|
+
disclosure: s.disclosure ?? this.info?.disclosure ?? null,
|
|
808
|
+
welcome: this.info?.welcome ?? null,
|
|
809
|
+
takeover: s.takeover,
|
|
810
|
+
items,
|
|
811
|
+
activity: prev && sameTools(prev.activity, activity) ? prev.activity : activity,
|
|
812
|
+
responding: s.turn !== null || s.streaming !== null,
|
|
813
|
+
openQuestion: s.openQuestion,
|
|
814
|
+
answersNeedWebSocket: status === "sse",
|
|
815
|
+
turnsLeft: s.turnsLeft,
|
|
816
|
+
history: this.historyState
|
|
817
|
+
};
|
|
818
|
+
if (prev && shallowEqual(prev, view)) return prev;
|
|
819
|
+
return view;
|
|
820
|
+
}
|
|
821
|
+
status() {
|
|
822
|
+
if (this.disposed) return "closed";
|
|
823
|
+
if (this.suspended) return "idle";
|
|
824
|
+
const session = this.session;
|
|
825
|
+
if (!session) {
|
|
826
|
+
if (this.failed) return "unavailable";
|
|
827
|
+
return this.getting || this.early.size ? "starting" : "idle";
|
|
828
|
+
}
|
|
829
|
+
const status = session.status;
|
|
830
|
+
if (this.failed && status === "ended") return "unavailable";
|
|
831
|
+
if (status === "idle")
|
|
832
|
+
return this.offlineAfterRetries || this.online === false ? "offline" : "idle";
|
|
833
|
+
if (status === "reconnecting" && this.online === false) return "offline";
|
|
834
|
+
return status;
|
|
835
|
+
}
|
|
836
|
+
nextFrom(state) {
|
|
837
|
+
if (this.session?.status !== "ended" && state.reconnect === "allowed")
|
|
838
|
+
return null;
|
|
839
|
+
if (state.reconnect === "new_session_on_next_action") return "continue";
|
|
840
|
+
if (state.reconnect === "user_action_only") return "continue_here";
|
|
841
|
+
return null;
|
|
842
|
+
}
|
|
843
|
+
/** The streamed text, current at once; its listeners hear of it with the next flush. */
|
|
844
|
+
stream(state) {
|
|
845
|
+
const s = state.streaming;
|
|
846
|
+
if (!s) {
|
|
847
|
+
if (this.streaming !== null) {
|
|
848
|
+
this.streaming = null;
|
|
849
|
+
this.streamDirty = true;
|
|
850
|
+
}
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
853
|
+
if (this.streaming?.turnId === s.turnId && this.streaming.text === s.text)
|
|
854
|
+
return;
|
|
855
|
+
this.streaming = { key: `a:${s.turnId}`, turnId: s.turnId, text: s.text };
|
|
856
|
+
this.streamDirty = true;
|
|
857
|
+
}
|
|
858
|
+
scheduler() {
|
|
859
|
+
return this.options.scheduler ?? defaultScheduler;
|
|
860
|
+
}
|
|
861
|
+
// ------------------------------------------------------------ persistence
|
|
862
|
+
key() {
|
|
863
|
+
return this.options.persistenceKey ?? "vinaverse.conversation";
|
|
864
|
+
}
|
|
865
|
+
restore() {
|
|
866
|
+
const store = this.options.persistence;
|
|
867
|
+
if (!store) return;
|
|
868
|
+
const done = () => {
|
|
869
|
+
this.historyState = "restored";
|
|
870
|
+
this.update();
|
|
871
|
+
};
|
|
872
|
+
const apply = (raw) => {
|
|
873
|
+
if (this.disposed) return;
|
|
874
|
+
if (raw === null) return done();
|
|
875
|
+
try {
|
|
876
|
+
const saved = JSON.parse(raw);
|
|
877
|
+
if (saved.v === PERSIST_VERSION && Array.isArray(saved.items))
|
|
878
|
+
this.history = saved.items.map(
|
|
879
|
+
(item) => item.type === "card" ? { ...item, busy: false, restored: true } : item
|
|
880
|
+
);
|
|
881
|
+
} catch {
|
|
882
|
+
}
|
|
883
|
+
done();
|
|
884
|
+
};
|
|
885
|
+
try {
|
|
886
|
+
const raw = store.getItem(this.key());
|
|
887
|
+
if (typeof raw === "string" || raw === null) apply(raw);
|
|
888
|
+
else {
|
|
889
|
+
this.historyState = "restoring";
|
|
890
|
+
this.view = this.compute(null);
|
|
891
|
+
raw.then(apply, () => {
|
|
892
|
+
if (!this.disposed) done();
|
|
893
|
+
});
|
|
894
|
+
}
|
|
895
|
+
} catch {
|
|
896
|
+
done();
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
schedulePersist() {
|
|
900
|
+
const store = this.options.persistence;
|
|
901
|
+
if (!store || this.disposed || this.persistTimer !== void 0) return;
|
|
902
|
+
this.persistTimer = setTimeout(() => {
|
|
903
|
+
this.persistTimer = void 0;
|
|
904
|
+
const items = this.view.items.filter(persistable).slice(-100);
|
|
905
|
+
try {
|
|
906
|
+
void store.setItem(
|
|
907
|
+
this.key(),
|
|
908
|
+
JSON.stringify({ v: PERSIST_VERSION, items })
|
|
909
|
+
);
|
|
910
|
+
} catch {
|
|
911
|
+
}
|
|
912
|
+
}, PERSIST_DELAY);
|
|
913
|
+
}
|
|
914
|
+
// ------------------------------------------------------------ guards
|
|
915
|
+
/** Past the session's end (`expires_at`), when the backend passed it on. */
|
|
916
|
+
sessionOver() {
|
|
917
|
+
const end = this.info?.expires_at;
|
|
918
|
+
return end !== void 0 && (this.options.clock?.now() ?? Date.now()) >= Date.parse(end);
|
|
919
|
+
}
|
|
920
|
+
live() {
|
|
921
|
+
if (this.disposed)
|
|
922
|
+
throw new VinaverseClientError(
|
|
923
|
+
"session_ended",
|
|
924
|
+
"This conversation was closed."
|
|
925
|
+
);
|
|
926
|
+
}
|
|
927
|
+
required() {
|
|
928
|
+
this.live();
|
|
929
|
+
if (!this.session)
|
|
930
|
+
throw new VinaverseClientError(
|
|
931
|
+
"session_ended",
|
|
932
|
+
"The conversation has no session yet: start() it first."
|
|
933
|
+
);
|
|
934
|
+
return this.session;
|
|
935
|
+
}
|
|
936
|
+
};
|
|
937
|
+
function createConversation(options) {
|
|
938
|
+
return new ConversationStore(options);
|
|
939
|
+
}
|
|
940
|
+
var defaultScheduler = {
|
|
941
|
+
request(callback) {
|
|
942
|
+
const raf = globalThis.requestAnimationFrame;
|
|
943
|
+
return raf ? { raf: raf(callback) } : { timer: setTimeout(callback, 16) };
|
|
944
|
+
},
|
|
945
|
+
cancel(handle) {
|
|
946
|
+
const h = handle;
|
|
947
|
+
const caf = globalThis.cancelAnimationFrame;
|
|
948
|
+
if (h.raf !== void 0) caf?.(h.raf);
|
|
949
|
+
if (h.timer !== void 0) clearTimeout(h.timer);
|
|
950
|
+
}
|
|
951
|
+
};
|
|
952
|
+
function banner(status, next) {
|
|
953
|
+
if (status === "connecting" || status === "starting") return "connecting";
|
|
954
|
+
if (status === "reconnecting") return "reconnecting";
|
|
955
|
+
if (status === "offline") return "offline";
|
|
956
|
+
if (status === "unavailable" || status === "unauthorized")
|
|
957
|
+
return "unavailable";
|
|
958
|
+
if (next === "continue_here") return "continued_elsewhere";
|
|
959
|
+
return null;
|
|
960
|
+
}
|
|
961
|
+
function sameAgent(agent, name, avatarUrl) {
|
|
962
|
+
return agent?.name === name && agent?.avatarUrl === avatarUrl;
|
|
963
|
+
}
|
|
964
|
+
function sameTools(a, b) {
|
|
965
|
+
return a.length === b.length && a.every(
|
|
966
|
+
(t, i) => t.turn_id === b[i]?.turn_id && t.label === b[i]?.label && t.state === b[i]?.state
|
|
967
|
+
);
|
|
968
|
+
}
|
|
969
|
+
function shallowEqual(a, b) {
|
|
970
|
+
return a.status === b.status && a.banner === b.banner && a.next === b.next && a.agent.name === b.agent.name && a.agent.avatarUrl === b.agent.avatarUrl && a.disclosure === b.disclosure && a.takeover?.by === b.takeover?.by && a.takeover?.at === b.takeover?.at && a.takeover === null === (b.takeover === null) && a.items === b.items && a.activity === b.activity && a.responding === b.responding && a.openQuestion === b.openQuestion && a.answersNeedWebSocket === b.answersNeedWebSocket && a.turnsLeft === b.turnsLeft && a.history === b.history;
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
export { ConversationStore, MAX_TEXT, createConversation, isAllowedLink, isValidLinkPrefix };
|
|
974
|
+
//# sourceMappingURL=chunk-ZWG2TMUL.js.map
|
|
975
|
+
//# sourceMappingURL=chunk-ZWG2TMUL.js.map
|