@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
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { request } from "../http.js";
|
|
2
|
+
import { actingAs } from "./acting-as.js";
|
|
3
|
+
/**
|
|
4
|
+
* Web Push, with Connect holding everything that makes it work.
|
|
5
|
+
*
|
|
6
|
+
* Your application supplies a title, a body, a URL and the members to reach.
|
|
7
|
+
* Connect owns the VAPID keys it signs with, the devices each member has
|
|
8
|
+
* registered across every application on the platform, that member's own
|
|
9
|
+
* notification preferences, and the bookkeeping when a browser goes away.
|
|
10
|
+
*
|
|
11
|
+
* So you never hold a push credential, never store an endpoint, and cannot
|
|
12
|
+
* reach anybody who is not an active member of the workspace you name -
|
|
13
|
+
* membership is re-checked on every send rather than trusted from the ids you
|
|
14
|
+
* pass.
|
|
15
|
+
*
|
|
16
|
+
* ## Two halves, and only `send` belongs on your server
|
|
17
|
+
*
|
|
18
|
+
* Registering a device happens in the **browser**, because a subscription is
|
|
19
|
+
* produced by `pushManager.subscribe()` against the member's own browser and
|
|
20
|
+
* no server can make one for them. `registerDevice` and `revokeDevice` below
|
|
21
|
+
* therefore take a session token and are what the SDK bridge calls on the
|
|
22
|
+
* browser's behalf - see `createConnectHandler`. You will normally reach them
|
|
23
|
+
* through `useConnectPush()` in `@cosmoops/connect-sdk/client` rather than
|
|
24
|
+
* directly.
|
|
25
|
+
*/
|
|
26
|
+
export class PushResource {
|
|
27
|
+
transport;
|
|
28
|
+
constructor(transport) {
|
|
29
|
+
this.transport = transport;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Notify members of a workspace.
|
|
33
|
+
*
|
|
34
|
+
* **`delivered: 0` is a success, not a failure.** The member may have
|
|
35
|
+
* registered no device, silenced notifications from their apps, or denied
|
|
36
|
+
* the browser prompt - all ordinary. A caller that retries on zero will
|
|
37
|
+
* retry forever against somebody who has opted out.
|
|
38
|
+
*
|
|
39
|
+
* Treat every send as a nudge toward something already true in your own
|
|
40
|
+
* data, and never let a failure here fail the thing that triggered it: the
|
|
41
|
+
* reminder is worth less than the record it is about.
|
|
42
|
+
*
|
|
43
|
+
* `url` must be absolute and on an origin this application registered as an
|
|
44
|
+
* SSO redirect URI. Connect refuses anything else, because a URL it did not
|
|
45
|
+
* check is an open redirect delivered to a lock screen.
|
|
46
|
+
*/
|
|
47
|
+
send(input) {
|
|
48
|
+
return request(this.transport, { method: "POST", path: "/push", json: input });
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The VAPID public key a browser needs before it can subscribe, and whether
|
|
52
|
+
* the platform can send at all.
|
|
53
|
+
*
|
|
54
|
+
* Safe to hand to any signed-in caller: it is the verification half of the
|
|
55
|
+
* pair, so holding it lets a browser *receive* what Connect signs and never
|
|
56
|
+
* send anything.
|
|
57
|
+
*/
|
|
58
|
+
key(options) {
|
|
59
|
+
return request(this.transport, {
|
|
60
|
+
method: "GET",
|
|
61
|
+
path: "/push/subscriptions",
|
|
62
|
+
headers: actingAs(options.asUser),
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
/** Register one browser, from a subscription that browser just produced. */
|
|
66
|
+
registerDevice(input, options) {
|
|
67
|
+
return request(this.transport, {
|
|
68
|
+
method: "POST",
|
|
69
|
+
path: "/push/subscriptions",
|
|
70
|
+
json: input,
|
|
71
|
+
headers: actingAs(options.asUser),
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Stop notifications on one browser.
|
|
76
|
+
*
|
|
77
|
+
* The id is the one `registerDevice` returned. Connect re-checks that it
|
|
78
|
+
* belongs to the calling member, so a guessed id is a refusal rather than
|
|
79
|
+
* somebody else's device going quiet.
|
|
80
|
+
*/
|
|
81
|
+
revokeDevice(id, options) {
|
|
82
|
+
return request(this.transport, {
|
|
83
|
+
method: "DELETE",
|
|
84
|
+
path: `/push/subscriptions?id=${encodeURIComponent(id)}`,
|
|
85
|
+
headers: actingAs(options.asUser),
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { request } from "../http.js";
|
|
2
|
+
/**
|
|
3
|
+
* The header a bridge forwards the visitor's own address on.
|
|
4
|
+
*
|
|
5
|
+
* It exists because `x-forwarded-for` cannot do this job here: by the time your
|
|
6
|
+
* backend calls Connect, that header describes the hop from *your server*, and
|
|
7
|
+
* the address Connect's per-visitor limiter needs is the one on the hop before
|
|
8
|
+
* it - which only your bridge has seen.
|
|
9
|
+
*
|
|
10
|
+
* **What it is and is not trusted for.** It partitions your own application's
|
|
11
|
+
* budget fairly between visitors; it does not grant anything. A caller sending a
|
|
12
|
+
* fresh value per request defeats only the per-visitor limiter and is still
|
|
13
|
+
* bounded by the per-key burst limit and the per-application daily ceiling -
|
|
14
|
+
* which are the two that actually cap spending, and neither is client-supplied.
|
|
15
|
+
* Omit it and every visitor shares one bucket, which is a stricter limit rather
|
|
16
|
+
* than an exemption.
|
|
17
|
+
*/
|
|
18
|
+
const VISITOR_IP_HEADER = "x-connect-visitor-ip";
|
|
19
|
+
function visitorHeaders(options) {
|
|
20
|
+
return options?.visitorIp ? { [VISITOR_IP_HEADER]: options.visitorIp } : {};
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* The public chat bubble on your own marketing site.
|
|
24
|
+
*
|
|
25
|
+
* The one resource here whose caller is **not** one of your users. There is no
|
|
26
|
+
* `asUser`, no `workspaceId` and no session anywhere in it: the counterpart is a
|
|
27
|
+
* stranger who has no account, and everything the assistant may say or do is
|
|
28
|
+
* configuration a platform admin set on your application record.
|
|
29
|
+
*
|
|
30
|
+
* That is why none of these methods takes an id naming anything. The workspace a
|
|
31
|
+
* call books into, the event type it books, the knowledge it answers from and the
|
|
32
|
+
* daily budget it spends are all read from your application - so nothing a
|
|
33
|
+
* visitor types, and nothing an injection attempt in your own chat window
|
|
34
|
+
* achieves, reaches a different tenant.
|
|
35
|
+
*
|
|
36
|
+
* **Never call these from a browser.** They take your API key, like every other
|
|
37
|
+
* resource on this client. `createConnectHandler` already exposes them on your
|
|
38
|
+
* own origin for `<ConnectVisitorChat>` to use, which is how the credential stays
|
|
39
|
+
* server-side.
|
|
40
|
+
*/
|
|
41
|
+
export class VisitorResource {
|
|
42
|
+
transport;
|
|
43
|
+
/**
|
|
44
|
+
* The knowledge base behind the assistant - see `VisitorKnowledgeResource`.
|
|
45
|
+
*
|
|
46
|
+
* Nested rather than a top-level resource, because it is meaningless on its
|
|
47
|
+
* own: it exists to decide what *this* assistant may say.
|
|
48
|
+
*/
|
|
49
|
+
knowledge;
|
|
50
|
+
constructor(transport) {
|
|
51
|
+
this.transport = transport;
|
|
52
|
+
this.knowledge = new VisitorKnowledgeResource(transport);
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Open a conversation, or reopen one.
|
|
56
|
+
*
|
|
57
|
+
* Generates nothing - the greeting and the suggested questions come off the
|
|
58
|
+
* application record - so this is the one call in the flow that is always fast,
|
|
59
|
+
* which is what lets a bubble paint real copy instead of a spinner.
|
|
60
|
+
*
|
|
61
|
+
* Pass `resume` with a `sessionId` you kept and the transcript comes back with
|
|
62
|
+
* it. A `resume` that is expired, unknown or another application's silently
|
|
63
|
+
* starts a fresh conversation rather than failing: the token is the only thing
|
|
64
|
+
* being offered and it is being rejected, so there is nothing for the visitor
|
|
65
|
+
* to do about an error.
|
|
66
|
+
*/
|
|
67
|
+
startSession(input, options = {}) {
|
|
68
|
+
return request(this.transport, {
|
|
69
|
+
method: "POST",
|
|
70
|
+
path: "/visitor/session",
|
|
71
|
+
json: {
|
|
72
|
+
workspaceId: input.workspaceId,
|
|
73
|
+
page: input.page ?? null,
|
|
74
|
+
resume: input.resume ?? null,
|
|
75
|
+
},
|
|
76
|
+
headers: visitorHeaders(options),
|
|
77
|
+
signal: options.signal,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* One turn, streamed.
|
|
82
|
+
*
|
|
83
|
+
* Returns the raw `Response` rather than a parsed body, the same way
|
|
84
|
+
* `ai.chat` does: this answers server-sent events, and buffering them here
|
|
85
|
+
* would throw away the only reason to stream. The events are `delta`
|
|
86
|
+
* (`{text}`), `done` (`VisitorTurnDone`) and `error` (`{message}`).
|
|
87
|
+
*
|
|
88
|
+
* **The transcript is not a parameter**, which is the security property this
|
|
89
|
+
* endpoint is built around: the caller is ultimately a browser on a public
|
|
90
|
+
* page, and a client that can supply its own transcript can supply one in
|
|
91
|
+
* which anything has already been established. Only the conversation id and
|
|
92
|
+
* the new message travel.
|
|
93
|
+
*/
|
|
94
|
+
chat(input, options = {}) {
|
|
95
|
+
return request(this.transport, {
|
|
96
|
+
method: "POST",
|
|
97
|
+
path: "/visitor/chat",
|
|
98
|
+
json: input,
|
|
99
|
+
headers: visitorHeaders(options),
|
|
100
|
+
raw: true,
|
|
101
|
+
signal: options.signal,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Book the call a visitor chose.
|
|
106
|
+
*
|
|
107
|
+
* `startAt` must be a time the turn's own slot lookup returned - an arbitrary
|
|
108
|
+
* instant is refused. That, plus a name and phone collected in real fields,
|
|
109
|
+
* is what makes it impossible to book an appointment at a time nobody offered
|
|
110
|
+
* or with a phone number a model parsed out of prose.
|
|
111
|
+
*
|
|
112
|
+
* A conversation books at most one appointment; a second call answers `409`.
|
|
113
|
+
*/
|
|
114
|
+
book(input, options = {}) {
|
|
115
|
+
return request(this.transport, {
|
|
116
|
+
method: "POST",
|
|
117
|
+
path: "/visitor/book",
|
|
118
|
+
headers: visitorHeaders(options),
|
|
119
|
+
signal: options.signal,
|
|
120
|
+
json: {
|
|
121
|
+
...input,
|
|
122
|
+
startAt: input.startAt instanceof Date ? input.startAt.toISOString() : input.startAt,
|
|
123
|
+
email: input.email ?? null,
|
|
124
|
+
notes: input.notes ?? null,
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* A subscribing workspace's **public** knowledge base - what their website chat
|
|
131
|
+
* may tell strangers.
|
|
132
|
+
*
|
|
133
|
+
* ## It is deliberately not their Context Index
|
|
134
|
+
*
|
|
135
|
+
* `ai.chat` answers from a workspace's Drive-fed corpus, for a signed-in member
|
|
136
|
+
* of that workspace. This is a separate store that holds only what somebody
|
|
137
|
+
* explicitly published, and the separation is the reason a publishable widget
|
|
138
|
+
* credential is safe to publish: everything the widget can reach was already
|
|
139
|
+
* meant for the public.
|
|
140
|
+
*
|
|
141
|
+
* Do not try to fill it by uploading to Drive. Nothing reaches this store as a
|
|
142
|
+
* side effect of anything else - that is the invariant, not an inconvenience.
|
|
143
|
+
*
|
|
144
|
+
* ## Publishing is its own call
|
|
145
|
+
*
|
|
146
|
+
* `save` always writes a draft. `publish` is what makes text readable by
|
|
147
|
+
* strangers, so no edit can do it by accident, and `unpublish` can fail - which
|
|
148
|
+
* means the item is **still live**, not that the call was pointless.
|
|
149
|
+
*/
|
|
150
|
+
export class VisitorKnowledgeResource {
|
|
151
|
+
transport;
|
|
152
|
+
constructor(transport) {
|
|
153
|
+
this.transport = transport;
|
|
154
|
+
}
|
|
155
|
+
/** Everything, drafts included - the list a customer audits. */
|
|
156
|
+
list(workspaceId) {
|
|
157
|
+
return request(this.transport, {
|
|
158
|
+
method: "GET",
|
|
159
|
+
path: "/visitor/knowledge",
|
|
160
|
+
query: { workspaceId },
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Create or edit a draft.
|
|
165
|
+
*
|
|
166
|
+
* A body over the cap is **refused rather than truncated** - split a long page
|
|
167
|
+
* into sections, which is also what makes retrieval work.
|
|
168
|
+
*/
|
|
169
|
+
async save(input) {
|
|
170
|
+
const result = await request(this.transport, {
|
|
171
|
+
method: "POST",
|
|
172
|
+
path: "/visitor/knowledge",
|
|
173
|
+
json: {
|
|
174
|
+
...input,
|
|
175
|
+
itemId: input.itemId ?? null,
|
|
176
|
+
sourceRef: input.sourceRef ?? null,
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
return result.item;
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Make one item readable by strangers.
|
|
183
|
+
*
|
|
184
|
+
* May be slow: the publish that outgrows the prompt provisions a retrieval
|
|
185
|
+
* corpus and indexes everything published so far. An indexing failure still
|
|
186
|
+
* publishes and comes back with `indexState: "failed"` and `indexError` set,
|
|
187
|
+
* so a provider hiccup never makes your button look broken.
|
|
188
|
+
*/
|
|
189
|
+
async publish(input) {
|
|
190
|
+
const result = await request(this.transport, {
|
|
191
|
+
method: "POST",
|
|
192
|
+
path: "/visitor/knowledge/publish",
|
|
193
|
+
json: input,
|
|
194
|
+
});
|
|
195
|
+
return result.item;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Take one item out of what strangers can read.
|
|
199
|
+
*
|
|
200
|
+
* **A failure means it is still live.** The retrievable copy is removed before
|
|
201
|
+
* the record is flipped, so a provider that refuses the delete aborts rather
|
|
202
|
+
* than leaving an item your screen shows as withdrawn that the widget goes on
|
|
203
|
+
* quoting. Surface the error and let them retry; do not grey the row out
|
|
204
|
+
* optimistically.
|
|
205
|
+
*/
|
|
206
|
+
async unpublish(input) {
|
|
207
|
+
const result = await request(this.transport, {
|
|
208
|
+
method: "POST",
|
|
209
|
+
path: "/visitor/knowledge/unpublish",
|
|
210
|
+
json: input,
|
|
211
|
+
});
|
|
212
|
+
return result.item;
|
|
213
|
+
}
|
|
214
|
+
/** Remove it entirely. A published item is unpublished first. */
|
|
215
|
+
async delete(input) {
|
|
216
|
+
await request(this.transport, {
|
|
217
|
+
method: "POST",
|
|
218
|
+
path: "/visitor/knowledge/delete",
|
|
219
|
+
json: input,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
export interface ConnectVisitorChatProps {
|
|
2
|
+
/**
|
|
3
|
+
* The customer whose website this widget is on.
|
|
4
|
+
*
|
|
5
|
+
* Required, and it is the one field that decides whose published knowledge the
|
|
6
|
+
* assistant answers from and whose diary it books into. Your bridge checks it
|
|
7
|
+
* against your own subscriptions before it reaches Connect.
|
|
8
|
+
*/
|
|
9
|
+
workspaceId: string;
|
|
10
|
+
/**
|
|
11
|
+
* What the assistant calls itself, and the copy shown before anybody types.
|
|
12
|
+
*
|
|
13
|
+
* **Yours, not Connect's.** These never reach the model and are rendered from
|
|
14
|
+
* your own store, which is what lets the bubble paint its first frame with no
|
|
15
|
+
* network call at all. Connect holds what it enforces; you hold what you draw.
|
|
16
|
+
*/
|
|
17
|
+
assistantName?: string;
|
|
18
|
+
greeting?: string;
|
|
19
|
+
/** Opening questions offered as buttons. Three is what fits above a phone keyboard. */
|
|
20
|
+
suggestions?: readonly string[];
|
|
21
|
+
/** A published event type slug in that workspace, to offer a call. Omit for no booking. */
|
|
22
|
+
bookingEventTypeSlug?: string | null;
|
|
23
|
+
/**
|
|
24
|
+
* Where `createConnectHandler` is mounted. Defaults to `/api/connect`, which
|
|
25
|
+
* is its own default.
|
|
26
|
+
*
|
|
27
|
+
* Passed explicitly here, rather than only inherited from `ConnectProvider`,
|
|
28
|
+
* because this component is built to work on a page that has no provider -
|
|
29
|
+
* see the component's own note.
|
|
30
|
+
*/
|
|
31
|
+
basePath?: string;
|
|
32
|
+
/**
|
|
33
|
+
* The page the visitor is on, passed to Connect as context.
|
|
34
|
+
*
|
|
35
|
+
* Left unset it uses `location.pathname`. Pass something more meaningful - a
|
|
36
|
+
* page title, a product slug - where the path is not descriptive.
|
|
37
|
+
*/
|
|
38
|
+
page?: string;
|
|
39
|
+
/** The bubble's accessible label, and the panel's heading. */
|
|
40
|
+
title?: string;
|
|
41
|
+
/** Start open. The panel owns its state after that. */
|
|
42
|
+
defaultOpen?: boolean;
|
|
43
|
+
/**
|
|
44
|
+
* Show the bubble even to a signed-in user. Off by default.
|
|
45
|
+
*
|
|
46
|
+
* The default is the useful one: a signed-in member has your product's own
|
|
47
|
+
* support surfaces and, if you mount it, `<ConnectAskAi>` - which is grounded
|
|
48
|
+
* in their actual workspace and can therefore answer things this assistant
|
|
49
|
+
* cannot. Two bubbles in the same corner is also the one layout bug this
|
|
50
|
+
* component could cause, since `<ConnectAskAi>` sits at the same `z-30`
|
|
51
|
+
* bottom-right position.
|
|
52
|
+
*
|
|
53
|
+
* Turn it on for a marketing site that is a separate surface from the
|
|
54
|
+
* application, where "signed in" says nothing about what the visitor is
|
|
55
|
+
* reading.
|
|
56
|
+
*/
|
|
57
|
+
showWhenSignedIn?: boolean;
|
|
58
|
+
/**
|
|
59
|
+
* Replaces `fetch`, for a mount with **no `ConnectProvider`** around it.
|
|
60
|
+
*
|
|
61
|
+
* Inside a provider this is not needed and not read: the component goes
|
|
62
|
+
* through `mutateResource`/`streamResource`, which already use whatever
|
|
63
|
+
* `fetch` that provider was given - so Storybook and the docs site work by
|
|
64
|
+
* wrapping it, with nothing passed here.
|
|
65
|
+
*/
|
|
66
|
+
fetch?: (input: string, init?: RequestInit) => Promise<Response>;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* The public chat bubble: a visitor with no account asks about your product,
|
|
70
|
+
* books a call, or gets started.
|
|
71
|
+
*
|
|
72
|
+
* ## It runs on pages that have no `ConnectProvider`
|
|
73
|
+
*
|
|
74
|
+
* Deliberately, and it is the main thing separating this component from every
|
|
75
|
+
* other one here. A marketing page usually has no session to hold and nothing
|
|
76
|
+
* on it that needs one, so requiring the provider would mean wrapping a
|
|
77
|
+
* landing page in an auth context to put a chat bubble on it. It reads the
|
|
78
|
+
* context through `useConnectOptional` and uses it only for `basePath` and the
|
|
79
|
+
* injected `fetch`; both have props, and both have defaults.
|
|
80
|
+
*
|
|
81
|
+
* ## Nothing happens until somebody opens it
|
|
82
|
+
*
|
|
83
|
+
* The conversation is minted on first open, never on mount. On a busy marketing
|
|
84
|
+
* site mounting would mean one Firestore write and one wasted session row per
|
|
85
|
+
* *page view*, for a bubble most visitors never touch - and the bubble itself
|
|
86
|
+
* needs no server state to render. The greeting arrives with that first call
|
|
87
|
+
* and is stored copy rather than generated, so opening costs one fast round
|
|
88
|
+
* trip and the visitor is reading real words immediately.
|
|
89
|
+
*
|
|
90
|
+
* ## It is built for a phone first
|
|
91
|
+
*
|
|
92
|
+
* Open, it is a full-height sheet on a narrow viewport and an anchored panel
|
|
93
|
+
* from `sm` up - a marketing chat that covers a desktop browser is harder to
|
|
94
|
+
* use, and one that does not cover a phone has nowhere to put a keyboard.
|
|
95
|
+
* `h-dvh` rather than `h-screen`, so the sheet tracks the visual viewport when
|
|
96
|
+
* the keyboard opens; `env(safe-area-inset-bottom)` on the composer, so the
|
|
97
|
+
* send button is not under a home indicator.
|
|
98
|
+
*
|
|
99
|
+
* The composer's input type is **the server's decision**, not a guess from what
|
|
100
|
+
* the assistant said - see `VisitorField`. That is what puts an `@` on the
|
|
101
|
+
* keyboard when an email is wanted and lets iOS offer a verification code
|
|
102
|
+
* straight from the notification.
|
|
103
|
+
*
|
|
104
|
+
* ## The times are drawn from the tool result, never the prose
|
|
105
|
+
*
|
|
106
|
+
* A turn that looked up availability carries the slots as structured data, and
|
|
107
|
+
* the buttons come from that. So a time the visitor taps is one the server
|
|
108
|
+
* offered, which is the property `/visitor/book` relies on and cannot check for
|
|
109
|
+
* itself.
|
|
110
|
+
*
|
|
111
|
+
* ## Replies render as text, not markdown
|
|
112
|
+
*
|
|
113
|
+
* `<ConnectAskAi>` lazy-loads a markdown renderer because it answers questions
|
|
114
|
+
* about a workspace's documents, where tables and code blocks carry meaning.
|
|
115
|
+
* This answers three-sentence questions about a product on a marketing page.
|
|
116
|
+
* Shipping 462 KB of renderer to a landing page for prose that is deliberately
|
|
117
|
+
* kept to a couple of lines is the wrong trade, so line breaks are preserved
|
|
118
|
+
* and nothing else is parsed.
|
|
119
|
+
*/
|
|
120
|
+
export declare function ConnectVisitorChat({ workspaceId, assistantName, greeting, suggestions, bookingEventTypeSlug, basePath, page, title, defaultOpen, showWhenSignedIn, fetch: fetchProp, }: ConnectVisitorChatProps): import("react").JSX.Element | null;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { VisitorSlotOffer, VisitorToolCall, VisitorTurnDone } from "../../types.js";
|
|
2
|
+
/**
|
|
3
|
+
* The visitor turn's wire format, read.
|
|
4
|
+
*
|
|
5
|
+
* **A named-event reader, where `<ConnectAskAi>`'s is data-only.** That one
|
|
6
|
+
* consumes the AI SDK's UI message stream, where every frame is a `data:` line
|
|
7
|
+
* carrying a `type` field. This endpoint answers `event:`/`data:` pairs instead,
|
|
8
|
+
* for the reason the route explains: the UI message protocol exists for
|
|
9
|
+
* `useChat`, which owns the message list client-side, and this surface
|
|
10
|
+
* deliberately does not let the client own the transcript.
|
|
11
|
+
*
|
|
12
|
+
* Kept out of the component and pure so it can be tested without a DOM. The
|
|
13
|
+
* three behaviours worth pinning are all here: a frame split across two network
|
|
14
|
+
* reads, a malformed frame, and a stream that ends without its `done`.
|
|
15
|
+
*/
|
|
16
|
+
export interface VisitorStreamHandlers {
|
|
17
|
+
onDelta(text: string): void;
|
|
18
|
+
onDone(done: VisitorTurnDone): void;
|
|
19
|
+
/**
|
|
20
|
+
* A failure the server reported *after* the stream opened.
|
|
21
|
+
*
|
|
22
|
+
* It arrives as an event rather than a status because the status was already
|
|
23
|
+
* sent, and the message is written as a sentence for the same reason - it is
|
|
24
|
+
* shown to the visitor verbatim.
|
|
25
|
+
*/
|
|
26
|
+
onError(message: string): void;
|
|
27
|
+
}
|
|
28
|
+
export declare function readVisitorStream(response: Response, handlers: VisitorStreamHandlers): Promise<void>;
|
|
29
|
+
/**
|
|
30
|
+
* The times a turn looked up, lifted out of its tool calls.
|
|
31
|
+
*
|
|
32
|
+
* **This is what the buttons are drawn from, never the assistant's sentence.** A
|
|
33
|
+
* time the visitor taps is then one the server offered, which is what makes it
|
|
34
|
+
* impossible to book an appointment at a time nobody gave - the property
|
|
35
|
+
* `/visitor/book` depends on and cannot check for itself.
|
|
36
|
+
*
|
|
37
|
+
* `null` when the turn looked nothing up, and also when the lookup *refused* -
|
|
38
|
+
* a tool answering `ok: false` is the assistant being told there is nothing
|
|
39
|
+
* available, and it says so in prose. Rendering an empty slot list under that
|
|
40
|
+
* would be a second, emptier way of saying the same thing.
|
|
41
|
+
*/
|
|
42
|
+
export declare function visitorSlotOfferFrom(toolCalls: readonly VisitorToolCall[]): VisitorSlotOffer | null;
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* whatever theme the host application platform already configured, and one theme
|
|
11
11
|
* change lands identically in all of them.
|
|
12
12
|
*/
|
|
13
|
-
export { ConnectProvider, useConnect, useConnectUser, useConnectWorkspaceType, useConnectAudience, useConnectPlatformAccess, useConnectEntitlements, useConnectPlans, useConnectApps, useConnectCompanyProfile, useConnectAddonUsage, useConnectSessions, useConnectIntegrations, useConnectResource, CONNECT_APP_URL, } from "./provider.js";
|
|
13
|
+
export { ConnectProvider, useConnect, useConnectOptional, useConnectUser, useConnectWorkspaceType, useConnectAudience, useConnectPlatformAccess, useConnectEntitlements, useConnectPlans, useConnectApps, useConnectCompanyProfile, useConnectAddonUsage, useConnectSessions, useConnectIntegrations, useConnectResource, CONNECT_APP_URL, } from "./provider.js";
|
|
14
14
|
export type { ConnectProviderProps, ConnectContextValue, ConnectSession, ConnectOneTapHint, ConnectOneTapFailure, UseConnectResourceOptions, } from "./provider.js";
|
|
15
15
|
export { blockingBlockers, normalizePlanFeatures } from "../helpers.js";
|
|
16
16
|
export type { WirePlanFeature } from "../helpers.js";
|
|
@@ -41,10 +41,22 @@ export type { ConnectShellContextValue } from "./components/shell-context.js";
|
|
|
41
41
|
export { ConnectSidebarNav, ConnectSideNav } from "./components/side-nav.js";
|
|
42
42
|
export type { ConnectSidebarNavProps, ConnectSideNavProps } from "./components/side-nav.js";
|
|
43
43
|
export { useActivePath } from "./use-active-path.js";
|
|
44
|
+
/**
|
|
45
|
+
* Web Push. The hook owns the browser half - permission, the worker, the
|
|
46
|
+
* subscription - and Connect owns the keys, the devices and the member's
|
|
47
|
+
* preferences. Sending is server-side, through `connect.push.send`.
|
|
48
|
+
*/
|
|
49
|
+
export { useConnectPush } from "./use-push.js";
|
|
50
|
+
export type { ConnectPushStatus, UseConnectPush } from "./use-push.js";
|
|
44
51
|
export { normalizeNav, flattenNav, normalizePath, resolveActivePath, isNavItemActive, gateExemptPaths, filterNavForAudience, } from "./components/nav.js";
|
|
45
52
|
export type { ConnectNav, ConnectNavItem, ConnectNavSection, ConnectNavLinkProps, ConnectLinkRenderer, } from "./components/nav.js";
|
|
46
53
|
export type { ConnectScreen } from "../types.js";
|
|
47
54
|
export { matchConnectScreen, isLinkableConnectScreen, } from "./components/ai-screens.js";
|
|
55
|
+
export { ConnectVisitorChat } from "./components/visitor-chat.js";
|
|
56
|
+
export type { ConnectVisitorChatProps } from "./components/visitor-chat.js";
|
|
57
|
+
export { readVisitorStream, visitorSlotOfferFrom } from "./components/visitor-stream.js";
|
|
58
|
+
export type { VisitorStreamHandlers } from "./components/visitor-stream.js";
|
|
59
|
+
export type { VisitorAppointment, VisitorBooking, VisitorField, VisitorMessage, VisitorSession, VisitorSlot, VisitorSlotOffer, VisitorToolCall, VisitorTurnDone, } from "../types.js";
|
|
48
60
|
export { ConnectAppsGrid } from "./components/apps-grid.js";
|
|
49
61
|
export type { ConnectAppsGridProps } from "./components/apps-grid.js";
|
|
50
62
|
export { ConnectAskAi } from "./components/ask-ai.js";
|
|
@@ -245,6 +245,27 @@ export interface ConnectProviderProps {
|
|
|
245
245
|
*/
|
|
246
246
|
export declare function ConnectProvider({ clientId, basePath, redirectUri, appUrl, fetch: fetchImpl, silentSignIn, children, }: ConnectProviderProps): import("react").JSX.Element;
|
|
247
247
|
export declare function useConnect(): ConnectContextValue;
|
|
248
|
+
/**
|
|
249
|
+
* The context when there is one, `null` when there is not.
|
|
250
|
+
*
|
|
251
|
+
* **For a component that must work on a page with no provider**, which is a
|
|
252
|
+
* real case rather than defensive coding: `<ConnectVisitorChat>` goes on a
|
|
253
|
+
* public marketing page, and those pages frequently have no `ConnectProvider`
|
|
254
|
+
* anywhere near them - there is no session to hold and nothing on the page that
|
|
255
|
+
* needs one. `useConnect()` throwing is right for every component that reads a
|
|
256
|
+
* session; it is wrong for one whose whole purpose is the visitor who has none.
|
|
257
|
+
*
|
|
258
|
+
* What inheriting the context buys such a component, when it is inside one, is
|
|
259
|
+
* `basePath` and the injected `fetch` - so the same component renders against
|
|
260
|
+
* Storybook's mock bridge and the docs site's canned fetch without either of
|
|
261
|
+
* them having to pass anything.
|
|
262
|
+
*
|
|
263
|
+
* Do not reach for this to make an ordinary component tolerant of a missing
|
|
264
|
+
* provider. A session-reading component that silently renders a signed-out
|
|
265
|
+
* state because somebody forgot to wrap their app is a bug that looks like a
|
|
266
|
+
* product decision.
|
|
267
|
+
*/
|
|
268
|
+
export declare function useConnectOptional(): ConnectContextValue | null;
|
|
248
269
|
/** The signed-in user, or `null`. Sugar over `useConnect().session`. */
|
|
249
270
|
export declare function useConnectUser(): ConnectUser | null;
|
|
250
271
|
/**
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether this browser can show notifications, and whether it has been asked.
|
|
3
|
+
*
|
|
4
|
+
* Five states, not a boolean - and that is the whole reason this hook exists
|
|
5
|
+
* rather than a `<Switch>`:
|
|
6
|
+
*
|
|
7
|
+
* - `unsupported` - no service worker or no Push API. On iOS this is every
|
|
8
|
+
* browser until the site is added to the home screen, which is worth saying
|
|
9
|
+
* out loud because it is most of the phones in some markets.
|
|
10
|
+
* - `unconfigured` - the platform has no VAPID keys, so nothing could be
|
|
11
|
+
* delivered even if permission were granted.
|
|
12
|
+
* - `denied` - **and this one cannot be undone by script.** A browser will not
|
|
13
|
+
* show the prompt again once told no; only the site-settings panel in the
|
|
14
|
+
* address bar can reverse it. A control that silently does nothing when
|
|
15
|
+
* pressed is the worst version of this screen, so the state is surfaced and
|
|
16
|
+
* the UI is expected to explain it.
|
|
17
|
+
* - `prompt` - askable.
|
|
18
|
+
* - `granted` - permission held. It does **not** follow that this browser is
|
|
19
|
+
* registered with Connect; permission and registration are separate facts,
|
|
20
|
+
* and a member who cleared site data holds the first without the second.
|
|
21
|
+
*/
|
|
22
|
+
export type ConnectPushStatus = "checking" | "unsupported" | "unconfigured" | "denied" | "prompt" | "granted";
|
|
23
|
+
export interface UseConnectPush {
|
|
24
|
+
status: ConnectPushStatus;
|
|
25
|
+
/** True while `enable()` is in flight. */
|
|
26
|
+
isEnabling: boolean;
|
|
27
|
+
/** The last failure, in words a person can act on. Cleared by the next attempt. */
|
|
28
|
+
error: string | null;
|
|
29
|
+
/**
|
|
30
|
+
* Ask for permission, register this browser with Connect, and resolve the
|
|
31
|
+
* device id - or `null` if the member declined or anything refused.
|
|
32
|
+
*
|
|
33
|
+
* Must be called from a user gesture: browsers refuse
|
|
34
|
+
* `Notification.requestPermission()` outside one, and the refusal is
|
|
35
|
+
* indistinguishable from the member saying no.
|
|
36
|
+
*/
|
|
37
|
+
enable: () => Promise<string | null>;
|
|
38
|
+
/** Stop notifications on one browser, by the id `enable()` returned. */
|
|
39
|
+
disable: (deviceId: string) => Promise<void>;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Register this browser for the platform's notifications.
|
|
43
|
+
*
|
|
44
|
+
* The subscription is **Connect's, not your application's**: a member who
|
|
45
|
+
* turns notifications on in one app has turned them on for the platform, and
|
|
46
|
+
* the same device receives every other app's notifications and Connect's own.
|
|
47
|
+
* That is the point of centralising it - one browser prompt instead of one per
|
|
48
|
+
* app, and one place to turn everything off.
|
|
49
|
+
*
|
|
50
|
+
* Your application needs a service worker at its own origin that handles the
|
|
51
|
+
* `push` event; the SDK does not install one, because a worker is a
|
|
52
|
+
* site-wide concern with caching implications only the host can decide.
|
|
53
|
+
*
|
|
54
|
+
* @param serviceWorkerUrl Where your worker lives. Root-scoped by convention.
|
|
55
|
+
*/
|
|
56
|
+
export declare function useConnectPush(serviceWorkerUrl?: string): UseConnectPush;
|
|
@@ -21,6 +21,8 @@ import { AnalyticsResource } from "./resources/analytics.js";
|
|
|
21
21
|
import { OtpResource } from "./resources/otp.js";
|
|
22
22
|
import { SessionsResource } from "./resources/sessions.js";
|
|
23
23
|
import { PlatformResource } from "./resources/platform.js";
|
|
24
|
+
import { VisitorResource } from "./resources/visitor.js";
|
|
25
|
+
import { PushResource } from "./resources/push.js";
|
|
24
26
|
export declare const DEFAULT_BASE_URL = "https://connect.cosmoops.com";
|
|
25
27
|
export interface ConnectClientOptions {
|
|
26
28
|
/**
|
|
@@ -110,8 +112,18 @@ export declare class ConnectClient {
|
|
|
110
112
|
readonly integrations: IntegrationsResource;
|
|
111
113
|
/** Which workspaces the signed-in user belongs to, switching between them, and the team - see `WorkspacesResource`. */
|
|
112
114
|
readonly workspaces: WorkspacesResource;
|
|
115
|
+
/**
|
|
116
|
+
* The public chat bubble on your own site - see `VisitorResource`. The one
|
|
117
|
+
* resource here whose caller is not one of your users.
|
|
118
|
+
*/
|
|
119
|
+
readonly visitor: VisitorResource;
|
|
113
120
|
/** The platform's own legal identity and DPDP grievance channel - see `PlatformResource`. */
|
|
114
121
|
readonly platform: PlatformResource;
|
|
122
|
+
/**
|
|
123
|
+
* Web Push. Connect holds the VAPID keys, the devices and the member's own
|
|
124
|
+
* preferences; you supply a title, a body and a URL. See `PushResource`.
|
|
125
|
+
*/
|
|
126
|
+
readonly push: PushResource;
|
|
115
127
|
/**
|
|
116
128
|
* Relays one `/data/*` or `/kv/*` request verbatim - `createConnectHandler`'s
|
|
117
129
|
* store route and nothing else. See `storePassthrough`.
|
|
@@ -126,4 +138,4 @@ export declare class ConnectClient {
|
|
|
126
138
|
constructor(options: ConnectClientOptions);
|
|
127
139
|
}
|
|
128
140
|
/** Kept in step with `package.json` by `sdk.test.ts`. */
|
|
129
|
-
export declare const SDK_VERSION = "0.
|
|
141
|
+
export declare const SDK_VERSION = "0.32.0";
|
|
@@ -40,28 +40,4 @@ export interface ConnectHandlerOptions {
|
|
|
40
40
|
*/
|
|
41
41
|
stateCookieName?: string;
|
|
42
42
|
}
|
|
43
|
-
/**
|
|
44
|
-
* The browser half of this SDK cannot hold an API key - so it does not.
|
|
45
|
-
*
|
|
46
|
-
* `@cosmoops/connect-sdk/client` talks only to your own origin, and this
|
|
47
|
-
* handler is what answers. It lives in your backend, holds the key, and
|
|
48
|
-
* exposes a fixed, named set of reads plus sign-out and session revocation.
|
|
49
|
-
* Nothing here proxies arbitrary paths: a generic passthrough would hand the
|
|
50
|
-
* browser your whole API surface under your credentials, which is the same
|
|
51
|
-
* leak with extra steps.
|
|
52
|
-
*
|
|
53
|
-
* Framework-agnostic - `Request` in, `Response` out, so it mounts anywhere
|
|
54
|
-
* that shape exists.
|
|
55
|
-
*
|
|
56
|
-
* ```ts
|
|
57
|
-
* // app/api/connect/[...route]/route.ts
|
|
58
|
-
* const handler = createConnectHandler({ client, session });
|
|
59
|
-
* export { handler as GET, handler as POST };
|
|
60
|
-
* ```
|
|
61
|
-
*
|
|
62
|
-
* Every route resolves the workspace from the caller's *own* session, never
|
|
63
|
-
* from a query parameter. A browser must not be able to ask about an
|
|
64
|
-
* workspace it is not signed into, and a client-supplied `workspaceId`
|
|
65
|
-
* is exactly that hole.
|
|
66
|
-
*/
|
|
67
43
|
export declare function createConnectHandler(options: ConnectHandlerOptions): (request: Request) => Promise<Response>;
|