@ourlu/assistant-sdk 0.2.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/README.md ADDED
@@ -0,0 +1,109 @@
1
+ # Assistant SDK JS
2
+
3
+ SDK JavaScript du widget assistant, maintenu dans `assistant-sdk-js`.
4
+
5
+ ## Mise à jour mission (2026-05-17)
6
+
7
+ - Aucun changement de périmètre fonctionnel SDK lié à la rationalisation microservices backend.
8
+ - Le SDK reste découplé de Temporal et des choix d'orchestration backend.
9
+ - Priorité conservée: stabilité contractuelle (`SDK_CONTRACT_V1`) et diffusion versionnée.
10
+
11
+ ## Périmètre
12
+
13
+ Ce package fournit:
14
+
15
+ - le loader IIFE (`loader.v1.js`) pour intégration `<script ...>`;
16
+ - les runtimes UI/engine (`ui.v1.js`, `engine.v1.js`);
17
+ - une API ESM (`dist/esm/index.js`) pour bootstrap programmatique.
18
+
19
+ Le package n'écrit jamais dans `ourlu` directement. La copie vers le backend est pilotée côté consommateur via script dédié.
20
+
21
+ ## Arborescence réelle
22
+
23
+ - `src/loader/loader.v1.js`: chargeur principal exécuté côté navigateur.
24
+ - `src/runtime/loader.runtime.ui.v1.js`: UI widget, rendu, interactions.
25
+ - `src/runtime/loader.runtime.engine.v1.js`: appels API chat/audio + orchestration runtime.
26
+ - `src/esm/index.js`: helpers publics (`bootstrapWidgetRuntime`, `mountWidgetFromScript`) et managers ESM.
27
+ - `scripts/build.mjs`: build par copie contrôlée `src -> dist`.
28
+ - `tests/runtime-url-resolver.test.mjs`: tests Node du resolver d'URLs runtime.
29
+ - `.github/workflows/sdk-ci-release.yml`: pipeline CI + publication sur tags `v*`.
30
+
31
+ ## API publique (ESM)
32
+
33
+ Exports (`src/esm/index.js`):
34
+
35
+ - `bootstrapWidgetRuntime(options)`: charge `ui.v1.js` + `engine.v1.js`.
36
+ - `mountWidgetFromScript(scriptTag)`: délègue au runtime global `window.__CompanionWidgetRuntimeV1`.
37
+ - `RuntimeUrlResolverManager`
38
+ - `BrowserScriptLoaderManager`
39
+ - `RuntimeBootstrapManager`
40
+ - `sdkVersion` (`"v1"`).
41
+
42
+ Options principales `bootstrapWidgetRuntime`:
43
+
44
+ - `loaderUrl` (recommandé) pour résoudre automatiquement `ui.v1.js` et `engine.v1.js`;
45
+ - `uiUrl` / `engineUrl` (optionnels) pour surcharger explicitement;
46
+ - `timeoutMs` (optionnel, défaut interne `8000`).
47
+
48
+ ## Build, test, packaging
49
+
50
+ ```powershell
51
+ npm ci
52
+ npm run build
53
+ npm run test
54
+ npm run pack:check
55
+ ```
56
+
57
+ Scripts disponibles (`package.json`):
58
+
59
+ - `build`: `node ./scripts/build.mjs`
60
+ - `test`: `node --test ./tests/*.test.mjs`
61
+ - `prepack`: build + test
62
+ - `pack:check`: `npm pack --dry-run`
63
+ - `publish:dry-run`: `npm publish --dry-run`
64
+
65
+ ## Intégration avec `ourlu`
66
+
67
+ Le backend `ourlu` sert les assets runtime via:
68
+
69
+ - `GET /v1/widget/runtime/loader.v1.js`
70
+ - `GET /v1/widget/runtime/ui.v1.js`
71
+ - `GET /v1/widget/runtime/engine.v1.js`
72
+
73
+ Ces assets sont embarqués dans le binaire backend (`go:embed`) après synchronisation.
74
+
75
+ ### Mode workspace (développement local)
76
+
77
+ 1. builder le SDK dans ce dossier (`npm run build`);
78
+ 2. exécuter la synchronisation côté `ourlu`.
79
+
80
+ Depuis la racine du workspace:
81
+
82
+ ```powershell
83
+ .\ourlu\backend\scripts\sync-assistant-sdk-assets.ps1
84
+ ```
85
+
86
+ Lock utilisé: `ourlu/backend/scripts/assistant-sdk.lock.json`
87
+ (`workspaceDistDirectory=..\..\sdks\js\assistant-sdk-js\dist\iife`).
88
+
89
+ ### Mode npm (release versionnée)
90
+
91
+ Le script de sync backend supporte `sourceMode=npm` via:
92
+
93
+ - `ourlu/backend/scripts/assistant-sdk.lock.npm.example.json`
94
+ - champs `registryUrl` et `npmAuthTokenEnvVar`
95
+
96
+ ## Contrat et compatibilité
97
+
98
+ - contrat public SDK: `../../../docs/SDK_CONTRACT_V1.md`
99
+ - changelog: `CHANGELOG.md`
100
+ - stratégie de release: `docs/RELEASE_STRATEGY.md`
101
+
102
+ Règle: toute rupture contractuelle sur les `data-*`, méthodes ou événements publics implique un changement majeur (`v2`).
103
+
104
+ ## CI/CD
105
+
106
+ Workflow: `.github/workflows/sdk-ci-release.yml`
107
+
108
+ - job `validate` sur branches/PR: `npm ci`, `npm run test`, `npm run build`, `npm run pack:check`;
109
+ - job `publish` sur tag `v*`: `npm publish --access restricted` avec `NPM_TOKEN`.
@@ -0,0 +1,436 @@
1
+ const DEFAULT_TIMEOUT_MS = 8000;
2
+ const DEFAULT_CHAT_TIMEOUT_MS = 45000;
3
+
4
+ class RuntimeUrlResolverManager {
5
+ resolveFromLoaderUrl(loaderUrl, fileName) {
6
+ if (!loaderUrl || typeof loaderUrl !== "string") return "";
7
+ try {
8
+ const fallbackBaseUrl =
9
+ typeof window !== "undefined" && window.location && window.location.href
10
+ ? window.location.href
11
+ : "http://localhost/";
12
+ const normalizedLoaderUrl = new URL(loaderUrl, fallbackBaseUrl);
13
+ const normalizedPath = normalizedLoaderUrl.pathname.replace(/\/[^/]*$/, "/");
14
+ return `${normalizedLoaderUrl.origin}${normalizedPath}${fileName}`;
15
+ } catch (_error) {
16
+ return "";
17
+ }
18
+ }
19
+
20
+ resolve(options) {
21
+ const loaderUrl = String(options?.loaderUrl || "").trim();
22
+ const uiUrl = String(options?.uiUrl || "").trim();
23
+ const engineUrl = String(options?.engineUrl || "").trim();
24
+ return {
25
+ loaderUrl,
26
+ uiUrl: uiUrl || this.resolveFromLoaderUrl(loaderUrl, "ui.v1.js"),
27
+ engineUrl: engineUrl || this.resolveFromLoaderUrl(loaderUrl, "engine.v1.js"),
28
+ };
29
+ }
30
+ }
31
+
32
+ class BrowserScriptLoaderManager {
33
+ loadScript(url, timeoutMs = DEFAULT_TIMEOUT_MS) {
34
+ return new Promise((resolve, reject) => {
35
+ const node = document.createElement("script");
36
+ let timeoutId = null;
37
+ node.src = url;
38
+ node.async = true;
39
+ node.onload = () => {
40
+ if (timeoutId) clearTimeout(timeoutId);
41
+ resolve();
42
+ };
43
+ node.onerror = () => {
44
+ if (timeoutId) clearTimeout(timeoutId);
45
+ reject(new Error(`Impossible de charger le script: ${url}`));
46
+ };
47
+ timeoutId = setTimeout(() => {
48
+ reject(new Error(`Timeout de chargement script: ${url}`));
49
+ }, timeoutMs);
50
+ document.head.appendChild(node);
51
+ });
52
+ }
53
+ }
54
+
55
+ class RuntimeBootstrapManager {
56
+ constructor(urlResolverManager, scriptLoaderManager) {
57
+ this.urlResolverManager = urlResolverManager;
58
+ this.scriptLoaderManager = scriptLoaderManager;
59
+ }
60
+
61
+ async loadRuntimeAssets(options) {
62
+ const resolvedUrls = this.urlResolverManager.resolve(options);
63
+ if (!resolvedUrls.uiUrl || !resolvedUrls.engineUrl) {
64
+ throw new Error("Urls runtime incomplètes (ui/engine).");
65
+ }
66
+ await this.scriptLoaderManager.loadScript(resolvedUrls.uiUrl, options?.timeoutMs);
67
+ await this.scriptLoaderManager.loadScript(resolvedUrls.engineUrl, options?.timeoutMs);
68
+ return resolvedUrls;
69
+ }
70
+
71
+ mountFromScript(scriptTag) {
72
+ const runtime = window.__CompanionWidgetRuntimeV1;
73
+ if (!runtime || typeof runtime.mountFromScript !== "function") {
74
+ throw new Error("Entrée runtime SDK absente.");
75
+ }
76
+ runtime.mountFromScript(scriptTag);
77
+ }
78
+ }
79
+
80
+ class SdkHttpTransportManager {
81
+ constructor({ fetchImpl = globalThis.fetch, headersProvider = null } = {}) {
82
+ this.fetchImpl = fetchImpl;
83
+ this.headersProvider = headersProvider;
84
+ }
85
+
86
+ buildHeaders(extraHeaders = {}) {
87
+ const resolvedHeaders = this.headersProvider ? this.headersProvider() : {};
88
+ return {
89
+ ...(resolvedHeaders && typeof resolvedHeaders === "object" ? resolvedHeaders : {}),
90
+ ...extraHeaders,
91
+ };
92
+ }
93
+
94
+ async fetchJson({ url, method = "GET", headers = {}, body = null, timeoutMs = DEFAULT_CHAT_TIMEOUT_MS }) {
95
+ const response = await this.fetchRaw({ url, method, headers, body, timeoutMs });
96
+ const contentType = String(response.headers.get("content-type") || "");
97
+ if (!contentType.includes("application/json")) {
98
+ throw new Error(`Réponse JSON attendue: ${url}`);
99
+ }
100
+ return response.json();
101
+ }
102
+
103
+ async fetchRaw({ url, method = "GET", headers = {}, body = null, timeoutMs = DEFAULT_CHAT_TIMEOUT_MS }) {
104
+ if (typeof this.fetchImpl !== "function") {
105
+ throw new Error("API fetch indisponible pour le SDK.");
106
+ }
107
+ const abortController = new AbortController();
108
+ const timeoutId = setTimeout(() => abortController.abort(), timeoutMs);
109
+ try {
110
+ const response = await this.fetchImpl(url, {
111
+ method,
112
+ headers: this.buildHeaders(headers),
113
+ body,
114
+ signal: abortController.signal,
115
+ });
116
+ if (!response.ok) {
117
+ throw new Error(await this.resolveHttpError({ response, url, method }));
118
+ }
119
+ return response;
120
+ } catch (error) {
121
+ if (error instanceof Error && error.name === "AbortError") {
122
+ throw new Error(`Timeout SDK après ${timeoutMs}ms`);
123
+ }
124
+ throw error;
125
+ } finally {
126
+ clearTimeout(timeoutId);
127
+ }
128
+ }
129
+
130
+ async resolveHttpError({ response, url, method }) {
131
+ const contentType = String(response.headers.get("content-type") || "");
132
+ if (contentType.includes("application/json")) {
133
+ const payload = await response.json().catch(() => null);
134
+ const message = String(payload?.error || payload?.message || "").trim();
135
+ if (message) {
136
+ return `${message} [${method} ${url}]`;
137
+ }
138
+ }
139
+ return `Erreur HTTP SDK (${response.status}) [${method} ${url}]`;
140
+ }
141
+ }
142
+
143
+ class SseEventDecoderManager {
144
+ decodeChunk(buffer, incomingChunk) {
145
+ const joinedBuffer = `${buffer}${incomingChunk}`;
146
+ const eventBlocks = joinedBuffer.split("\n\n");
147
+ const remainingBuffer = eventBlocks.pop() || "";
148
+ const decodedEvents = eventBlocks.map((eventBlock) => this.decodeBlock(eventBlock)).filter(Boolean);
149
+ return {
150
+ remainingBuffer,
151
+ decodedEvents,
152
+ };
153
+ }
154
+
155
+ decodeLastBuffer(buffer) {
156
+ if (!String(buffer || "").trim()) {
157
+ return [];
158
+ }
159
+ const decodedEvent = this.decodeBlock(buffer);
160
+ return decodedEvent ? [decodedEvent] : [];
161
+ }
162
+
163
+ decodeBlock(eventBlock) {
164
+ const lines = String(eventBlock || "").split("\n");
165
+ let eventName = "message";
166
+ const dataLines = [];
167
+ lines.forEach((line) => {
168
+ if (line.startsWith("event:")) {
169
+ eventName = line.slice("event:".length).trim() || "message";
170
+ } else if (line.startsWith("data:")) {
171
+ dataLines.push(line.slice("data:".length).trim());
172
+ }
173
+ });
174
+ if (dataLines.length === 0) {
175
+ return null;
176
+ }
177
+ const rawData = dataLines.join("\n");
178
+ if (rawData === "[DONE]") {
179
+ return { eventName: "done", data: { raw: "[DONE]" } };
180
+ }
181
+ try {
182
+ return { eventName, data: JSON.parse(rawData) };
183
+ } catch (_error) {
184
+ return { eventName, data: { raw: rawData } };
185
+ }
186
+ }
187
+ }
188
+
189
+ class SdkEndpointPathResolverManager {
190
+ resolve(options = {}) {
191
+ const apiBaseUrl = this.resolveApiBaseUrl(options.apiBaseUrl);
192
+ const bootstrapEndpoint = this.resolveAbsoluteUrl({
193
+ baseUrl: apiBaseUrl,
194
+ endpoint: options.bootstrapEndpoint,
195
+ defaultPath: "/v1/occe/chat/bootstrap",
196
+ });
197
+ const sessionsEndpoint = this.resolveAbsoluteUrl({
198
+ baseUrl: apiBaseUrl,
199
+ endpoint: options.sessionsEndpoint,
200
+ defaultPath: "/v1/occe/chat/sessions",
201
+ });
202
+ return {
203
+ apiBaseUrl,
204
+ bootstrapEndpoint,
205
+ sessionsEndpoint,
206
+ };
207
+ }
208
+
209
+ resolveApiBaseUrl(rawBaseUrl) {
210
+ const trimmedBaseUrl = String(rawBaseUrl || "").trim();
211
+ const fallbackBaseUrl = this.resolveBrowserOrigin();
212
+ return (trimmedBaseUrl || fallbackBaseUrl).replace(/\/+$/, "");
213
+ }
214
+
215
+ resolveBrowserOrigin() {
216
+ if (typeof window !== "undefined" && window.location && window.location.origin) {
217
+ return window.location.origin;
218
+ }
219
+ return "http://localhost";
220
+ }
221
+
222
+ resolveAbsoluteUrl({ baseUrl, endpoint, defaultPath }) {
223
+ const explicitEndpoint = String(endpoint || "").trim();
224
+ if (!explicitEndpoint) {
225
+ return `${baseUrl}${defaultPath}`;
226
+ }
227
+ if (/^https?:\/\//i.test(explicitEndpoint)) {
228
+ return explicitEndpoint;
229
+ }
230
+ const normalizedPath = explicitEndpoint.startsWith("/") ? explicitEndpoint : `/${explicitEndpoint}`;
231
+ return `${baseUrl}${normalizedPath}`;
232
+ }
233
+ }
234
+
235
+ class SdkHeadlessChatClientManager {
236
+ constructor(options = {}) {
237
+ this.endpointResolverManager = new SdkEndpointPathResolverManager();
238
+ this.sseDecoderManager = new SseEventDecoderManager();
239
+ this.transportManager = new SdkHttpTransportManager({
240
+ fetchImpl: options.fetchImpl,
241
+ headersProvider: options.headersProvider,
242
+ });
243
+ this.timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : DEFAULT_CHAT_TIMEOUT_MS;
244
+ this.tenantId = String(options.tenantId || "").trim();
245
+ this.widgetKey = String(options.widgetKey || "").trim();
246
+ this.targetLanguage = String(options.targetLanguage || "").trim().toLowerCase();
247
+ if (!this.targetLanguage) {
248
+ throw new Error("targetLanguage requis en mode temporal-only.");
249
+ }
250
+ const resolvedEndpoints = this.endpointResolverManager.resolve(options);
251
+ this.bootstrapEndpoint = resolvedEndpoints.bootstrapEndpoint;
252
+ this.sessionsEndpoint = resolvedEndpoints.sessionsEndpoint;
253
+ this.sessionBootstrapToken = String(options.sessionBootstrapToken || "").trim();
254
+ this.useBootstrapAuthorization = options.useBootstrapAuthorization !== false;
255
+ this.sessionId = "";
256
+ }
257
+
258
+ resetSession() {
259
+ this.sessionId = "";
260
+ }
261
+
262
+ async runChat({ content, messagePayload = {}, onEvent } = {}) {
263
+ const safeContent = String(content || "").trim();
264
+ if (!safeContent) {
265
+ throw new Error("Le contenu du message SDK est vide.");
266
+ }
267
+ const sessionId = await this.ensureSession();
268
+ await this.sendMessage({ sessionId, content: safeContent, messagePayload });
269
+ const streamResult = await this.streamSession({ sessionId, onEvent });
270
+ return {
271
+ sessionId,
272
+ ...streamResult,
273
+ };
274
+ }
275
+
276
+ async ensureSession() {
277
+ if (this.sessionId) {
278
+ return this.sessionId;
279
+ }
280
+ await this.ensureBootstrapToken();
281
+ const payload = await this.transportManager.fetchJson({
282
+ url: this.sessionsEndpoint,
283
+ method: "POST",
284
+ headers: this.buildHeaders({ "Content-Type": "application/json" }),
285
+ body: "{}",
286
+ timeoutMs: this.timeoutMs,
287
+ });
288
+ const sessionId = String(payload?.session_id || payload?.id || "").trim();
289
+ if (!sessionId) {
290
+ throw new Error("Session SDK introuvable dans la réponse.");
291
+ }
292
+ this.sessionId = sessionId;
293
+ return this.sessionId;
294
+ }
295
+
296
+ async ensureBootstrapToken() {
297
+ if (this.sessionBootstrapToken) {
298
+ return this.sessionBootstrapToken;
299
+ }
300
+ const payload = await this.transportManager.fetchJson({
301
+ url: this.bootstrapEndpoint,
302
+ method: "POST",
303
+ headers: { "Content-Type": "application/json" },
304
+ body: JSON.stringify({
305
+ tenant_id: this.tenantId,
306
+ public_widget_key: this.widgetKey,
307
+ origin: this.resolveOrigin(),
308
+ }),
309
+ timeoutMs: this.timeoutMs,
310
+ });
311
+ this.sessionBootstrapToken = String(payload?.session_bootstrap_token || payload?.session_token || "").trim();
312
+ return this.sessionBootstrapToken;
313
+ }
314
+
315
+ resolveOrigin() {
316
+ if (typeof window !== "undefined" && window.location && window.location.origin) {
317
+ return window.location.origin;
318
+ }
319
+ return "http://localhost";
320
+ }
321
+
322
+ buildHeaders(extraHeaders = {}) {
323
+ const headers = {
324
+ ...extraHeaders,
325
+ };
326
+ const widgetOrigin = this.resolveOrigin();
327
+ if (widgetOrigin) {
328
+ headers["X-Widget-Origin"] = widgetOrigin;
329
+ }
330
+ if (this.widgetKey) {
331
+ headers["X-Widget-Key"] = this.widgetKey;
332
+ }
333
+ if (this.tenantId) {
334
+ headers["X-Tenant-ID"] = this.tenantId;
335
+ }
336
+ if (this.targetLanguage) {
337
+ headers["X-Target-Language"] = this.targetLanguage;
338
+ }
339
+ if (this.useBootstrapAuthorization && this.sessionBootstrapToken) {
340
+ headers.Authorization = `Bearer ${this.sessionBootstrapToken}`;
341
+ }
342
+ return headers;
343
+ }
344
+
345
+ async sendMessage({ sessionId, content, messagePayload = {} }) {
346
+ const body = JSON.stringify({
347
+ content,
348
+ ...((messagePayload && typeof messagePayload === "object") ? messagePayload : {}),
349
+ });
350
+ await this.transportManager.fetchRaw({
351
+ url: `${this.sessionsEndpoint}/${sessionId}/messages`,
352
+ method: "POST",
353
+ headers: this.buildHeaders({ "Content-Type": "application/json" }),
354
+ body,
355
+ timeoutMs: this.timeoutMs,
356
+ });
357
+ }
358
+
359
+ async streamSession({ sessionId, onEvent } = {}) {
360
+ const response = await this.transportManager.fetchRaw({
361
+ url: `${this.sessionsEndpoint}/${sessionId}/stream`,
362
+ method: "GET",
363
+ headers: this.buildHeaders(),
364
+ timeoutMs: this.timeoutMs,
365
+ });
366
+ if (!response.body) {
367
+ throw new Error("Flux SDK indisponible (body absent).");
368
+ }
369
+ const reader = response.body.getReader();
370
+ const decoder = new TextDecoder("utf-8");
371
+ const streamEvents = [];
372
+ let finalEventPayload = null;
373
+ let buffer = "";
374
+ while (true) {
375
+ const { value, done } = await reader.read();
376
+ if (done) {
377
+ const trailingEvents = this.sseDecoderManager.decodeLastBuffer(buffer);
378
+ trailingEvents.forEach((streamEvent) => {
379
+ finalEventPayload = this.emitStreamEvent({ streamEvent, onEvent, streamEvents, finalEventPayload });
380
+ });
381
+ break;
382
+ }
383
+ const chunk = decoder.decode(value, { stream: true });
384
+ const decodedChunk = this.sseDecoderManager.decodeChunk(buffer, chunk);
385
+ buffer = decodedChunk.remainingBuffer;
386
+ decodedChunk.decodedEvents.forEach((streamEvent) => {
387
+ finalEventPayload = this.emitStreamEvent({ streamEvent, onEvent, streamEvents, finalEventPayload });
388
+ });
389
+ }
390
+ return {
391
+ events: streamEvents,
392
+ finalEventPayload,
393
+ };
394
+ }
395
+
396
+ emitStreamEvent({ streamEvent, onEvent, streamEvents, finalEventPayload }) {
397
+ const normalizedEvent = {
398
+ eventName: streamEvent.eventName,
399
+ data: streamEvent.data,
400
+ receivedAt: new Date().toISOString(),
401
+ };
402
+ streamEvents.push(normalizedEvent);
403
+ if (typeof onEvent === "function") {
404
+ onEvent(normalizedEvent);
405
+ }
406
+ const eventName = String(normalizedEvent.eventName || "").toLowerCase();
407
+ if (eventName.includes("final") || eventName.includes("done")) {
408
+ return normalizedEvent.data;
409
+ }
410
+ return finalEventPayload;
411
+ }
412
+ }
413
+
414
+ const runtimeBootstrapManager = new RuntimeBootstrapManager(
415
+ new RuntimeUrlResolverManager(),
416
+ new BrowserScriptLoaderManager(),
417
+ );
418
+
419
+ export const sdkVersion = "v1";
420
+ export {
421
+ RuntimeUrlResolverManager,
422
+ BrowserScriptLoaderManager,
423
+ RuntimeBootstrapManager,
424
+ SdkHeadlessChatClientManager,
425
+ SdkHttpTransportManager,
426
+ SdkEndpointPathResolverManager,
427
+ SseEventDecoderManager,
428
+ };
429
+
430
+ export async function bootstrapWidgetRuntime(options) {
431
+ return runtimeBootstrapManager.loadRuntimeAssets(options || {});
432
+ }
433
+
434
+ export function mountWidgetFromScript(scriptTag) {
435
+ return runtimeBootstrapManager.mountFromScript(scriptTag);
436
+ }