@ahmedramez-org/nexus-chat 1.0.0-beta.5 → 1.0.0-beta.7
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 +90 -20
- package/contract/index.d.ts +15 -2
- package/index.d.ts +15 -1
- package/index.js +146 -10
- package/package.json +2 -1
- package/react.d.ts +39 -17
- package/react.js +143 -288
- package/window.js +378 -0
package/README.md
CHANGED
|
@@ -1,14 +1,31 @@
|
|
|
1
1
|
# @incorta/nexus-chat
|
|
2
2
|
|
|
3
|
-
NexusChat, the IncortaCopilot chat UI, for any app on any framework: `<
|
|
4
|
-
`mountNexusChat()` for everything else.
|
|
3
|
+
NexusChat, the IncortaCopilot chat UI, for any app on any framework: one `<script>` tag for any
|
|
4
|
+
site, `<NexusChat />` for React, `mountNexusChat()` for everything else.
|
|
5
5
|
|
|
6
6
|
The chat always runs as a prebuilt Module Federation bundle with its own React and isolated
|
|
7
7
|
styles, never re-compiled by the app's bundler, so it never collides with the app's React, its
|
|
8
|
-
dependencies or its CSS.
|
|
9
|
-
|
|
8
|
+
dependencies or its CSS. The connector adds about 6 KB gzipped to an app (half of it the Incorta
|
|
9
|
+
Intelligence mark its loading skeleton draws), and the React component about 7 KB more. The chat
|
|
10
|
+
build itself is not in this package: Copilot serves it, and each GitHub Release attaches it as
|
|
10
11
|
`nexus-chat-dist.tar.gz`.
|
|
11
12
|
|
|
13
|
+
## Any site: one script
|
|
14
|
+
|
|
15
|
+
```html
|
|
16
|
+
<script src="https://<incorta>/incorta/copilot/nexus-chat/widget.js" defer></script>
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Incorta serves this script next to `embed.html`; it is not in this package and needs no build. It
|
|
20
|
+
draws the Incorta Intelligence launcher at the bottom right and opens the embedded chat (below)
|
|
21
|
+
floating, docked or full screen, on the user's Incorta session, so the page holds no token. It is
|
|
22
|
+
one classic script with no imports, so a page on any origin loads it without CORS.
|
|
23
|
+
|
|
24
|
+
Optional `data-` attributes set it up: `data-theme="dark"`, `data-modes="floating,docked"` (the
|
|
25
|
+
windows allowed, in that order; default all three), `data-default-mode="docked"` (open on load;
|
|
26
|
+
default only the launcher) and `data-locale="fr"`. The page drives it afterwards through
|
|
27
|
+
`window.NexusChatWidget`: `open()`, `close()`, `setMode('docked')`, `setTheme('dark')`.
|
|
28
|
+
|
|
12
29
|
## React
|
|
13
30
|
|
|
14
31
|
Two ways in: **direct**, where the chat mounts into the page and shares its session (Incorta's own
|
|
@@ -24,38 +41,88 @@ import { NexusChat } from '@incorta/nexus-chat/react';
|
|
|
24
41
|
```
|
|
25
42
|
|
|
26
43
|
It draws its own window: an Incorta Intelligence launcher at the bottom right opens the chat
|
|
27
|
-
floating
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
44
|
+
floating (a card lifted off the page, opening out of the launcher, which stays under it to hide
|
|
45
|
+
it) or docked flush on the right (the page makes room). The chat's own top bar switches between
|
|
46
|
+
the windows (Float, Dock to the side, Enter full screen: every allowed one but the current) and
|
|
47
|
+
closes it back to the launcher; nothing sits above the chat. `modes` limits the windows (default
|
|
48
|
+
all three, in that order) and `defaultMode` opens one on load. Below 768px wide the chat covers
|
|
49
|
+
the page. The chat is drawn smaller in a small window, and a skeleton holds the window while the
|
|
50
|
+
chat loads. `theme` is followed live; `locale` is read when the chat first opens.
|
|
33
51
|
|
|
34
52
|
The Incorta cluster needs `iframe.inclusion.enabled` (and `frame-ancestors` set to the app's
|
|
35
|
-
domains)
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
53
|
+
domains). Where the browser keeps the Incorta session from the iframe (Safari, Firefox, private
|
|
54
|
+
windows), the chat shows Continue with Incorta: the browser asks the user to allow it once, and
|
|
55
|
+
the chat starts on their Incorta session. A user not signed in to Incorta signs in through a small
|
|
56
|
+
Incorta window that closes itself afterwards. No browser setting is needed.
|
|
57
|
+
|
|
58
|
+
While the user clicks or types in the app, the component tells the chat at most every ten seconds,
|
|
59
|
+
so Incorta's own idle timeout does not end the chat's session while the app is in use. A page that
|
|
60
|
+
places the iframe itself can do the same: `iframe.contentWindow.postMessage({ nexusChat: 'active' },
|
|
61
|
+
'<incorta origin>')`. If the session ends anyway, the chat shows Sign in to Incorta.
|
|
39
62
|
|
|
40
63
|
### Direct
|
|
41
64
|
|
|
65
|
+
```ts
|
|
66
|
+
// Copilot answers 400 without the signed-in user's token: read it from Incorta's
|
|
67
|
+
// session, and read it again after a 401 or 403.
|
|
68
|
+
let session;
|
|
69
|
+
const incortaHeaders = () =>
|
|
70
|
+
(session ??= fetch('/incorta/service/user/isLoggedIn', { credentials: 'include' }).then(
|
|
71
|
+
async (response) => ({
|
|
72
|
+
Authorization: `Bearer ${(await response.json()).accessToken}`,
|
|
73
|
+
'X-XSRF-TOKEN': response.headers.get('X-XSRF-TOKEN') ?? '',
|
|
74
|
+
}),
|
|
75
|
+
));
|
|
76
|
+
```
|
|
77
|
+
|
|
42
78
|
```tsx
|
|
43
79
|
<NexusChat
|
|
44
|
-
connection={{
|
|
80
|
+
connection={{
|
|
81
|
+
client: 'general',
|
|
82
|
+
copilotUrl: '/incorta/copilot',
|
|
83
|
+
credentials: 'include',
|
|
84
|
+
headers: incortaHeaders, // read before each request
|
|
85
|
+
refreshAuth: async () => ((session = undefined), true), // after a 401 or 403: read again, retry
|
|
86
|
+
}}
|
|
45
87
|
theme="dark"
|
|
46
88
|
user={{ id: user.sub, name: user.name }}
|
|
47
89
|
onLoadError={(error) => setError(error.message)}
|
|
48
90
|
/>
|
|
49
91
|
```
|
|
50
92
|
|
|
51
|
-
The chat fills the component's box; the app draws any frame around it. Until the chat is mounted,
|
|
93
|
+
The chat fills the component's box; the app draws any frame around it. Until the chat is mounted, a skeleton holds the box (the embedded window's loading face: the Incorta Intelligence mark and two lines, after 300 ms), and it fades once the chat mounts, or leaves at once when `onLoadError` fires, so the app's own failure takes the box. `theme`, `locale` (default:
|
|
52
94
|
the browser's), `direction`, `tenantId`, `user` and `window` update the running chat;
|
|
53
95
|
`connection`, `chat`, `actions`, `events`, `instanceId`, `assetsUrl` and `timeoutMs` are read once
|
|
54
96
|
at mount. `window` (`docked`, `canDock`, `setDocked`, `close`) hands the chat's window to the app:
|
|
55
|
-
the chat shows a dock toggle and a close button
|
|
97
|
+
the chat shows a dock toggle and a close button; with `mode`, `modes` and `setMode` (read at
|
|
98
|
+
mount) it shows a button per other mode instead of the toggle. An app that already builds the contract's
|
|
56
99
|
`environment` or `window` passes it as is. `onReady` receives the handle (`ask`, `attach`), and
|
|
57
100
|
`null` once the chat unmounts; `loadedNexusChatVersion()` names the loaded build for error reports.
|
|
58
101
|
|
|
102
|
+
### Direct, in a window
|
|
103
|
+
|
|
104
|
+
```tsx
|
|
105
|
+
<NexusChat
|
|
106
|
+
connection={{
|
|
107
|
+
client: 'general',
|
|
108
|
+
copilotUrl: '/incorta/copilot',
|
|
109
|
+
credentials: 'include',
|
|
110
|
+
headers: incortaHeaders, // as above
|
|
111
|
+
refreshAuth: async () => ((session = undefined), true),
|
|
112
|
+
}}
|
|
113
|
+
theme="dark"
|
|
114
|
+
modes={['floating', 'docked', 'fullscreen']}
|
|
115
|
+
defaultMode="floating"
|
|
116
|
+
/>
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
`modes` or `defaultMode` instead of `window` gives the direct chat the embedded window: the
|
|
120
|
+
launcher, floating, docked (the page makes room) and full-screen windows, full screen below 768px.
|
|
121
|
+
The chat's own top bar switches windows and closes it to the launcher, where the conversation
|
|
122
|
+
stays. `modes` limits the windows (default all three, in that order); `defaultMode` opens one on
|
|
123
|
+
load, else only the launcher shows. The other props are the direct ones, and an app passing
|
|
124
|
+
`window` keeps drawing its own frame.
|
|
125
|
+
|
|
59
126
|
## Any framework
|
|
60
127
|
|
|
61
128
|
```ts
|
|
@@ -73,8 +140,10 @@ chat.unmount();
|
|
|
73
140
|
```
|
|
74
141
|
|
|
75
142
|
The options are the host contract: `connection` and `environment` are required; `chat`, `actions`,
|
|
76
|
-
`events` and `window` are optional, and the chat hides what a host leaves out.
|
|
77
|
-
|
|
143
|
+
`events` and `window` are optional, and the chat hides what a host leaves out. The chat fills
|
|
144
|
+
`element`. Until it is mounted the React component's skeleton holds the element (after 300 ms, in
|
|
145
|
+
the `environment`'s theme), and it leaves at once if the chat cannot load, so the app's own failure
|
|
146
|
+
takes its place. Mount in the browser only, never during SSR.
|
|
78
147
|
|
|
79
148
|
`chat={{ contexts: ['business-view', 'ai-container', 'file'] }}` lets users attach contexts, in
|
|
80
149
|
that menu order: `'file'` uploads, and each other built-in kind (`business-view`, `physical-table`,
|
|
@@ -84,7 +153,8 @@ that menu order: `'file'` uploads, and each other built-in kind (`business-view`
|
|
|
84
153
|
Stores. A `ContextSource` of a kind replaces the chat's loader for it.
|
|
85
154
|
|
|
86
155
|
`loadNexusChat(source)` only loads the chat and resolves to its `mount`. It loads once per source,
|
|
87
|
-
retries once, and gives up after `timeoutMs` (default 20 s
|
|
156
|
+
retries once, and gives up after `timeoutMs` (default 20 s, counted while the page is visible: a
|
|
157
|
+
hidden tab gets a fresh deadline once it shows); a failed load is forgotten so the next
|
|
88
158
|
call tries again. Call it when the page is idle to make the first open instant.
|
|
89
159
|
|
|
90
160
|
## Where the chat comes from
|
package/contract/index.d.ts
CHANGED
|
@@ -383,16 +383,27 @@ export interface NexusChatOptions {
|
|
|
383
383
|
* The host sizes the mount and owns its window (a page-covering overlay, a
|
|
384
384
|
* docked side panel) and draws its frame. Present: the chat hides its own
|
|
385
385
|
* fullscreen toggle and its edge border, and shows a dock toggle while
|
|
386
|
-
* `canDock
|
|
387
|
-
* and
|
|
386
|
+
* `canDock` (or, with `mode`, `modes` and `setMode`, one button per other
|
|
387
|
+
* mode), and a close button. Absent: the chat's own fullscreen toggle, and
|
|
388
|
+
* no close button.
|
|
388
389
|
*/
|
|
389
390
|
window?: NexusChatWindow;
|
|
390
391
|
}
|
|
392
|
+
/** The windows a host can show the chat in. */
|
|
393
|
+
export type NexusChatWindowMode = 'floating' | 'docked' | 'fullscreen';
|
|
391
394
|
export interface NexusChatWindowState {
|
|
392
395
|
/** The mount is the docked side panel (the toggle shows pressed). */
|
|
393
396
|
docked: boolean;
|
|
394
397
|
/** The host page allows docking now; `false` hides the dock toggle. */
|
|
395
398
|
canDock: boolean;
|
|
399
|
+
/**
|
|
400
|
+
* The window shown now. With `modes` and `setMode`, the header offers every
|
|
401
|
+
* mode in `modes` but this one (float, dock, full screen, in that order)
|
|
402
|
+
* instead of the dock toggle.
|
|
403
|
+
*/
|
|
404
|
+
mode?: NexusChatWindowMode;
|
|
405
|
+
/** The modes the host can switch to now, the current one included. */
|
|
406
|
+
modes?: readonly NexusChatWindowMode[];
|
|
396
407
|
}
|
|
397
408
|
export interface NexusChatWindow {
|
|
398
409
|
/** May return a fresh object on every call: the chat compares the fields. */
|
|
@@ -400,6 +411,8 @@ export interface NexusChatWindow {
|
|
|
400
411
|
/** Call `onChange` whenever `get` may return something different. */
|
|
401
412
|
subscribe(onChange: () => void): () => void;
|
|
402
413
|
setDocked(docked: boolean): void;
|
|
414
|
+
/** Shows the chat in another of `modes`; the conversation and draft stay. */
|
|
415
|
+
setMode?(mode: NexusChatWindowMode): void;
|
|
403
416
|
close(): void;
|
|
404
417
|
}
|
|
405
418
|
/** What `mount` returns: the host's handle on one running chat. */
|
package/index.d.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
HostTheme,
|
|
3
|
+
NexusChatHandle,
|
|
4
|
+
NexusChatMount,
|
|
5
|
+
NexusChatOptions,
|
|
6
|
+
} from './contract/index.js';
|
|
2
7
|
|
|
3
8
|
export type * from './contract/index.js';
|
|
4
9
|
|
|
@@ -35,3 +40,12 @@ export function mountNexusChat(
|
|
|
35
40
|
element: HTMLElement,
|
|
36
41
|
options: MountNexusChatOptions,
|
|
37
42
|
): Promise<NexusChatHandle>;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @internal The skeleton `mountNexusChat` and `<NexusChat>` draw over a box
|
|
46
|
+
* while the chat loads. Not part of the package's API.
|
|
47
|
+
*/
|
|
48
|
+
export function showNexusChatSkeleton(
|
|
49
|
+
box: HTMLElement,
|
|
50
|
+
theme?: HostTheme,
|
|
51
|
+
): { setTheme(theme?: HostTheme): void; done(): void; remove(): void };
|
package/index.js
CHANGED
|
@@ -126,6 +126,24 @@ export function loadedNexusChatVersion() {
|
|
|
126
126
|
return loadedVersion;
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
+
/**
|
|
130
|
+
* Rejects after `timeoutMs`, counted while the page can be seen: a hidden tab
|
|
131
|
+
* runs its timers and requests slowly (Chrome throttles background tabs), so
|
|
132
|
+
* a chat loading there gets a fresh deadline once the tab shows. Internal to
|
|
133
|
+
* this package.
|
|
134
|
+
*/
|
|
135
|
+
export function loadDeadline(timeoutMs) {
|
|
136
|
+
return new Promise((_, reject) => {
|
|
137
|
+
const expire = () => {
|
|
138
|
+
if (!document.hidden) return reject(new Error('Timed out loading NexusChat.'));
|
|
139
|
+
document.addEventListener('visibilitychange', () => setTimeout(expire, timeoutMs), {
|
|
140
|
+
once: true,
|
|
141
|
+
});
|
|
142
|
+
};
|
|
143
|
+
setTimeout(expire, timeoutMs);
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
129
147
|
/**
|
|
130
148
|
* Loads the chat once per source and resolves to its `mount` function. One
|
|
131
149
|
* retry, since a single failure (a 502 during a rolling restart) is not an
|
|
@@ -143,9 +161,7 @@ export function loadNexusChat(options) {
|
|
|
143
161
|
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));
|
|
144
162
|
return importMount(entry);
|
|
145
163
|
}),
|
|
146
|
-
|
|
147
|
-
setTimeout(() => reject(new Error('Timed out loading NexusChat.')), timeoutMs),
|
|
148
|
-
),
|
|
164
|
+
loadDeadline(timeoutMs),
|
|
149
165
|
]);
|
|
150
166
|
load.catch(() => loads.delete(entry));
|
|
151
167
|
loads.set(entry, load);
|
|
@@ -156,13 +172,133 @@ export function loadNexusChat(options) {
|
|
|
156
172
|
/**
|
|
157
173
|
* Loads the chat (from `assetsUrl`, else `connection.copilotUrl`) and mounts
|
|
158
174
|
* it into `element` with the host contract options. The chat fills the
|
|
159
|
-
* element, so the element's box decides the presentation.
|
|
175
|
+
* element, so the element's box decides the presentation. Until the chat is
|
|
176
|
+
* up the skeleton holds the element; it leaves at once if the chat cannot load.
|
|
160
177
|
*/
|
|
161
178
|
export async function mountNexusChat(element, { assetsUrl, timeoutMs, ...options }) {
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
179
|
+
const skeleton = showNexusChatSkeleton(element, options.environment?.get().theme);
|
|
180
|
+
let handle;
|
|
181
|
+
try {
|
|
182
|
+
const mount = await loadNexusChat({
|
|
183
|
+
copilotUrl: options.connection?.copilotUrl,
|
|
184
|
+
assetsUrl,
|
|
185
|
+
timeoutMs,
|
|
186
|
+
});
|
|
187
|
+
handle = mount(element, { version: CONTRACT_VERSION, ...options });
|
|
188
|
+
} catch (error) {
|
|
189
|
+
skeleton.remove();
|
|
190
|
+
throw error;
|
|
191
|
+
}
|
|
192
|
+
skeleton.done();
|
|
193
|
+
return {
|
|
194
|
+
...handle,
|
|
195
|
+
unmount() {
|
|
196
|
+
skeleton.remove();
|
|
197
|
+
handle.unmount();
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ---- the loading face every app shows until the chat is up (<NexusChat> draws it too)
|
|
203
|
+
|
|
204
|
+
/** Fast loads show no skeleton; one that shows stays long enough to read, then cross-fades. */
|
|
205
|
+
const SKELETON_DELAY_MS = 300;
|
|
206
|
+
const SKELETON_MIN_MS = 400;
|
|
207
|
+
const SKELETON_FADE_MS = 150;
|
|
208
|
+
|
|
209
|
+
/** The chat's own ground (so the swap does not flash), its line fill, its window edge. */
|
|
210
|
+
export const PALETTE = {
|
|
211
|
+
light: { ground: '#fcfdfe', fill: '#efeff2', line: '#e4e4e7' },
|
|
212
|
+
dark: { ground: '#151515', fill: '#242426', line: '#2c2c2e' },
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
export const reducedMotion = () => window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
|
|
216
|
+
|
|
217
|
+
/** The Incorta Intelligence mark. */
|
|
218
|
+
export const ICON = `data:image/svg+xml,${encodeURIComponent(
|
|
219
|
+
"<svg width='107' height='120' viewBox='0 0 107 120' fill='none' xmlns='http://www.w3.org/2000/svg'><g clip-path='url(#clip0_108_873)'><path d='M84 35.4C89.8 37.6 95.2 41.2 99.8 45.9C102.4 48.6 104.6 51.5 106.5 54.6L106.7 35.4C106.7 32 104.9 28.8 102 27L63.1 3.9C66.2 5.8 69.1 8.1 71.7 10.9C78.5 17.8 82.6 26.4 84 35.4Z' fill='url(#paint0_linear_108_873)'/><path d='M22.9 84.8C17.1 82.5 11.7 79 7.1 74.3C4.4 71.5 2.1 68.4 0.2 65.2L0 84.6C0 88.1 1.7 91.2 4.7 93L43.9 116.3C40.8 114.4 37.9 112.1 35.2 109.3C28.4 102.4 24.3 93.8 22.9 84.8Z' fill='url(#paint1_linear_108_873)'/><path d='M106.1 85.8L106.5 54.6C104.6 51.5 102.4 48.6 99.8 45.9C95.2 41.2 89.8 37.6 84 35.4C86.3 49.4 81.9 64.3 71 75C57.9 87.9 38.9 91.1 22.9 84.8C24.3 93.8 28.4 102.4 35.2 109.3C37.9 112.1 40.8 114.4 43.9 116.3L45.2 117.1L47.9 118.7C48.9 119.3 49.9 119.7 51 119.9C53.2 120.3 55.5 119.9 57.5 118.8L101.3 94.1C104.2 92.4 106.1 89.2 106.1 85.8V85.8Z' fill='url(#paint2_linear_108_873)'/><path d='M35.9 45.2C49 32.3 68 29.1 84 35.4C82.6 26.4 78.5 17.8 71.7 10.8C69.1 8.1 66.2 5.8 63.1 3.9L61.7 3.1L58.8 1.3C57.7 0.7 56.6 0.3 55.4 0.1C53.3 -0.2 51.1 0.2 49.2 1.2L5.4 26C2.4 27.6 0.6 30.8 0.6 34.2L0.2 65.2C2.1 68.4 4.4 71.5 7.1 74.3C11.7 79 17.1 82.5 22.9 84.8C20.7 70.7 25 55.9 35.9 45.2V45.2Z' fill='#00A098'/><path d='M22.9 84.8C23 85.3 23.1 85.8 23.2 86.3C23.2 86.5 23.2 86.7 23.3 86.8C23.3 87.2 23.4 87.5 23.5 87.9C23.5 88 23.6 88.2 23.6 88.4C23.7 88.8 23.8 89.1 23.9 89.4C23.9 89.6 24 89.8 24 90C24.1 90.4 24.2 90.8 24.4 91.1C24.4 91.4 24.5 91.6 24.6 91.9C24.7 92.2 24.8 92.5 24.9 92.8C25 93.1 25.1 93.3 25.2 93.6C25.3 93.8 25.4 94 25.5 94.2C25.6 94.5 25.7 94.8 25.8 95C25.9 95.2 25.9 95.4 26 95.6C26.2 95.9 26.3 96.2 26.4 96.5C26.5 96.6 26.5 96.7 26.5 96.7C26.9 97.4 27.2 98.1 27.6 98.8C27.6 98.9 27.6 98.9 27.7 99C27.9 99.3 28 99.7 28.2 100C28.3 100.1 28.4 100.2 28.4 100.3C28.6 100.7 28.8 101 29 101.3C29 101.4 29.1 101.5 29.1 101.6C29.8 102.7 30.6 103.8 31.4 104.9C31.8 104.9 92.7 102.9 96.2 42.5C96.6 36.7 96.4 30.2 95.5 23.2L63.1 3.9C66.2 5.8 69.1 8.1 71.7 10.9C78.5 17.8 82.6 26.4 84 35.4C86.3 49.4 81.9 64.3 71 75C57.9 87.9 38.9 91.1 22.9 84.8V84.8Z' fill='url(#paint3_linear_108_873)'/><path d='M84 35.4C84 34.9 83.9 34.4 83.8 33.8C83.7 33.7 83.7 33.5 83.7 33.4C83.6 33 83.5 32.7 83.4 32.3C83.4 32.1 83.3 31.9 83.3 31.7C83.2 31.4 83.1 31.1 83.1 30.8C83 30.6 83 30.4 82.9 30.2C82.8 29.8 82.7 29.4 82.6 29C82.5 28.8 82.4 28.5 82.3 28.3C82.2 28 82.1 27.7 82 27.4C81.9 27.1 81.8 26.9 81.7 26.6C81.6 26.4 81.5 26.2 81.5 25.9C81.4 25.7 81.2 25.4 81.1 25.1C81.1 24.9 81 24.7 80.9 24.6C80.8 24.3 80.6 24 80.5 23.7C80.5 23.6 80.4 23.5 80.4 23.4C80.1 22.7 79.7 22.1 79.4 21.4C79.3 21.3 79.3 21.2 79.2 21.2C79.1 20.8 78.9 20.5 78.7 20.2C78.6 20.1 78.6 20 78.5 19.9C78.3 19.5 78.1 19.2 77.9 18.9C77.9 18.8 77.8 18.7 77.8 18.6C77.1 17.5 76.3 16.4 75.5 15.3C75.1 15.3 14.2 17.3 10.7 77.6C10.4 83.5 10.6 90 11.4 97L43.8 116.2C40.7 114.3 37.8 112 35.2 109.3C28.4 102.4 24.3 93.8 22.9 84.8C20.7 70.8 25 55.9 35.9 45.2C49 32.3 68 29.1 84 35.4V35.4Z' fill='url(#paint4_linear_108_873)'/></g><defs><linearGradient id='paint0_linear_108_873' x1='104.6' y1='41.1' x2='52.2' y2='1.6' gradientUnits='userSpaceOnUse'><stop stop-color='#4854FE'/><stop offset='1' stop-color='#241C55'/></linearGradient><linearGradient id='paint1_linear_108_873' x1='0' y1='90.8' x2='43.9' y2='90.8' gradientUnits='userSpaceOnUse'><stop stop-color='#4854FE'/><stop offset='1' stop-color='#241C55'/></linearGradient><linearGradient id='paint2_linear_108_873' x1='31.5' y1='40.6' x2='92.7' y2='102.9' gradientUnits='userSpaceOnUse'><stop stop-color='white'/><stop offset='1' stop-color='#4854FE'/></linearGradient><linearGradient id='paint3_linear_108_873' x1='86.3' y1='19.1' x2='41.6' y2='98.7' gradientUnits='userSpaceOnUse'><stop stop-color='#4854FE' stop-opacity='0.93'/><stop offset='1' stop-color='#006EF0' stop-opacity='0'/></linearGradient><linearGradient id='paint4_linear_108_873' x1='19.9' y1='103.3' x2='61' y2='27.5' gradientUnits='userSpaceOnUse'><stop stop-color='#006EF0'/><stop offset='1' stop-color='#006EF0' stop-opacity='0'/></linearGradient><clipPath id='clip0_108_873'><rect width='106.7' height='120' fill='white'/></clipPath></defs></svg>",
|
|
220
|
+
)}`;
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Draws the skeleton over `box`: the mark and two lines centred in it,
|
|
224
|
+
* pulsing together, revealed only once loading has taken SKELETON_DELAY_MS.
|
|
225
|
+
* It stays on top of whatever renders into `box` (the chat's React empties its
|
|
226
|
+
* element when it first paints) until `done` fades it out (after showing for
|
|
227
|
+
* SKELETON_MIN_MS) or `remove` drops it at once. Internal to this package.
|
|
228
|
+
*/
|
|
229
|
+
export function showNexusChatSkeleton(box, theme) {
|
|
230
|
+
const root = document.createElement('div');
|
|
231
|
+
root.setAttribute('role', 'status');
|
|
232
|
+
root.setAttribute('aria-label', 'Loading Incorta Intelligence');
|
|
233
|
+
root.style.cssText =
|
|
234
|
+
'position:absolute;inset:0;box-sizing:border-box;padding:24px;display:flex;align-items:center;justify-content:center;pointer-events:none';
|
|
235
|
+
const line = (width, height) =>
|
|
236
|
+
`<span style="display:block;width:${width};height:${height}px;border-radius:${height / 2}px"></span>`;
|
|
237
|
+
root.innerHTML = `<div style="width:100%;max-width:280px"><div style="display:flex;flex-direction:column;align-items:center;gap:20px"><img alt="" width="32" height="34"><div style="display:flex;flex-direction:column;align-items:center;gap:10px;width:100%">${line('100%', 14)}${line('64%', 10)}</div></div></div>`;
|
|
238
|
+
root.querySelector('img').src = ICON;
|
|
239
|
+
const setTheme = (next) => {
|
|
240
|
+
const palette = PALETTE[next === 'dark' ? 'dark' : 'light'];
|
|
241
|
+
root.style.background = palette.ground;
|
|
242
|
+
for (const span of root.querySelectorAll('span')) span.style.background = palette.fill;
|
|
243
|
+
};
|
|
244
|
+
setTheme(theme);
|
|
245
|
+
|
|
246
|
+
// An unpositioned box becomes the skeleton's containing block while it shows.
|
|
247
|
+
const position = box.style.position;
|
|
248
|
+
const unpositioned = getComputedStyle(box).position === 'static';
|
|
249
|
+
if (unpositioned) box.style.position = 'relative';
|
|
250
|
+
box.append(root);
|
|
251
|
+
const stay = new MutationObserver(() => root.parentNode === box || box.append(root));
|
|
252
|
+
stay.observe(box, { childList: true });
|
|
253
|
+
|
|
254
|
+
const shownAt = Date.now() + SKELETON_DELAY_MS;
|
|
255
|
+
const stack = root.firstChild;
|
|
256
|
+
const animations = [
|
|
257
|
+
stack.animate?.([{ opacity: 0 }, { opacity: 1 }], {
|
|
258
|
+
duration: SKELETON_FADE_MS,
|
|
259
|
+
delay: SKELETON_DELAY_MS,
|
|
260
|
+
fill: 'backwards',
|
|
261
|
+
}),
|
|
262
|
+
reducedMotion()
|
|
263
|
+
? undefined
|
|
264
|
+
: stack.firstChild.animate?.([{ opacity: 1 }, { opacity: 0.45 }, { opacity: 1 }], {
|
|
265
|
+
duration: 1600,
|
|
266
|
+
easing: 'ease-in-out',
|
|
267
|
+
iterations: Infinity,
|
|
268
|
+
}),
|
|
269
|
+
];
|
|
270
|
+
let timer;
|
|
271
|
+
let gone = false;
|
|
272
|
+
const remove = () => {
|
|
273
|
+
if (gone) return;
|
|
274
|
+
gone = true;
|
|
275
|
+
clearTimeout(timer);
|
|
276
|
+
stay.disconnect();
|
|
277
|
+
animations.forEach((animation) => animation?.cancel());
|
|
278
|
+
root.remove();
|
|
279
|
+
if (unpositioned) box.style.position = position;
|
|
280
|
+
};
|
|
281
|
+
return {
|
|
282
|
+
setTheme,
|
|
283
|
+
remove,
|
|
284
|
+
done() {
|
|
285
|
+
if (gone || timer !== undefined) return;
|
|
286
|
+
const sinceShown = Date.now() - shownAt;
|
|
287
|
+
// Never shown (a fast load): gone at once, before its reveal can start.
|
|
288
|
+
if (sinceShown < 0) return remove();
|
|
289
|
+
// Shown: for at least SKELETON_MIN_MS, then it fades.
|
|
290
|
+
timer = setTimeout(
|
|
291
|
+
() => {
|
|
292
|
+
const fade = root.animate?.([{ opacity: 1 }, { opacity: 0 }], {
|
|
293
|
+
duration: SKELETON_FADE_MS,
|
|
294
|
+
fill: 'forwards',
|
|
295
|
+
});
|
|
296
|
+
if (!fade) return remove();
|
|
297
|
+
animations.push(fade);
|
|
298
|
+
fade.onfinish = remove;
|
|
299
|
+
},
|
|
300
|
+
Math.max(0, SKELETON_MIN_MS - sinceShown),
|
|
301
|
+
);
|
|
302
|
+
},
|
|
303
|
+
};
|
|
168
304
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ahmedramez-org/nexus-chat",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.7",
|
|
4
4
|
"description": "NexusChat for any app: a connector and React component that load the prebuilt chat from IncortaCopilot (or a pinned copy), and the host contract types and checks.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"repository": {
|
|
@@ -53,6 +53,7 @@
|
|
|
53
53
|
"index.js",
|
|
54
54
|
"index.d.ts",
|
|
55
55
|
"react.js",
|
|
56
|
+
"window.js",
|
|
56
57
|
"react.d.ts",
|
|
57
58
|
"contract",
|
|
58
59
|
"README.md"
|
package/react.d.ts
CHANGED
|
@@ -8,13 +8,10 @@ import type {
|
|
|
8
8
|
NexusChatEnvironment,
|
|
9
9
|
NexusChatHandle,
|
|
10
10
|
NexusChatWindow,
|
|
11
|
+
NexusChatWindowMode,
|
|
11
12
|
} from './index.js';
|
|
12
13
|
|
|
13
|
-
|
|
14
|
-
export interface DirectNexusChatProps extends Omit<
|
|
15
|
-
MountNexusChatOptions,
|
|
16
|
-
'environment' | 'window'
|
|
17
|
-
> {
|
|
14
|
+
interface DirectChatProps extends Omit<MountNexusChatOptions, 'environment' | 'window'> {
|
|
18
15
|
embedUrl?: undefined;
|
|
19
16
|
/** Required unless `environment` is given. */
|
|
20
17
|
theme?: HostTheme;
|
|
@@ -26,28 +23,52 @@ export interface DirectNexusChatProps extends Omit<
|
|
|
26
23
|
user?: HostUser;
|
|
27
24
|
/** The app's own contract environment, used as is instead of the props above. */
|
|
28
25
|
environment?: NexusChatEnvironment;
|
|
26
|
+
/** The chat is mounted (`ask` and `attach` are available); `null` once it unmounts. */
|
|
27
|
+
onReady?(chat: NexusChatHandle | null): void;
|
|
28
|
+
/** The chat could not load (after its retry and deadline). */
|
|
29
|
+
onLoadError?(error: Error): void;
|
|
30
|
+
className?: string;
|
|
31
|
+
style?: CSSProperties;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The chat mounted into this page, sharing its session. It fills its box. */
|
|
35
|
+
export interface DirectNexusChatProps extends DirectChatProps {
|
|
29
36
|
/**
|
|
30
37
|
* The app owns the chat's window (a docked panel, an overlay): the chat
|
|
31
|
-
* shows a dock toggle while `canDock
|
|
32
|
-
* `
|
|
38
|
+
* shows a dock toggle while `canDock` (or, with `mode`, `modes` and
|
|
39
|
+
* `setMode`, a button per other mode), and a close button. `setMode` is
|
|
40
|
+
* read at mount. A contract `NexusChatWindow` is used as is.
|
|
33
41
|
*/
|
|
34
42
|
window?:
|
|
35
43
|
| {
|
|
36
44
|
docked: boolean;
|
|
37
45
|
canDock: boolean;
|
|
46
|
+
mode?: NexusChatMode;
|
|
47
|
+
modes?: readonly NexusChatMode[];
|
|
38
48
|
setDocked(docked: boolean): void;
|
|
49
|
+
setMode?(mode: NexusChatMode): void;
|
|
39
50
|
close(): void;
|
|
40
51
|
}
|
|
41
52
|
| NexusChatWindow;
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
53
|
+
modes?: undefined;
|
|
54
|
+
defaultMode?: undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The chat mounted into this page, sharing its session, in a window drawn
|
|
59
|
+
* here as for `embedUrl`: a launcher icon that opens it floating, docked on
|
|
60
|
+
* the right, or fullscreen. Giving `modes` or `defaultMode` instead of a
|
|
61
|
+
* `window` opts in.
|
|
62
|
+
*/
|
|
63
|
+
export interface WindowedNexusChatProps extends DirectChatProps {
|
|
64
|
+
/** Where the chat may open, in launcher order. Default: all three. */
|
|
65
|
+
modes?: NexusChatMode[];
|
|
66
|
+
/** Open on load in this mode. Default: the launcher icon only. */
|
|
67
|
+
defaultMode?: NexusChatMode;
|
|
68
|
+
window?: undefined;
|
|
48
69
|
}
|
|
49
70
|
|
|
50
|
-
export type NexusChatMode =
|
|
71
|
+
export type NexusChatMode = NexusChatWindowMode;
|
|
51
72
|
|
|
52
73
|
/**
|
|
53
74
|
* The chat in an <iframe> served by Incorta, on the user's own Incorta
|
|
@@ -67,12 +88,13 @@ export interface EmbeddedNexusChatProps {
|
|
|
67
88
|
locale?: string;
|
|
68
89
|
}
|
|
69
90
|
|
|
70
|
-
export type NexusChatProps = DirectNexusChatProps | EmbeddedNexusChatProps;
|
|
91
|
+
export type NexusChatProps = DirectNexusChatProps | WindowedNexusChatProps | EmbeddedNexusChatProps;
|
|
71
92
|
|
|
72
93
|
/**
|
|
73
94
|
* The chat as a React component. Direct: it fills its box; `connection`,
|
|
74
95
|
* `chat`, `actions`, `events`, `instanceId`, `assetsUrl` and `timeoutMs` are
|
|
75
|
-
* read once at mount, and the other props update the running chat.
|
|
76
|
-
*
|
|
96
|
+
* read once at mount, and the other props update the running chat. With
|
|
97
|
+
* `modes` or `defaultMode` (and no `window`), or embedded (`embedUrl`): it
|
|
98
|
+
* draws its own window.
|
|
77
99
|
*/
|
|
78
100
|
export function NexusChat(props: NexusChatProps): ReactElement;
|