@cosmoops/connect-sdk 0.30.1 → 0.33.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +267 -0
- package/dist/cjs/client/components/visitor-chat.js +387 -0
- package/dist/cjs/client/components/visitor-stream.js +124 -0
- package/dist/cjs/client/index.js +23 -3
- package/dist/cjs/client/provider.js +24 -0
- package/dist/cjs/client/use-push.js +139 -0
- package/dist/cjs/server/client.js +15 -1
- package/dist/cjs/server/handler.js +196 -0
- package/dist/cjs/server/resources/ai.js +85 -0
- package/dist/cjs/server/resources/enrichment.js +107 -0
- package/dist/cjs/server/resources/push.js +92 -0
- package/dist/cjs/server/resources/visitor.js +227 -0
- package/dist/esm/client/components/visitor-chat.js +384 -0
- package/dist/esm/client/components/visitor-stream.js +120 -0
- package/dist/esm/client/index.js +17 -1
- package/dist/esm/client/provider.js +23 -0
- package/dist/esm/client/use-push.js +136 -0
- package/dist/esm/server/client.js +15 -1
- package/dist/esm/server/handler.js +196 -0
- package/dist/esm/server/resources/ai.js +85 -0
- package/dist/esm/server/resources/enrichment.js +107 -0
- package/dist/esm/server/resources/push.js +88 -0
- package/dist/esm/server/resources/visitor.js +222 -0
- package/dist/types/client/components/visitor-chat.d.ts +120 -0
- package/dist/types/client/components/visitor-stream.d.ts +42 -0
- package/dist/types/client/index.d.ts +13 -1
- package/dist/types/client/provider.d.ts +21 -0
- package/dist/types/client/use-push.d.ts +56 -0
- package/dist/types/server/client.d.ts +13 -1
- package/dist/types/server/handler.d.ts +0 -24
- package/dist/types/server/resources/ai.d.ts +75 -1
- package/dist/types/server/resources/enrichment.d.ts +102 -1
- package/dist/types/server/resources/push.d.ts +73 -0
- package/dist/types/server/resources/visitor.d.ts +200 -0
- package/dist/types/types.d.ts +683 -0
- package/package.json +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,273 @@
|
|
|
3
3
|
`@cosmoops/connect-sdk` follows semver, with the 0.x convention that **minor
|
|
4
4
|
versions may carry breaking changes** and patch versions never do.
|
|
5
5
|
|
|
6
|
+
## 0.33.0
|
|
7
|
+
|
|
8
|
+
Two additions: the public visitor assistant, and `connect.enrichment.presence()`.
|
|
9
|
+
|
|
10
|
+
### The visitor assistant
|
|
11
|
+
|
|
12
|
+
A chat bubble for **your customers' own websites**, answered by Connect, for
|
|
13
|
+
people who have no account anywhere. A visitor asks about that business, or
|
|
14
|
+
books a call with them; your application holds no model credential and runs no
|
|
15
|
+
AI infrastructure.
|
|
16
|
+
|
|
17
|
+
Three calls drive a conversation and five manage what it is allowed to say:
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
const session = await connect.visitor.startSession({
|
|
21
|
+
workspaceId, // which of your customers' sites this is
|
|
22
|
+
page: "/pricing",
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const stream = await connect.visitor.chat({
|
|
26
|
+
sessionId: session.sessionId,
|
|
27
|
+
workspaceId,
|
|
28
|
+
message: "do you take walk-ins?",
|
|
29
|
+
});
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Needs an API key scoped to **`visitor:chat`**, and **`visitor:knowledge`** to
|
|
33
|
+
manage the knowledge base. Both are workspace-scoped, so they carry the second
|
|
34
|
+
check every workspace-wide scope carries: a live subscription to the workspace
|
|
35
|
+
you name.
|
|
36
|
+
|
|
37
|
+
**The assistant answers from a knowledge base you publish, and from nothing
|
|
38
|
+
else.** `connect.visitor.knowledge.*` writes to a store that exists only for
|
|
39
|
+
this purpose:
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
const item = await connect.visitor.knowledge.save({
|
|
43
|
+
workspaceId,
|
|
44
|
+
title: "Opening hours",
|
|
45
|
+
body: "Mon-Fri, 9 to 6. Closed on public holidays.",
|
|
46
|
+
source: "authored",
|
|
47
|
+
});
|
|
48
|
+
await connect.visitor.knowledge.publish({ workspaceId, itemId: item.id });
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Five things to know before wiring it in, in the order they will bite you:
|
|
52
|
+
|
|
53
|
+
**It is not the workspace's Context Index, and must never become it.** The
|
|
54
|
+
corpus behind `ai.chat` is fed by every Drive upload a workspace makes —
|
|
55
|
+
contracts, payroll, customer lists. Pointing a public widget at that would let
|
|
56
|
+
a stranger extract an internal document, helpfully summarised. Nothing reaches
|
|
57
|
+
the visitor store except through an explicit `publish`, and the assistant's
|
|
58
|
+
read path has no route to the other corpus at all. Do not try to fill it by
|
|
59
|
+
uploading to Drive; that is the invariant, not an inconvenience.
|
|
60
|
+
|
|
61
|
+
**Publishing is its own call, and unpublishing can fail.** `save` always writes
|
|
62
|
+
a draft, so no edit can make text public by accident. `unpublish` removes the
|
|
63
|
+
retrievable copy *before* it flips the record, which means a failure leaves the
|
|
64
|
+
item **still live** — surface the error and let them retry rather than greying
|
|
65
|
+
the row out optimistically. For a retracted price, an item that looks withdrawn
|
|
66
|
+
and is not is the most expensive bug this feature has.
|
|
67
|
+
|
|
68
|
+
**Record `grounded: false` from the `done` event.** It means nothing published
|
|
69
|
+
covered the question, so the assistant said so and offered a call instead of
|
|
70
|
+
inventing an answer. A run of them is a knowledge base with a gap in it, which
|
|
71
|
+
is the most useful signal this endpoint produces.
|
|
72
|
+
|
|
73
|
+
**`mode` tells you how the knowledge reaches the model**, and `inline` is the
|
|
74
|
+
good state. Below a size threshold the whole published set is carried in the
|
|
75
|
+
prompt — the model sees every item rather than a top-k slice, with no chunk
|
|
76
|
+
boundaries and no retrieval round trip. `retrieval` only begins once a
|
|
77
|
+
knowledge base has outgrown that, and only then does `indexState` on each item
|
|
78
|
+
matter.
|
|
79
|
+
|
|
80
|
+
**The workspace is yours to resolve, not the browser's.** Connect checks the
|
|
81
|
+
`workspaceId` you send against your own subscriptions, so the worst case is
|
|
82
|
+
bounded to your own customers rather than the platform's. That is still one
|
|
83
|
+
customer's budget spent from another's page, so if you serve several customers,
|
|
84
|
+
resolve the workspace from your own widget record — the token in the embed
|
|
85
|
+
snippet, say — rather than trusting a field a page supplied.
|
|
86
|
+
|
|
87
|
+
`<ConnectVisitorChat>` in `./client` is the widget itself: a bubble that
|
|
88
|
+
becomes a full-height sheet on a phone and an anchored panel above `sm`, with
|
|
89
|
+
the composer's input type decided by the server rather than guessed from the
|
|
90
|
+
reply — so an email field brings up a keyboard with an `@` on it and a
|
|
91
|
+
verification code can be offered straight from the notification. It renders on
|
|
92
|
+
a page with **no `ConnectProvider`**, which most marketing pages are, and it
|
|
93
|
+
mints nothing until somebody opens it.
|
|
94
|
+
|
|
95
|
+
Times offered for a call are drawn from the turn's tool result, never from the
|
|
96
|
+
assistant's sentence, and the appointment is placed by a typed action rather
|
|
97
|
+
than by the model — so it cannot book a time nobody offered, and a phone number
|
|
98
|
+
never arrives parsed out of prose.
|
|
99
|
+
|
|
100
|
+
### `connect.enrichment.presence()`
|
|
101
|
+
|
|
102
|
+
`connect.enrichment.presence()` — how visible a business is online, scored out
|
|
103
|
+
of 100.
|
|
104
|
+
|
|
105
|
+
The third sibling of `company()` and `brand()`: that one answers what a
|
|
106
|
+
business does, the other what it looks like, and this answers how findable it
|
|
107
|
+
is — by a search engine, by a customer, and by the assistants that
|
|
108
|
+
increasingly answer questions about a business before anybody visits its site.
|
|
109
|
+
|
|
110
|
+
Returns the score, a breakdown across five categories, every check with the
|
|
111
|
+
evidence behind it, every social profile linked from the site, and a
|
|
112
|
+
prioritised list of what to fix.
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
const result = await connect.enrichment.presence({ domain: "acme.com" });
|
|
116
|
+
if (result.status === "scanned") {
|
|
117
|
+
for (const next of result.scan.recommendations) {
|
|
118
|
+
console.log(`${next.priority}. ${next.title} — ${next.fix}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Needs an API key scoped to **`enrichment:presence`**.
|
|
124
|
+
|
|
125
|
+
Three things to know before wiring it in:
|
|
126
|
+
|
|
127
|
+
**The score is computed, not generated.** Every point comes from a check run
|
|
128
|
+
against something a fetcher actually observed, and every check ships with its
|
|
129
|
+
weight, what was seen and the URL that settles it — so the total is
|
|
130
|
+
reconstructable. A model only orders the recommendations and writes the prose;
|
|
131
|
+
it cannot move the number. That is the whole reason this is worth showing a
|
|
132
|
+
customer: ask a model how good a company's web presence is and it answers 72
|
|
133
|
+
with total confidence for a site it never read.
|
|
134
|
+
|
|
135
|
+
**`social` is claimed, never verified.** Those are URLs the site itself
|
|
136
|
+
printed. Nothing proves the account belongs to that business — render it as
|
|
137
|
+
"linked from their site". Check `machineReadable` too: `false` means a real
|
|
138
|
+
profile that search engines have to infer from a footer link, which is a
|
|
139
|
+
specific, common and easily fixed finding.
|
|
140
|
+
|
|
141
|
+
**A low score is a success, not a skip.** Every check failing is the most
|
|
142
|
+
useful report this produces. Only `unreachable` means the site did not answer —
|
|
143
|
+
and `provider_unavailable` means *Connect* did not, which is never something
|
|
144
|
+
to tell your user about their own site.
|
|
145
|
+
|
|
146
|
+
## 0.32.0
|
|
147
|
+
|
|
148
|
+
Push notifications: `connect.push.send()` on the server, `useConnectPush()` in
|
|
149
|
+
the browser.
|
|
150
|
+
|
|
151
|
+
**The point is what your application no longer holds.** It holds no VAPID key,
|
|
152
|
+
stores no push endpoint, and needs no notification table of its own. It names
|
|
153
|
+
a workspace, some members and three strings; Connect resolves those members'
|
|
154
|
+
devices, applies each member's own preferences, and signs the payload.
|
|
155
|
+
|
|
156
|
+
Two things follow that are worth more than the code saved:
|
|
157
|
+
|
|
158
|
+
- **A member grants the browser prompt once for the platform**, not once per
|
|
159
|
+
app. The same device receives your notifications, every other app's, and
|
|
160
|
+
Connect's own — and one switch in their Connect profile silences the lot.
|
|
161
|
+
That matters because a browser will not re-prompt once it has been told no,
|
|
162
|
+
so every app asking separately is a queue of chances to lose the permission
|
|
163
|
+
for all of them.
|
|
164
|
+
- **You never learn who has a device.** There is no read that answers it, by
|
|
165
|
+
design. A send reports a fan-out; it does not report an audience.
|
|
166
|
+
|
|
167
|
+
```ts
|
|
168
|
+
// Your server — a task fell due.
|
|
169
|
+
await connect.push.send({
|
|
170
|
+
workspaceId,
|
|
171
|
+
userIds: [ownerId],
|
|
172
|
+
title: "A task is due",
|
|
173
|
+
body: "Follow up with Priya Sharma",
|
|
174
|
+
url: "https://crm.example.com/tasks",
|
|
175
|
+
});
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
```tsx
|
|
179
|
+
// Your browser — registering this device.
|
|
180
|
+
const { status, enable, error } = useConnectPush("/sw.js");
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Three things to know before wiring it up:
|
|
184
|
+
|
|
185
|
+
**`delivered: 0` is a success.** The member may have registered no device,
|
|
186
|
+
silenced notifications from their apps, or denied the prompt — all ordinary. A
|
|
187
|
+
caller that retries on zero retries forever against somebody who opted out.
|
|
188
|
+
Treat a notification as a nudge toward something already true in your own data,
|
|
189
|
+
and never let a send failure fail the thing that triggered it.
|
|
190
|
+
|
|
191
|
+
**`url` must be absolute and on an origin you registered as an SSO redirect
|
|
192
|
+
URI.** Connect refuses anything else: a URL it did not check is an open
|
|
193
|
+
redirect delivered to a lock screen, wearing the styling of an app the person
|
|
194
|
+
trusts.
|
|
195
|
+
|
|
196
|
+
**You supply the service worker.** The SDK does not install one — a worker is a
|
|
197
|
+
site-wide concern with caching implications only you can decide. It needs to
|
|
198
|
+
handle `push` and `notificationclick`; the payload is `{title, body, url, tag}`.
|
|
199
|
+
|
|
200
|
+
`status` has five values rather than being a boolean, and the one that matters
|
|
201
|
+
is `denied`: it cannot be undone by script, only from the browser's own site
|
|
202
|
+
settings. A control that silently does nothing when pressed is the worst
|
|
203
|
+
version of that screen, so the state is surfaced for you to explain.
|
|
204
|
+
|
|
205
|
+
Requires the `push:send` scope on your API key.
|
|
206
|
+
|
|
207
|
+
## 0.31.0
|
|
208
|
+
|
|
209
|
+
`connect.enrichment.company()` tells you what a company *does*. This adds
|
|
210
|
+
`connect.enrichment.brand()`, which tells you what it *looks like*: its logo,
|
|
211
|
+
its palette, its typefaces, and the radius, spacing and breakpoint scales its
|
|
212
|
+
own stylesheets declare.
|
|
213
|
+
|
|
214
|
+
The reason this needed building rather than prompting: **these values are
|
|
215
|
+
measured, not generated.** Ask any model for a company's brand colour and it
|
|
216
|
+
answers `#3B82F6` with complete confidence, because that is the median of
|
|
217
|
+
every site it has ever seen. Every hex in this response was parsed out of a
|
|
218
|
+
real stylesheet, every scale step is a count of real declarations, and
|
|
219
|
+
`palette[].source` tells you which parse produced it — a `css-var` entry is
|
|
220
|
+
the company naming its own colour in its own tokens, a `frequency` entry is a
|
|
221
|
+
ranked count and much weaker. A model is involved in exactly two places:
|
|
222
|
+
writing the prose fields, and assigning a `role` to a value it was shown.
|
|
223
|
+
|
|
224
|
+
`officialName` follows the same discipline the company lookup applies to
|
|
225
|
+
`legalName`. It is filled only from a legal disclosure the company published
|
|
226
|
+
itself — a footer copyright line, or the entity named on its Terms page —
|
|
227
|
+
matched against a corporate suffix by code and cited. `officialNameSource`
|
|
228
|
+
says which. It is never a model's recollection of a company register.
|
|
229
|
+
|
|
230
|
+
### Added
|
|
231
|
+
|
|
232
|
+
- **`connect.enrichment.brand()`** — read a company's brand kit and design
|
|
233
|
+
system from its domain.
|
|
234
|
+
|
|
235
|
+
```ts
|
|
236
|
+
const result = await connect.enrichment.brand({ domain: "acme.com" });
|
|
237
|
+
if (result.status === "extracted") {
|
|
238
|
+
const primary = result.profile.brandKit.palette.find((c) => c.role === "primary");
|
|
239
|
+
const body = result.profile.brandKit.typography.find((t) => t.role === "body");
|
|
240
|
+
}
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
Needs an API key scoped to the new **`enrichment:brand`**. That is separate
|
|
244
|
+
from `enrichment:read` because the cost differs by an order of magnitude: a
|
|
245
|
+
company lookup is three page fetches, a brand read crawls stylesheets, may
|
|
246
|
+
drive a headless browser, and caches image bytes.
|
|
247
|
+
|
|
248
|
+
Three sharp edges, in the order they will bite you:
|
|
249
|
+
|
|
250
|
+
**Set your HTTP client timeout to at least 90 seconds.** A cold read runs
|
|
251
|
+
the full extraction before answering and can take up to a minute. Many
|
|
252
|
+
clients — including `undici` and most `axios` setups — default to 30s and
|
|
253
|
+
will abandon a request that was about to succeed. Cached reads return in
|
|
254
|
+
milliseconds, and cached is the normal case: profiles are platform-owned
|
|
255
|
+
and held for 90 days, so you pay the cold path roughly once per domain
|
|
256
|
+
across all of your users.
|
|
257
|
+
|
|
258
|
+
**Never persist `brandKit.logos[].url`.** It is a short-lived signed URL,
|
|
259
|
+
minted fresh on every read. Stored in your own database or baked into a
|
|
260
|
+
cached page it becomes a broken image with a plausible-looking address.
|
|
261
|
+
Render it directly, or re-read the profile. `sourceUrl` is the stable value
|
|
262
|
+
and points at the company's own site — though it is theirs, so it can move.
|
|
263
|
+
|
|
264
|
+
**A `skipped` status is a normal 200.** `no_stylesheet` is the one new
|
|
265
|
+
reason worth handling specially: the site answered and said things, but
|
|
266
|
+
shipped no CSS this reader could measure.
|
|
267
|
+
|
|
268
|
+
`designSystem.framework` is worth reading first. It tells you how to
|
|
269
|
+
interpret everything else — a Tailwind site's spacing scale is Tailwind's
|
|
270
|
+
defaults rather than a considered choice, and a palette full of `--bs-*` is
|
|
271
|
+
Bootstrap's.
|
|
272
|
+
|
|
6
273
|
## 0.30.1
|
|
7
274
|
|
|
8
275
|
`ConnectAppShell` was putting **462 KB** of markdown renderer on every page of
|
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
"use strict";
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.ConnectVisitorChat = ConnectVisitorChat;
|
|
5
|
+
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
6
|
+
const react_1 = require("react");
|
|
7
|
+
const react_2 = require("@heroui/react");
|
|
8
|
+
const lucide_react_1 = require("lucide-react");
|
|
9
|
+
const cn_js_1 = require("../../cn.js");
|
|
10
|
+
const provider_js_1 = require("../provider.js");
|
|
11
|
+
const visitor_stream_js_1 = require("./visitor-stream.js");
|
|
12
|
+
/**
|
|
13
|
+
* The public chat bubble: a visitor with no account asks about your product,
|
|
14
|
+
* books a call, or gets started.
|
|
15
|
+
*
|
|
16
|
+
* ## It runs on pages that have no `ConnectProvider`
|
|
17
|
+
*
|
|
18
|
+
* Deliberately, and it is the main thing separating this component from every
|
|
19
|
+
* other one here. A marketing page usually has no session to hold and nothing
|
|
20
|
+
* on it that needs one, so requiring the provider would mean wrapping a
|
|
21
|
+
* landing page in an auth context to put a chat bubble on it. It reads the
|
|
22
|
+
* context through `useConnectOptional` and uses it only for `basePath` and the
|
|
23
|
+
* injected `fetch`; both have props, and both have defaults.
|
|
24
|
+
*
|
|
25
|
+
* ## Nothing happens until somebody opens it
|
|
26
|
+
*
|
|
27
|
+
* The conversation is minted on first open, never on mount. On a busy marketing
|
|
28
|
+
* site mounting would mean one Firestore write and one wasted session row per
|
|
29
|
+
* *page view*, for a bubble most visitors never touch - and the bubble itself
|
|
30
|
+
* needs no server state to render. The greeting arrives with that first call
|
|
31
|
+
* and is stored copy rather than generated, so opening costs one fast round
|
|
32
|
+
* trip and the visitor is reading real words immediately.
|
|
33
|
+
*
|
|
34
|
+
* ## It is built for a phone first
|
|
35
|
+
*
|
|
36
|
+
* Open, it is a full-height sheet on a narrow viewport and an anchored panel
|
|
37
|
+
* from `sm` up - a marketing chat that covers a desktop browser is harder to
|
|
38
|
+
* use, and one that does not cover a phone has nowhere to put a keyboard.
|
|
39
|
+
* `h-dvh` rather than `h-screen`, so the sheet tracks the visual viewport when
|
|
40
|
+
* the keyboard opens; `env(safe-area-inset-bottom)` on the composer, so the
|
|
41
|
+
* send button is not under a home indicator.
|
|
42
|
+
*
|
|
43
|
+
* The composer's input type is **the server's decision**, not a guess from what
|
|
44
|
+
* the assistant said - see `VisitorField`. That is what puts an `@` on the
|
|
45
|
+
* keyboard when an email is wanted and lets iOS offer a verification code
|
|
46
|
+
* straight from the notification.
|
|
47
|
+
*
|
|
48
|
+
* ## The times are drawn from the tool result, never the prose
|
|
49
|
+
*
|
|
50
|
+
* A turn that looked up availability carries the slots as structured data, and
|
|
51
|
+
* the buttons come from that. So a time the visitor taps is one the server
|
|
52
|
+
* offered, which is the property `/visitor/book` relies on and cannot check for
|
|
53
|
+
* itself.
|
|
54
|
+
*
|
|
55
|
+
* ## Replies render as text, not markdown
|
|
56
|
+
*
|
|
57
|
+
* `<ConnectAskAi>` lazy-loads a markdown renderer because it answers questions
|
|
58
|
+
* about a workspace's documents, where tables and code blocks carry meaning.
|
|
59
|
+
* This answers three-sentence questions about a product on a marketing page.
|
|
60
|
+
* Shipping 462 KB of renderer to a landing page for prose that is deliberately
|
|
61
|
+
* kept to a couple of lines is the wrong trade, so line breaks are preserved
|
|
62
|
+
* and nothing else is parsed.
|
|
63
|
+
*/
|
|
64
|
+
function ConnectVisitorChat({ workspaceId, assistantName, greeting = "Hi - ask me anything, or I can put you in touch with someone.", suggestions = [], bookingEventTypeSlug = null, basePath, page, title = "Chat with us", defaultOpen = false, showWhenSignedIn = false, fetch: fetchProp, }) {
|
|
65
|
+
const connect = (0, provider_js_1.useConnectOptional)();
|
|
66
|
+
const root = basePath ?? "/api/connect";
|
|
67
|
+
const [isOpen, setIsOpen] = (0, react_1.useState)(defaultOpen);
|
|
68
|
+
const [session, setSession] = (0, react_1.useState)(null);
|
|
69
|
+
const [messages, setMessages] = (0, react_1.useState)([]);
|
|
70
|
+
const [field, setField] = (0, react_1.useState)("message");
|
|
71
|
+
const [input, setInput] = (0, react_1.useState)("");
|
|
72
|
+
const [streaming, setStreaming] = (0, react_1.useState)("");
|
|
73
|
+
const [isBusy, setIsBusy] = (0, react_1.useState)(false);
|
|
74
|
+
const [error, setError] = (0, react_1.useState)(null);
|
|
75
|
+
const [offer, setOffer] = (0, react_1.useState)(null);
|
|
76
|
+
const [chosen, setChosen] = (0, react_1.useState)(null);
|
|
77
|
+
const [booked, setBooked] = (0, react_1.useState)(null);
|
|
78
|
+
const [turnsRemaining, setTurnsRemaining] = (0, react_1.useState)(1);
|
|
79
|
+
const abortRef = (0, react_1.useRef)(null);
|
|
80
|
+
const bottomRef = (0, react_1.useRef)(null);
|
|
81
|
+
const headingId = (0, react_1.useId)();
|
|
82
|
+
/*
|
|
83
|
+
* **Through the provider when there is one, and directly when there is not.**
|
|
84
|
+
*
|
|
85
|
+
* `mutateResource` and `streamResource` already prefix the provider's own
|
|
86
|
+
* `basePath` and go through its injected `fetch` - which is the seam Storybook
|
|
87
|
+
* and the docs site mount this against, so using them is what makes those two
|
|
88
|
+
* work with nothing passed. Without a provider there is no seam to inherit and
|
|
89
|
+
* the `fetch` prop stands in, defaulting to the global.
|
|
90
|
+
*
|
|
91
|
+
* Reaching into the context for `basePath` and `fetch` instead would have
|
|
92
|
+
* meant widening `ConnectContextValue` with two fields nothing else needs, to
|
|
93
|
+
* re-implement two methods it already has.
|
|
94
|
+
*/
|
|
95
|
+
const call = (0, react_1.useCallback)(async (path, body) => {
|
|
96
|
+
if (connect)
|
|
97
|
+
return connect.mutateResource(path, body);
|
|
98
|
+
const response = await (fetchProp ?? globalThis.fetch)(`${root}${path}`, {
|
|
99
|
+
method: "POST",
|
|
100
|
+
credentials: "same-origin",
|
|
101
|
+
headers: { accept: "application/json", "content-type": "application/json" },
|
|
102
|
+
body: JSON.stringify(body),
|
|
103
|
+
});
|
|
104
|
+
const payload = await response.json().catch(() => null);
|
|
105
|
+
if (!response.ok) {
|
|
106
|
+
throw new Error(payload?.error?.message ?? "Something went wrong. Please try again.");
|
|
107
|
+
}
|
|
108
|
+
return payload;
|
|
109
|
+
}, [connect, root, fetchProp]);
|
|
110
|
+
const openStream = (0, react_1.useCallback)(async (body, signal) => {
|
|
111
|
+
if (connect)
|
|
112
|
+
return connect.streamResource("/visitor/chat", body, { signal });
|
|
113
|
+
const response = await (fetchProp ?? globalThis.fetch)(`${root}/visitor/chat`, {
|
|
114
|
+
method: "POST",
|
|
115
|
+
credentials: "same-origin",
|
|
116
|
+
headers: { accept: "text/event-stream", "content-type": "application/json" },
|
|
117
|
+
body: JSON.stringify(body),
|
|
118
|
+
signal,
|
|
119
|
+
});
|
|
120
|
+
if (!response.ok) {
|
|
121
|
+
const envelope = await response.json().catch(() => null);
|
|
122
|
+
throw new Error(envelope?.error?.message ?? "Something went wrong. Please try again.");
|
|
123
|
+
}
|
|
124
|
+
return response;
|
|
125
|
+
}, [connect, root, fetchProp]);
|
|
126
|
+
/*
|
|
127
|
+
* Minted on first open. `storedSessionId` survives a reload, which is what
|
|
128
|
+
* lets somebody follow a link out of the chat and come back to it - on a
|
|
129
|
+
* phone, the most likely thing they will do.
|
|
130
|
+
*/
|
|
131
|
+
(0, react_1.useEffect)(() => {
|
|
132
|
+
if (!isOpen || session)
|
|
133
|
+
return;
|
|
134
|
+
let cancelled = false;
|
|
135
|
+
void (async () => {
|
|
136
|
+
try {
|
|
137
|
+
const opened = await call("/visitor/session", {
|
|
138
|
+
workspaceId,
|
|
139
|
+
page: page ?? (typeof location !== "undefined" ? location.pathname : null),
|
|
140
|
+
resume: storedSessionId(),
|
|
141
|
+
});
|
|
142
|
+
if (cancelled)
|
|
143
|
+
return;
|
|
144
|
+
rememberSessionId(opened.sessionId);
|
|
145
|
+
setSession(opened);
|
|
146
|
+
setMessages(opened.messages);
|
|
147
|
+
setField(opened.field);
|
|
148
|
+
setTurnsRemaining(opened.turnsRemaining);
|
|
149
|
+
// Already carries its written-out `label`, so a reopened bubble names
|
|
150
|
+
// the time without formatting an instant in the visitor's own zone.
|
|
151
|
+
if (opened.booking)
|
|
152
|
+
setBooked(opened.booking);
|
|
153
|
+
}
|
|
154
|
+
catch (cause) {
|
|
155
|
+
if (!cancelled) {
|
|
156
|
+
setError(cause instanceof Error ? cause.message : "The assistant is unavailable.");
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
})();
|
|
160
|
+
return () => {
|
|
161
|
+
cancelled = true;
|
|
162
|
+
};
|
|
163
|
+
}, [isOpen, session, call, page, workspaceId]);
|
|
164
|
+
(0, react_1.useEffect)(() => {
|
|
165
|
+
bottomRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
|
|
166
|
+
}, [messages, streaming, offer, booked]);
|
|
167
|
+
// A conversation left mid-stream stops costing the application the moment the
|
|
168
|
+
// panel goes away.
|
|
169
|
+
(0, react_1.useEffect)(() => () => abortRef.current?.abort(), []);
|
|
170
|
+
const send = (0, react_1.useCallback)(async (text) => {
|
|
171
|
+
if (!session)
|
|
172
|
+
return;
|
|
173
|
+
const controller = new AbortController();
|
|
174
|
+
abortRef.current = controller;
|
|
175
|
+
setError(null);
|
|
176
|
+
setIsBusy(true);
|
|
177
|
+
setOffer(null);
|
|
178
|
+
setMessages((current) => [
|
|
179
|
+
...current,
|
|
180
|
+
{ role: "visitor", text, at: new Date().toISOString() },
|
|
181
|
+
]);
|
|
182
|
+
let answer = "";
|
|
183
|
+
try {
|
|
184
|
+
const response = await openStream({
|
|
185
|
+
sessionId: session.sessionId,
|
|
186
|
+
workspaceId,
|
|
187
|
+
message: text,
|
|
188
|
+
assistantName: assistantName ?? "",
|
|
189
|
+
bookingEventTypeSlug,
|
|
190
|
+
}, controller.signal);
|
|
191
|
+
await (0, visitor_stream_js_1.readVisitorStream)(response, {
|
|
192
|
+
onDelta: (delta) => {
|
|
193
|
+
answer += delta;
|
|
194
|
+
setStreaming(answer);
|
|
195
|
+
},
|
|
196
|
+
onDone: (done) => {
|
|
197
|
+
setField(done.field);
|
|
198
|
+
setTurnsRemaining(done.turnsRemaining);
|
|
199
|
+
setOffer((0, visitor_stream_js_1.visitorSlotOfferFrom)(done.toolCalls));
|
|
200
|
+
},
|
|
201
|
+
onError: (message) => setError(message),
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
catch (cause) {
|
|
205
|
+
if (!controller.signal.aborted) {
|
|
206
|
+
setError(cause instanceof Error ? cause.message : "Something went wrong.");
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
finally {
|
|
210
|
+
if (abortRef.current === controller)
|
|
211
|
+
abortRef.current = null;
|
|
212
|
+
/*
|
|
213
|
+
* The streamed text is committed to the transcript here rather than as
|
|
214
|
+
* it arrives, so there is exactly one copy of the reply on screen at
|
|
215
|
+
* every moment - `streaming` is what is being written, `messages` is
|
|
216
|
+
* what has been said.
|
|
217
|
+
*/
|
|
218
|
+
if (answer.trim()) {
|
|
219
|
+
setMessages((current) => [
|
|
220
|
+
...current,
|
|
221
|
+
{ role: "assistant", text: answer.trim(), at: new Date().toISOString() },
|
|
222
|
+
]);
|
|
223
|
+
}
|
|
224
|
+
setStreaming("");
|
|
225
|
+
setIsBusy(false);
|
|
226
|
+
}
|
|
227
|
+
}, [session, openStream, workspaceId, assistantName, bookingEventTypeSlug]);
|
|
228
|
+
const bookChosenSlot = (0, react_1.useCallback)(async (details) => {
|
|
229
|
+
if (!session || !chosen)
|
|
230
|
+
return;
|
|
231
|
+
setIsBusy(true);
|
|
232
|
+
setError(null);
|
|
233
|
+
try {
|
|
234
|
+
const appointment = await call("/visitor/book", {
|
|
235
|
+
sessionId: session.sessionId,
|
|
236
|
+
workspaceId,
|
|
237
|
+
eventTypeSlug: bookingEventTypeSlug,
|
|
238
|
+
startAt: chosen.startAt,
|
|
239
|
+
name: details.name,
|
|
240
|
+
phone: details.phone,
|
|
241
|
+
email: details.email || null,
|
|
242
|
+
});
|
|
243
|
+
setBooked(appointment);
|
|
244
|
+
setChosen(null);
|
|
245
|
+
setOffer(null);
|
|
246
|
+
setMessages((current) => [
|
|
247
|
+
...current,
|
|
248
|
+
{
|
|
249
|
+
role: "assistant",
|
|
250
|
+
text: `You're booked for ${appointment.label}. A confirmation is on its way.`,
|
|
251
|
+
at: new Date().toISOString(),
|
|
252
|
+
},
|
|
253
|
+
]);
|
|
254
|
+
}
|
|
255
|
+
catch (cause) {
|
|
256
|
+
setError(cause instanceof Error ? cause.message : "That time could not be booked.");
|
|
257
|
+
}
|
|
258
|
+
finally {
|
|
259
|
+
setIsBusy(false);
|
|
260
|
+
}
|
|
261
|
+
}, [session, chosen, call, workspaceId, bookingEventTypeSlug]);
|
|
262
|
+
/*
|
|
263
|
+
* A signed-in member has the application's own surfaces, and `<ConnectAskAi>`
|
|
264
|
+
* occupies this same corner - see `showWhenSignedIn`.
|
|
265
|
+
*/
|
|
266
|
+
if (!showWhenSignedIn && connect?.session?.user)
|
|
267
|
+
return null;
|
|
268
|
+
const exhausted = turnsRemaining <= 0;
|
|
269
|
+
const composerDisabled = isBusy || !session || exhausted;
|
|
270
|
+
const handleSubmit = (event) => {
|
|
271
|
+
event.preventDefault();
|
|
272
|
+
const text = input.trim();
|
|
273
|
+
if (!text || composerDisabled)
|
|
274
|
+
return;
|
|
275
|
+
setInput("");
|
|
276
|
+
void send(text);
|
|
277
|
+
};
|
|
278
|
+
return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [!isOpen && ((0, jsx_runtime_1.jsx)(react_2.Button, { isIconOnly: true, variant: "primary", "aria-label": title, onPress: () => setIsOpen(true),
|
|
279
|
+
/*
|
|
280
|
+
* `size-14` on a phone and `size-12` from `sm` up: 56px is the floor
|
|
281
|
+
* for a one-handed thumb target on a surface where this is the only
|
|
282
|
+
* interactive element floating over the page.
|
|
283
|
+
*/
|
|
284
|
+
className: "fixed end-4 bottom-4 z-30 size-14 rounded-full shadow-lg sm:size-12", style: { marginBottom: "env(safe-area-inset-bottom)" }, children: (0, jsx_runtime_1.jsx)(lucide_react_1.MessageCircle, { className: "size-6 sm:size-5" }) })), isOpen && ((0, jsx_runtime_1.jsxs)("div", { role: "dialog", "aria-modal": "true", "aria-labelledby": headingId,
|
|
285
|
+
/*
|
|
286
|
+
* A full-height sheet on a phone, an anchored panel from `sm` up.
|
|
287
|
+
* `h-dvh` rather than `h-screen`: `vh` is the *largest* viewport on
|
|
288
|
+
* iOS, so a `h-screen` sheet puts its composer under the keyboard.
|
|
289
|
+
*/
|
|
290
|
+
className: (0, cn_js_1.cn)("fixed inset-0 z-40 flex h-dvh flex-col bg-surface", "sm:inset-auto sm:end-6 sm:bottom-6 sm:h-[min(32rem,calc(100dvh-6rem))] sm:w-[22rem] sm:rounded-2xl sm:border sm:border-border sm:shadow-xl"), children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex shrink-0 items-center gap-2 border-b border-border px-4 py-3", children: [(0, jsx_runtime_1.jsx)("span", { id: headingId, className: "flex-1 truncate text-sm font-semibold", children: assistantName?.trim() || title }), (0, jsx_runtime_1.jsx)(react_2.Button, { isIconOnly: true, size: "sm", variant: "tertiary", "aria-label": "Close", onPress: () => setIsOpen(false), children: (0, jsx_runtime_1.jsx)(lucide_react_1.X, { className: "size-4" }) })] }), (0, jsx_runtime_1.jsxs)("div", { className: "min-h-0 min-w-0 flex-1 space-y-3 overflow-y-auto p-4", children: [session && messages.length === 0 && ((0, jsx_runtime_1.jsx)("p", { className: "text-sm text-muted", children: greeting })), messages.map((message, index) => ((0, jsx_runtime_1.jsx)(Bubble, { role: message.role, text: message.text }, `${message.at}-${index}`))), streaming && (0, jsx_runtime_1.jsx)(Bubble, { role: "assistant", text: streaming }), isBusy && !streaming && !chosen && ((0, jsx_runtime_1.jsx)("p", { className: "text-xs text-muted", "aria-live": "polite", children: "Typing..." })), session && messages.length === 0 && suggestions.length > 0 && ((0, jsx_runtime_1.jsx)("div", { className: "flex flex-wrap gap-2 pt-1", children: suggestions.map((suggestion) => ((0, jsx_runtime_1.jsx)(react_2.Button, { size: "sm", variant: "secondary", className: "h-auto min-h-11 whitespace-normal py-2 text-start sm:min-h-9", isDisabled: composerDisabled, onPress: () => void send(suggestion), children: suggestion }, suggestion))) })), offer && !chosen && !booked && ((0, jsx_runtime_1.jsx)(SlotPicker, { offer: offer, onChoose: setChosen, isDisabled: isBusy })), chosen && !booked && ((0, jsx_runtime_1.jsx)(AttendeeForm, { slotLabel: chosen.label, isBusy: isBusy, onCancel: () => setChosen(null), onSubmit: bookChosenSlot })), booked && ((0, jsx_runtime_1.jsxs)("div", { className: "flex items-start gap-2 rounded-lg border border-border bg-accent/5 p-3 text-sm", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.CalendarCheck, { className: "mt-0.5 size-4 shrink-0 text-accent" }), (0, jsx_runtime_1.jsxs)("span", { children: ["Booked - ", booked.label] })] })), error && ((0, jsx_runtime_1.jsx)("p", { className: "text-xs text-content-danger", role: "alert", children: error })), exhausted && ((0, jsx_runtime_1.jsx)("p", { className: "text-xs text-muted", children: "This conversation has reached its limit. Reload the page to start a new one." })), (0, jsx_runtime_1.jsx)("div", { ref: bottomRef })] }), (0, jsx_runtime_1.jsxs)("form", { onSubmit: handleSubmit, className: "flex shrink-0 gap-2 border-t border-border p-3",
|
|
291
|
+
// So the send button is not under a home indicator.
|
|
292
|
+
style: { paddingBottom: "max(0.75rem, env(safe-area-inset-bottom))" }, children: [(0, jsx_runtime_1.jsxs)(react_2.TextField, { variant: "secondary", className: "min-w-0 flex-1", children: [(0, jsx_runtime_1.jsx)(react_2.Label, { className: "sr-only", children: FIELD_LABEL[field] }), (0, jsx_runtime_1.jsx)(react_2.Input, { ...FIELD_INPUT[field], placeholder: FIELD_LABEL[field], value: input, onChange: (event) => setInput(event.currentTarget.value), disabled: composerDisabled })] }), (0, jsx_runtime_1.jsx)(react_2.Button, { type: "submit", isIconOnly: true, variant: "primary", "aria-label": "Send", className: "size-11 shrink-0 sm:size-10", isDisabled: composerDisabled || !input.trim(), children: (0, jsx_runtime_1.jsx)(lucide_react_1.Send, { className: "size-4" }) })] })] }))] }));
|
|
293
|
+
}
|
|
294
|
+
function Bubble({ role, text }) {
|
|
295
|
+
const isVisitor = role === "visitor";
|
|
296
|
+
return ((0, jsx_runtime_1.jsx)("div", { className: (0, cn_js_1.cn)("flex", isVisitor ? "justify-end" : "justify-start"), children: (0, jsx_runtime_1.jsx)("p", { className: (0, cn_js_1.cn)(
|
|
297
|
+
// `max-w-[85%]` rather than a fixed width: a one-word reply should not
|
|
298
|
+
// be a full-width slab, and a long one still has to wrap.
|
|
299
|
+
"max-w-[85%] rounded-2xl px-3 py-2 text-sm whitespace-pre-wrap", isVisitor ? "bg-accent text-accent-foreground" : "bg-default"), children: text }) }));
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* The times, as buttons.
|
|
303
|
+
*
|
|
304
|
+
* Every one of them came from the server in this turn - see
|
|
305
|
+
* `visitorSlotOfferFrom`. `min-h-11` is the 44px touch floor; a row of times is
|
|
306
|
+
* exactly the control people mis-tap when it is sized for a mouse.
|
|
307
|
+
*/
|
|
308
|
+
function SlotPicker({ offer, onChoose, isDisabled, }) {
|
|
309
|
+
return ((0, jsx_runtime_1.jsxs)("div", { className: "space-y-2 rounded-lg border border-border p-3", children: [(0, jsx_runtime_1.jsxs)("p", { className: "text-xs text-muted", children: [offer.eventType, " \u00B7 ", offer.durationMinutes, " min \u00B7 times shown in ", offer.timezone] }), (0, jsx_runtime_1.jsx)("div", { className: "flex flex-wrap gap-2", children: offer.slots.map((slot) => ((0, jsx_runtime_1.jsx)(react_2.Button, { size: "sm", variant: "secondary", className: "min-h-11 sm:min-h-9", isDisabled: isDisabled, onPress: () => onChoose(slot), children: slot.label }, slot.startAt))) })] }));
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Name, phone and optionally email, in real fields.
|
|
313
|
+
*
|
|
314
|
+
* The reason the assistant does not ask for these in prose: a phone number
|
|
315
|
+
* typed into a chat composer arrives as whatever the autocorrect did to it,
|
|
316
|
+
* where `type="tel" inputmode="tel" autocomplete="tel"` brings up a keypad and
|
|
317
|
+
* offers the number the browser already has.
|
|
318
|
+
*
|
|
319
|
+
* Phone is required because Connect's booking requires it - a host with no way
|
|
320
|
+
* to reach the attendee has an appointment with a stranger.
|
|
321
|
+
*/
|
|
322
|
+
function AttendeeForm({ slotLabel, isBusy, onCancel, onSubmit, }) {
|
|
323
|
+
const [name, setName] = (0, react_1.useState)("");
|
|
324
|
+
const [phone, setPhone] = (0, react_1.useState)("");
|
|
325
|
+
const [email, setEmail] = (0, react_1.useState)("");
|
|
326
|
+
const ready = name.trim().length > 0 && phone.trim().length >= 3;
|
|
327
|
+
return ((0, jsx_runtime_1.jsxs)("form", { className: "space-y-2 rounded-lg border border-border p-3", onSubmit: (event) => {
|
|
328
|
+
event.preventDefault();
|
|
329
|
+
if (ready && !isBusy)
|
|
330
|
+
onSubmit({ name: name.trim(), phone: phone.trim(), email: email.trim() });
|
|
331
|
+
}, children: [(0, jsx_runtime_1.jsxs)("p", { className: "text-xs text-muted", children: ["Booking ", slotLabel] }), (0, jsx_runtime_1.jsxs)(react_2.TextField, { variant: "secondary", children: [(0, jsx_runtime_1.jsx)(react_2.Label, { className: "text-xs", children: "Your name" }), (0, jsx_runtime_1.jsx)(react_2.Input, { autoComplete: "name", value: name, onChange: (event) => setName(event.currentTarget.value), disabled: isBusy })] }), (0, jsx_runtime_1.jsxs)(react_2.TextField, { variant: "secondary", children: [(0, jsx_runtime_1.jsx)(react_2.Label, { className: "text-xs", children: "Phone" }), (0, jsx_runtime_1.jsx)(react_2.Input, { type: "tel", inputMode: "tel", autoComplete: "tel", value: phone, onChange: (event) => setPhone(event.currentTarget.value), disabled: isBusy })] }), (0, jsx_runtime_1.jsxs)(react_2.TextField, { variant: "secondary", children: [(0, jsx_runtime_1.jsx)(react_2.Label, { className: "text-xs", children: "Email (optional)" }), (0, jsx_runtime_1.jsx)(react_2.Input, { type: "email", inputMode: "email", autoComplete: "email", value: email, onChange: (event) => setEmail(event.currentTarget.value), disabled: isBusy })] }), (0, jsx_runtime_1.jsxs)("div", { className: "flex gap-2 pt-1", children: [(0, jsx_runtime_1.jsx)(react_2.Button, { type: "submit", variant: "primary", className: "min-h-11 flex-1 sm:min-h-9", isDisabled: !ready || isBusy, children: "Confirm" }), (0, jsx_runtime_1.jsx)(react_2.Button, { type: "button", variant: "tertiary", className: "min-h-11 sm:min-h-9", isDisabled: isBusy, onPress: onCancel, children: "Back" })] })] }));
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* The composer's label and input attributes, per field Connect asked for.
|
|
335
|
+
*
|
|
336
|
+
* Total `Record`s over `VisitorField`, so a field added to the union fails the
|
|
337
|
+
* build here rather than silently rendering a plain text box - which is the
|
|
338
|
+
* whole failure this mechanism exists to prevent, since a plain text box is
|
|
339
|
+
* exactly what the wrong keyboard looks like.
|
|
340
|
+
*/
|
|
341
|
+
const FIELD_LABEL = {
|
|
342
|
+
message: "Type a message...",
|
|
343
|
+
email: "Your email address",
|
|
344
|
+
code: "6-digit code",
|
|
345
|
+
name: "Your full name",
|
|
346
|
+
};
|
|
347
|
+
const FIELD_INPUT = {
|
|
348
|
+
message: {},
|
|
349
|
+
email: { type: "email", inputMode: "email", autoComplete: "email" },
|
|
350
|
+
/*
|
|
351
|
+
* `autocomplete="one-time-code"` is what lets iOS offer the code from the
|
|
352
|
+
* notification without the visitor leaving the chat, and `inputmode="numeric"`
|
|
353
|
+
* brings up a keypad rather than a keyboard.
|
|
354
|
+
*/
|
|
355
|
+
code: { inputMode: "numeric", autoComplete: "one-time-code", maxLength: 6 },
|
|
356
|
+
name: { autoComplete: "name" },
|
|
357
|
+
};
|
|
358
|
+
/**
|
|
359
|
+
* The conversation token, in `sessionStorage`.
|
|
360
|
+
*
|
|
361
|
+
* `sessionStorage` rather than a cookie or `localStorage`, and each half of that
|
|
362
|
+
* is deliberate: it survives a reload, which is what makes "follow a link out of
|
|
363
|
+
* the chat and come back" work; it does not travel to another tab or outlive the
|
|
364
|
+
* visit, which is right for a bearer token whose only job is to continue one
|
|
365
|
+
* conversation.
|
|
366
|
+
*
|
|
367
|
+
* Every access is wrapped: storage throws outright in some privacy modes, and a
|
|
368
|
+
* chat bubble that fails to open because a visitor has blocked site data is a
|
|
369
|
+
* worse outcome than one that forgets a conversation.
|
|
370
|
+
*/
|
|
371
|
+
const STORAGE_KEY = "connect.visitor.session";
|
|
372
|
+
function storedSessionId() {
|
|
373
|
+
try {
|
|
374
|
+
return sessionStorage.getItem(STORAGE_KEY);
|
|
375
|
+
}
|
|
376
|
+
catch {
|
|
377
|
+
return null;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
function rememberSessionId(id) {
|
|
381
|
+
try {
|
|
382
|
+
sessionStorage.setItem(STORAGE_KEY, id);
|
|
383
|
+
}
|
|
384
|
+
catch {
|
|
385
|
+
/* private mode, or site data blocked - the conversation still works, it just will not resume */
|
|
386
|
+
}
|
|
387
|
+
}
|