@usereq/widget 0.2.20 → 0.2.22
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/dist/widget.js +29 -29
- package/package.json +1 -1
- package/src/custom-element/agent-widget-element.tsx +155 -108
- package/src/renderer/widget-runtime.tsx +9 -0
- package/src/runtime/bootstrap.ts +30 -39
- package/src/shared/widget-config.ts +21 -0
- package/src/types.ts +10 -0
- package/src/chat-widget/chat-widget-defaults.tsx +0 -6
package/package.json
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { createRoot, type Root } from "react-dom/client";
|
|
3
|
-
import { DEFAULT_START_CONVERSATION_LABEL } from "../chat-widget/chat-widget-defaults";
|
|
4
3
|
import {
|
|
5
4
|
DEFAULT_WIDGET_APPEARANCE,
|
|
6
5
|
DEFAULT_WIDGET_PRESET,
|
|
@@ -15,7 +14,10 @@ import {
|
|
|
15
14
|
type WidgetSessionState,
|
|
16
15
|
type WidgetVariant,
|
|
17
16
|
} from "../types";
|
|
18
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
normalizeWidgetMobileHeight,
|
|
19
|
+
widgetTriggerRuleKey,
|
|
20
|
+
} from "../shared/widget-config";
|
|
19
21
|
import { WidgetRuntime, type WidgetRuntimeStatus } from "../renderer/index";
|
|
20
22
|
import { buildAssistantMessageRevealSteps } from "../chat-widget/message-reveal";
|
|
21
23
|
import { DEFAULT_STOP_CONVERSATION_PROMPT } from "../shared/stop-confirmation";
|
|
@@ -23,7 +25,6 @@ import {
|
|
|
23
25
|
bootstrapWidget,
|
|
24
26
|
resetWidgetSession,
|
|
25
27
|
submitWidgetStopConfirmation,
|
|
26
|
-
startWidgetConversation,
|
|
27
28
|
submitWidgetMessage,
|
|
28
29
|
} from "../runtime/bootstrap";
|
|
29
30
|
import { getWidgetShadowCss } from "../shared/shadow-css";
|
|
@@ -67,7 +68,12 @@ export class AgentWidgetElement extends HTMLElement {
|
|
|
67
68
|
private sendVersion = 0;
|
|
68
69
|
private confirmationVersion = 0;
|
|
69
70
|
private bootstrapPhase: "idle" | "booting" | "ready" | "blocked" = "idle";
|
|
70
|
-
|
|
71
|
+
// Back-to-back send support. The composer stays enabled while the agent
|
|
72
|
+
// is replying; new submissions land here and are drained sequentially by
|
|
73
|
+
// `sendMessage`. Each entry carries the optimistic bubble id so failures
|
|
74
|
+
// can yank exactly the right row.
|
|
75
|
+
private pendingQueue: Array<{ content: string; optimisticId: string }> = [];
|
|
76
|
+
private draining = false;
|
|
71
77
|
|
|
72
78
|
constructor() {
|
|
73
79
|
super();
|
|
@@ -207,6 +213,10 @@ export class AgentWidgetElement extends HTMLElement {
|
|
|
207
213
|
this.welcomeMessage = null;
|
|
208
214
|
this.errorMessage = null;
|
|
209
215
|
this.status = "idle";
|
|
216
|
+
// Bumping sendVersion above invalidates the in-flight drain; reset
|
|
217
|
+
// the queue + flag so the next session starts fresh.
|
|
218
|
+
this.pendingQueue = [];
|
|
219
|
+
this.draining = false;
|
|
210
220
|
}
|
|
211
221
|
|
|
212
222
|
private renderWidget() {
|
|
@@ -224,32 +234,7 @@ export class AgentWidgetElement extends HTMLElement {
|
|
|
224
234
|
const variant = this.getResolvedVariant();
|
|
225
235
|
const preset = this.getResolvedPreset();
|
|
226
236
|
const behavior = this.sessionConfig?.widgetConfig.widgetBehavior;
|
|
227
|
-
const autoStartEnabled = behavior?.autoStart === true;
|
|
228
237
|
const alwaysOpen = behavior?.alwaysOpen === true;
|
|
229
|
-
const awaitingConversation =
|
|
230
|
-
this.status === "booting" ||
|
|
231
|
-
(this.sessionState != null && !this.sessionState.conversationId);
|
|
232
|
-
// When auto-start is on we never render the manual CTA — except as a
|
|
233
|
-
// fallback if the auto-start attempt errored, so the visitor isn't
|
|
234
|
-
// stranded on a blank panel.
|
|
235
|
-
const showFallbackCta = autoStartEnabled && this.status === "error";
|
|
236
|
-
const emptyAction =
|
|
237
|
-
!autoStartEnabled && awaitingConversation
|
|
238
|
-
? {
|
|
239
|
-
label: DEFAULT_START_CONVERSATION_LABEL,
|
|
240
|
-
onClick: () => void this.handleStartConversation(),
|
|
241
|
-
disabled: this.status !== "ready",
|
|
242
|
-
}
|
|
243
|
-
: showFallbackCta
|
|
244
|
-
? {
|
|
245
|
-
label: DEFAULT_START_CONVERSATION_LABEL,
|
|
246
|
-
onClick: () => {
|
|
247
|
-
this.autoStartTriggered = false;
|
|
248
|
-
void this.handleStartConversation();
|
|
249
|
-
},
|
|
250
|
-
disabled: false,
|
|
251
|
-
}
|
|
252
|
-
: undefined;
|
|
253
238
|
|
|
254
239
|
this.root.render(
|
|
255
240
|
React.createElement(WidgetRuntime, {
|
|
@@ -267,19 +252,23 @@ export class AgentWidgetElement extends HTMLElement {
|
|
|
267
252
|
welcomeMessage: this.getWelcomeMessage(),
|
|
268
253
|
spotlights: this.getSpotlights(),
|
|
269
254
|
ctaLink: this.getCtaLink(),
|
|
255
|
+
mobileHeight: normalizeWidgetMobileHeight(
|
|
256
|
+
this.sessionConfig?.widgetConfig.mobileHeight,
|
|
257
|
+
),
|
|
270
258
|
open: this.open,
|
|
271
259
|
alwaysOpen,
|
|
272
260
|
status: this.status,
|
|
273
261
|
messages: this.messages,
|
|
274
262
|
errorMessage: this.errorMessage,
|
|
275
263
|
triggerRule: this.sessionConfig?.widgetConfig.triggerRule ?? null,
|
|
276
|
-
|
|
264
|
+
// Lazy session: no "Start a chat" gate. The composer is live from
|
|
265
|
+
// the moment the panel opens; the conversation is created on the
|
|
266
|
+
// server the first time the user actually sends something.
|
|
267
|
+
emptyAction: undefined,
|
|
277
268
|
onStopConversation:
|
|
278
269
|
this.sessionState?.conversationId != null
|
|
279
270
|
? () => void this.handleStopConversation()
|
|
280
271
|
: undefined,
|
|
281
|
-
sendDisabled:
|
|
282
|
-
this.status === "sending" || !this.sessionState?.conversationId,
|
|
283
272
|
onOpenChange: (nextOpen: boolean) => void this.handleOpenChange(nextOpen),
|
|
284
273
|
onSendMessage: (content: string) => void this.sendMessage(content),
|
|
285
274
|
onConfirmationDecision: (confirmationId: string, accepted: boolean) =>
|
|
@@ -335,6 +324,11 @@ export class AgentWidgetElement extends HTMLElement {
|
|
|
335
324
|
this.welcomeMessage = state.session.widgetConfig.welcomeMessage;
|
|
336
325
|
this.status = "ready";
|
|
337
326
|
this.bootstrapPhase = "ready";
|
|
327
|
+
// Lazy session: when the visitor doesn't already have a running
|
|
328
|
+
// conversation, seed a client-side welcome bubble from the boot
|
|
329
|
+
// config so the panel doesn't open on a blank empty state. The
|
|
330
|
+
// real conversation is only created server-side once they type.
|
|
331
|
+
this.seedClientWelcomeIfNeeded();
|
|
338
332
|
// Apply the agent's configured open-state on mount. `alwaysOpen`
|
|
339
333
|
// wins (the panel is pinned regardless), and `defaultOpen` opens it
|
|
340
334
|
// once if the visitor hasn't already interacted with the launcher.
|
|
@@ -343,17 +337,6 @@ export class AgentWidgetElement extends HTMLElement {
|
|
|
343
337
|
this.open = true;
|
|
344
338
|
}
|
|
345
339
|
this.renderWidget();
|
|
346
|
-
// Bootstrap has finished and status is now "ready". If `autoStart`
|
|
347
|
-
// is on AND the panel is open (either because `defaultOpen` /
|
|
348
|
-
// `alwaysOpen` just opened it, or because the visitor opened it
|
|
349
|
-
// before bootstrap finished and `handleOpenChange` is awaiting
|
|
350
|
-
// this very `ensureBootstrap` call), trigger the auto-start now.
|
|
351
|
-
// Without this, the `defaultOpen + autoStart` combination renders
|
|
352
|
-
// an empty panel with no CTA (autoStart suppresses the Start CTA)
|
|
353
|
-
// and the visitor sees no welcome message ever.
|
|
354
|
-
// `maybeAutoStartConversation` is idempotent — it guards on
|
|
355
|
-
// `autoStartTriggered` so calling it from multiple paths is safe.
|
|
356
|
-
this.maybeAutoStartConversation();
|
|
357
340
|
} catch (error) {
|
|
358
341
|
if (runVersion !== this.bootstrapVersion) {
|
|
359
342
|
return;
|
|
@@ -374,35 +357,125 @@ export class AgentWidgetElement extends HTMLElement {
|
|
|
374
357
|
return this.bootstrapPromise;
|
|
375
358
|
}
|
|
376
359
|
|
|
360
|
+
/**
|
|
361
|
+
* Entry point for user-typed sends. Drops the optimistic bubble in
|
|
362
|
+
* immediately so the user sees their own backlog, then either kicks off
|
|
363
|
+
* the drain loop (if idle) or appends to `pendingQueue` (if a prior send
|
|
364
|
+
* is still mid-stream). The drain loop runs at most one POST in flight
|
|
365
|
+
* at a time — the server-side session memory expects sequential turns,
|
|
366
|
+
* and the AI engineer confirmed parallel requests are *possible* but
|
|
367
|
+
* the answers would interleave out-of-order on the wire.
|
|
368
|
+
*/
|
|
377
369
|
private async sendMessage(content: string) {
|
|
378
370
|
const trimmedContent = content.trim();
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
371
|
+
// Lazy session: no `conversationId` check here. If the visitor is
|
|
372
|
+
// sending their very first message, `submitWidgetMessage` will call
|
|
373
|
+
// POST /conversations/public/start under the hood before posting.
|
|
374
|
+
if (!trimmedContent || !this.sessionState) return;
|
|
375
|
+
|
|
376
|
+
const optimisticMessageId = `optimistic:${Date.now()}:${Math.random()
|
|
377
|
+
.toString(36)
|
|
378
|
+
.slice(2, 8)}`;
|
|
383
379
|
const optimisticMessage: WidgetMessage = {
|
|
384
380
|
id: optimisticMessageId,
|
|
385
|
-
|
|
381
|
+
// Empty when no conversation exists yet — the real id lands on the
|
|
382
|
+
// server row we swap in from `submitWidgetMessage`'s response.
|
|
383
|
+
conversationId: this.sessionState.conversationId ?? "",
|
|
386
384
|
role: "user",
|
|
387
385
|
content: trimmedContent,
|
|
388
386
|
createdAt: new Date().toISOString(),
|
|
389
387
|
};
|
|
390
388
|
|
|
391
|
-
this.status = "sending";
|
|
392
389
|
this.errorMessage = null;
|
|
393
390
|
this.messages = [...this.messages, optimisticMessage];
|
|
394
391
|
this.renderWidget();
|
|
395
392
|
|
|
393
|
+
// Drain already running → just join the queue. The drainer will
|
|
394
|
+
// consume this entry as soon as the in-flight POST + reveal settle.
|
|
395
|
+
if (this.draining) {
|
|
396
|
+
this.pendingQueue.push({
|
|
397
|
+
content: trimmedContent,
|
|
398
|
+
optimisticId: optimisticMessageId,
|
|
399
|
+
});
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
this.draining = true;
|
|
404
|
+
this.status = "sending";
|
|
405
|
+
this.renderWidget();
|
|
406
|
+
|
|
407
|
+
const runVersion = this.sendVersion;
|
|
408
|
+
let succeeded = true;
|
|
409
|
+
|
|
410
|
+
try {
|
|
411
|
+
await this.processSend(trimmedContent, optimisticMessageId, runVersion);
|
|
412
|
+
while (this.pendingQueue.length > 0 && runVersion === this.sendVersion) {
|
|
413
|
+
const next = this.pendingQueue.shift()!;
|
|
414
|
+
await this.processSend(next.content, next.optimisticId, runVersion);
|
|
415
|
+
}
|
|
416
|
+
} catch {
|
|
417
|
+
succeeded = false;
|
|
418
|
+
if (runVersion === this.sendVersion) {
|
|
419
|
+
// Drop the rest of the backlog — each queued message expects the
|
|
420
|
+
// server to have already consumed the prior turn, which didn't
|
|
421
|
+
// happen. Leaving them queued would silently fire mid-failure.
|
|
422
|
+
const failedIds = new Set(
|
|
423
|
+
this.pendingQueue.map((entry) => entry.optimisticId),
|
|
424
|
+
);
|
|
425
|
+
this.pendingQueue = [];
|
|
426
|
+
this.messages = this.messages.filter((m) => !failedIds.has(m.id));
|
|
427
|
+
// `processSend` already set status="error" + errorMessage; render
|
|
428
|
+
// once more so the cleared queue is visible.
|
|
429
|
+
this.renderWidget();
|
|
430
|
+
}
|
|
431
|
+
} finally {
|
|
432
|
+
this.draining = false;
|
|
433
|
+
if (succeeded && runVersion === this.sendVersion) {
|
|
434
|
+
this.status = "ready";
|
|
435
|
+
this.renderWidget();
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* One round-trip: POST → swap optimistic → stagger assistant reveal.
|
|
442
|
+
* Throws on failure so the outer drainer can clear the backlog. The
|
|
443
|
+
* `runVersion` guard short-circuits late state writes if a hard reset
|
|
444
|
+
* (agent-id change, conversation reset) bumped `sendVersion` mid-drain.
|
|
445
|
+
*/
|
|
446
|
+
private async processSend(
|
|
447
|
+
content: string,
|
|
448
|
+
optimisticId: string,
|
|
449
|
+
runVersion: number,
|
|
450
|
+
) {
|
|
396
451
|
try {
|
|
397
452
|
const result = await submitWidgetMessage({
|
|
398
453
|
agentId: this.agentId,
|
|
399
|
-
content
|
|
454
|
+
content,
|
|
400
455
|
});
|
|
401
|
-
if (runVersion !== this.sendVersion)
|
|
402
|
-
return;
|
|
403
|
-
}
|
|
456
|
+
if (runVersion !== this.sendVersion) return;
|
|
404
457
|
this.sessionState = result.session;
|
|
405
458
|
|
|
459
|
+
// Lazy-start swap: when this was the first send, `submitWidgetMessage`
|
|
460
|
+
// created the conversation server-side and the server inserted its
|
|
461
|
+
// own welcome message row. Replace the placeholder client-welcome
|
|
462
|
+
// bubble with the real server row so ids/timestamps match the DB.
|
|
463
|
+
if (result.welcomeMessage) {
|
|
464
|
+
const clientWelcomeId = this.getClientWelcomeId();
|
|
465
|
+
this.messages = this.messages.map((m) =>
|
|
466
|
+
m.id === clientWelcomeId
|
|
467
|
+
? {
|
|
468
|
+
id: result.welcomeMessage!.id,
|
|
469
|
+
conversationId: result.conversationId,
|
|
470
|
+
role: "assistant",
|
|
471
|
+
content: result.welcomeMessage!.content,
|
|
472
|
+
createdAt: result.welcomeMessage!.createdAt,
|
|
473
|
+
}
|
|
474
|
+
: m,
|
|
475
|
+
);
|
|
476
|
+
this.renderWidget();
|
|
477
|
+
}
|
|
478
|
+
|
|
406
479
|
const revealSteps = buildAssistantMessageRevealSteps(
|
|
407
480
|
result.assistantMessages.map((message) => ({
|
|
408
481
|
id: message.id,
|
|
@@ -418,10 +491,10 @@ export class AgentWidgetElement extends HTMLElement {
|
|
|
418
491
|
);
|
|
419
492
|
|
|
420
493
|
for (const step of revealSteps) {
|
|
421
|
-
await new Promise((resolve) =>
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
494
|
+
await new Promise((resolve) =>
|
|
495
|
+
window.setTimeout(resolve, step.delayMs),
|
|
496
|
+
);
|
|
497
|
+
if (runVersion !== this.sendVersion) return;
|
|
425
498
|
const dto = step.message;
|
|
426
499
|
this.messages = [
|
|
427
500
|
...this.messages,
|
|
@@ -435,22 +508,17 @@ export class AgentWidgetElement extends HTMLElement {
|
|
|
435
508
|
];
|
|
436
509
|
this.renderWidget();
|
|
437
510
|
}
|
|
438
|
-
|
|
439
|
-
if (runVersion === this.sendVersion) {
|
|
440
|
-
this.status = "ready";
|
|
441
|
-
this.renderWidget();
|
|
442
|
-
}
|
|
443
511
|
} catch (error) {
|
|
444
512
|
if (runVersion !== this.sendVersion) {
|
|
513
|
+
// The hard reset path already cleared state — swallow.
|
|
445
514
|
return;
|
|
446
515
|
}
|
|
447
|
-
this.messages = this.messages.filter(
|
|
448
|
-
(message) => message.id !== optimisticMessageId,
|
|
449
|
-
);
|
|
516
|
+
this.messages = this.messages.filter((m) => m.id !== optimisticId);
|
|
450
517
|
this.status = "error";
|
|
451
518
|
this.errorMessage =
|
|
452
519
|
error instanceof Error ? error.message : "Failed to send message";
|
|
453
520
|
this.renderWidget();
|
|
521
|
+
throw error;
|
|
454
522
|
}
|
|
455
523
|
}
|
|
456
524
|
|
|
@@ -461,55 +529,34 @@ export class AgentWidgetElement extends HTMLElement {
|
|
|
461
529
|
if (this.open && (this.status === "idle" || !this.sessionState)) {
|
|
462
530
|
await this.ensureBootstrap();
|
|
463
531
|
}
|
|
532
|
+
}
|
|
464
533
|
|
|
465
|
-
|
|
534
|
+
private getClientWelcomeId(): string {
|
|
535
|
+
return `client-welcome:${this.agentId}`;
|
|
466
536
|
}
|
|
467
537
|
|
|
468
538
|
/**
|
|
469
|
-
*
|
|
470
|
-
*
|
|
471
|
-
*
|
|
472
|
-
*
|
|
473
|
-
*
|
|
539
|
+
* Lazy session seeding. When bootstrap finishes with no live conversation
|
|
540
|
+
* and no rehydrated transcript, drop a synthetic assistant bubble carrying
|
|
541
|
+
* the boot-config welcome copy into `this.messages`. Skipped when there's
|
|
542
|
+
* already a transcript (rehydrated session) or no welcome copy configured.
|
|
543
|
+
* The bubble's id is stable (`client-welcome:{agentId}`) so `processSend`
|
|
544
|
+
* can swap it for the real server row after the first successful send.
|
|
474
545
|
*/
|
|
475
|
-
private
|
|
476
|
-
if (this.
|
|
477
|
-
if (!this.open) return;
|
|
478
|
-
if (this.status !== "ready") return;
|
|
479
|
-
if (!this.sessionConfig) return;
|
|
480
|
-
if (!this.sessionConfig.widgetConfig.widgetBehavior?.autoStart) return;
|
|
546
|
+
private seedClientWelcomeIfNeeded() {
|
|
547
|
+
if (this.messages.length > 0) return;
|
|
481
548
|
if (this.sessionState?.conversationId) return;
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
this.status = "booting";
|
|
496
|
-
this.errorMessage = null;
|
|
497
|
-
this.renderWidget();
|
|
498
|
-
|
|
499
|
-
try {
|
|
500
|
-
const result = await startWidgetConversation({
|
|
501
|
-
agentId: this.agentId,
|
|
502
|
-
});
|
|
503
|
-
this.sessionState = result.session;
|
|
504
|
-
this.messages = result.messages;
|
|
505
|
-
this.status = "ready";
|
|
506
|
-
this.renderWidget();
|
|
507
|
-
} catch (error) {
|
|
508
|
-
this.status = "error";
|
|
509
|
-
this.errorMessage =
|
|
510
|
-
error instanceof Error ? error.message : "Failed to start conversation";
|
|
511
|
-
this.renderWidget();
|
|
512
|
-
}
|
|
549
|
+
const welcome = this.getWelcomeMessage();
|
|
550
|
+
if (!welcome) return;
|
|
551
|
+
this.messages = [
|
|
552
|
+
{
|
|
553
|
+
id: this.getClientWelcomeId(),
|
|
554
|
+
conversationId: "",
|
|
555
|
+
role: "assistant",
|
|
556
|
+
content: welcome,
|
|
557
|
+
createdAt: new Date().toISOString(),
|
|
558
|
+
},
|
|
559
|
+
];
|
|
513
560
|
}
|
|
514
561
|
|
|
515
562
|
private async handleStopConversation() {
|
|
@@ -14,6 +14,7 @@ import type { WidgetMessage } from "../types";
|
|
|
14
14
|
import {
|
|
15
15
|
splitWidgetPlacement,
|
|
16
16
|
type WidgetAppearance,
|
|
17
|
+
type WidgetMobileHeight,
|
|
17
18
|
type WidgetPlacement,
|
|
18
19
|
type WidgetPreset,
|
|
19
20
|
type WidgetVariant,
|
|
@@ -49,6 +50,11 @@ export type WidgetRuntimeProps = {
|
|
|
49
50
|
welcomeMessage: string | null;
|
|
50
51
|
spotlights: string[];
|
|
51
52
|
ctaLink?: string | null;
|
|
53
|
+
/**
|
|
54
|
+
* How much of the screen the open panel covers on mobile. Ignored on
|
|
55
|
+
* desktop, where the panel floats above the launcher regardless.
|
|
56
|
+
*/
|
|
57
|
+
mobileHeight?: WidgetMobileHeight;
|
|
52
58
|
open: boolean;
|
|
53
59
|
/**
|
|
54
60
|
* When true, the embedded panel is pinned open and the launcher is hidden.
|
|
@@ -93,6 +99,7 @@ export function WidgetRuntime({
|
|
|
93
99
|
welcomeMessage,
|
|
94
100
|
spotlights,
|
|
95
101
|
ctaLink,
|
|
102
|
+
mobileHeight = "full",
|
|
96
103
|
open,
|
|
97
104
|
alwaysOpen = false,
|
|
98
105
|
status,
|
|
@@ -142,11 +149,13 @@ export function WidgetRuntime({
|
|
|
142
149
|
ctaLink,
|
|
143
150
|
actionLabel: appearance.actionText || undefined,
|
|
144
151
|
showStopConversation: Boolean(onStopConversation),
|
|
152
|
+
mobileHeight,
|
|
145
153
|
}),
|
|
146
154
|
[
|
|
147
155
|
agentAvatarUrl,
|
|
148
156
|
appearance,
|
|
149
157
|
ctaLink,
|
|
158
|
+
mobileHeight,
|
|
150
159
|
onStopConversation,
|
|
151
160
|
placement,
|
|
152
161
|
preset,
|
package/src/runtime/bootstrap.ts
CHANGED
|
@@ -227,6 +227,15 @@ export async function bootstrapWidget(input: {
|
|
|
227
227
|
return bootstrapWidgetWithDeps(input, defaultBootstrapDeps);
|
|
228
228
|
}
|
|
229
229
|
|
|
230
|
+
/**
|
|
231
|
+
* Send a user message. Lazy-starts the conversation on the first send —
|
|
232
|
+
* `startPublicChat` is called transparently when no `conversationId` is
|
|
233
|
+
* stored yet, so visitors never create empty conversations by opening
|
|
234
|
+
* the panel and walking away. When a start happens as part of this call
|
|
235
|
+
* the server-inserted welcome message rides back on `welcomeMessage`
|
|
236
|
+
* so the caller can reconcile the client-side placeholder bubble with
|
|
237
|
+
* the real DB row.
|
|
238
|
+
*/
|
|
230
239
|
export async function submitWidgetMessage(input: {
|
|
231
240
|
agentId: string;
|
|
232
241
|
content: string;
|
|
@@ -236,37 +245,52 @@ export async function submitWidgetMessage(input: {
|
|
|
236
245
|
messages: WidgetMessage[];
|
|
237
246
|
userMessage: WidgetMessage;
|
|
238
247
|
assistantMessages: WidgetMessage[];
|
|
248
|
+
welcomeMessage?: WidgetMessage;
|
|
239
249
|
}> {
|
|
240
|
-
|
|
250
|
+
let stored = await ensureWidgetSession({
|
|
241
251
|
agentId: input.agentId,
|
|
242
252
|
pageUrl: window.location.href,
|
|
243
253
|
referrer: document.referrer || undefined,
|
|
244
254
|
});
|
|
245
|
-
|
|
246
|
-
|
|
255
|
+
|
|
256
|
+
let welcomeMessage: WidgetMessage | undefined;
|
|
257
|
+
let conversationId: string;
|
|
258
|
+
if (stored.conversationId) {
|
|
259
|
+
conversationId = stored.conversationId;
|
|
260
|
+
} else {
|
|
261
|
+
const started = await startPublicChat({
|
|
262
|
+
agentId: input.agentId,
|
|
263
|
+
sessionToken: stored.sessionToken,
|
|
264
|
+
});
|
|
265
|
+
conversationId = started.conversation.id;
|
|
266
|
+
stored = { ...stored, conversationId };
|
|
267
|
+
saveSession(input.agentId, stored);
|
|
268
|
+
welcomeMessage = started.welcomeMessage;
|
|
247
269
|
}
|
|
248
270
|
|
|
249
271
|
const response = await sendPublicChatMessage({
|
|
250
|
-
conversationId
|
|
272
|
+
conversationId,
|
|
251
273
|
sessionToken: stored.sessionToken,
|
|
252
274
|
content: input.content,
|
|
253
275
|
});
|
|
254
276
|
|
|
255
277
|
const nextSession: WidgetSessionState = {
|
|
256
278
|
...stored,
|
|
257
|
-
conversationId
|
|
279
|
+
conversationId,
|
|
258
280
|
};
|
|
259
281
|
saveSession(input.agentId, nextSession);
|
|
260
282
|
|
|
261
283
|
return {
|
|
262
284
|
session: nextSession,
|
|
263
|
-
conversationId
|
|
285
|
+
conversationId,
|
|
264
286
|
messages: sortByCreatedAt([
|
|
287
|
+
...(welcomeMessage ? [welcomeMessage] : []),
|
|
265
288
|
response.userMessage,
|
|
266
289
|
...response.assistantMessages,
|
|
267
290
|
]),
|
|
268
291
|
userMessage: response.userMessage,
|
|
269
292
|
assistantMessages: response.assistantMessages,
|
|
293
|
+
welcomeMessage,
|
|
270
294
|
};
|
|
271
295
|
}
|
|
272
296
|
|
|
@@ -337,39 +361,6 @@ export async function submitWidgetStopConfirmation(input: {
|
|
|
337
361
|
};
|
|
338
362
|
}
|
|
339
363
|
|
|
340
|
-
export async function startWidgetConversation(input: {
|
|
341
|
-
agentId: string;
|
|
342
|
-
}): Promise<{
|
|
343
|
-
session: WidgetSessionState;
|
|
344
|
-
conversationId: string;
|
|
345
|
-
messages: WidgetMessage[];
|
|
346
|
-
welcomeMessage: WidgetMessage;
|
|
347
|
-
}> {
|
|
348
|
-
const stored = await ensureWidgetSession({
|
|
349
|
-
agentId: input.agentId,
|
|
350
|
-
pageUrl: window.location.href,
|
|
351
|
-
referrer: document.referrer || undefined,
|
|
352
|
-
});
|
|
353
|
-
|
|
354
|
-
const response = await startPublicChat({
|
|
355
|
-
agentId: input.agentId,
|
|
356
|
-
sessionToken: stored.sessionToken,
|
|
357
|
-
});
|
|
358
|
-
|
|
359
|
-
const nextSession: WidgetSessionState = {
|
|
360
|
-
...stored,
|
|
361
|
-
conversationId: response.conversation.id,
|
|
362
|
-
};
|
|
363
|
-
saveSession(input.agentId, nextSession);
|
|
364
|
-
|
|
365
|
-
return {
|
|
366
|
-
session: nextSession,
|
|
367
|
-
conversationId: response.conversation.id,
|
|
368
|
-
messages: sortByCreatedAt([response.welcomeMessage]),
|
|
369
|
-
welcomeMessage: response.welcomeMessage,
|
|
370
|
-
};
|
|
371
|
-
}
|
|
372
|
-
|
|
373
364
|
export async function resetWidgetSession(agentId: string): Promise<void> {
|
|
374
365
|
clearSession(agentId);
|
|
375
366
|
}
|
|
@@ -18,6 +18,27 @@ export type WidgetPreset = "default" | "facebook_messenger";
|
|
|
18
18
|
|
|
19
19
|
export const DEFAULT_WIDGET_PRESET: WidgetPreset = "default";
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* How much of the screen the open panel covers on mobile. `full` is the
|
|
23
|
+
* edge-to-edge sheet; the others render a bottom drawer over the host page.
|
|
24
|
+
*/
|
|
25
|
+
export type WidgetMobileHeight = "half" | "three-quarter" | "full";
|
|
26
|
+
|
|
27
|
+
export const DEFAULT_WIDGET_MOBILE_HEIGHT: WidgetMobileHeight = "full";
|
|
28
|
+
|
|
29
|
+
export function normalizeWidgetMobileHeight(
|
|
30
|
+
value: string | null | undefined,
|
|
31
|
+
): WidgetMobileHeight {
|
|
32
|
+
switch (value) {
|
|
33
|
+
case "half":
|
|
34
|
+
case "three-quarter":
|
|
35
|
+
case "full":
|
|
36
|
+
return value;
|
|
37
|
+
default:
|
|
38
|
+
return DEFAULT_WIDGET_MOBILE_HEIGHT;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
21
42
|
export type WidgetAppearance = ResolvedChatWidgetAppearance;
|
|
22
43
|
|
|
23
44
|
export const DEFAULT_WIDGET_APPEARANCE: WidgetAppearance =
|
package/src/types.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
WidgetAppearance,
|
|
3
3
|
WidgetBehavior,
|
|
4
|
+
WidgetMobileHeight,
|
|
4
5
|
WidgetPlacement,
|
|
5
6
|
WidgetPreset,
|
|
6
7
|
WidgetVariant,
|
|
@@ -10,14 +11,17 @@ import type {
|
|
|
10
11
|
export {
|
|
11
12
|
DEFAULT_WIDGET_APPEARANCE,
|
|
12
13
|
DEFAULT_WIDGET_BEHAVIOR,
|
|
14
|
+
DEFAULT_WIDGET_MOBILE_HEIGHT,
|
|
13
15
|
DEFAULT_WIDGET_PRESET,
|
|
14
16
|
type WidgetAppearance,
|
|
15
17
|
type WidgetBehavior,
|
|
18
|
+
type WidgetMobileHeight,
|
|
16
19
|
type WidgetPlacement,
|
|
17
20
|
type WidgetPreset,
|
|
18
21
|
type WidgetVariant,
|
|
19
22
|
normalizeWidgetAppearance,
|
|
20
23
|
normalizeWidgetBehavior,
|
|
24
|
+
normalizeWidgetMobileHeight,
|
|
21
25
|
normalizeWidgetPlacement,
|
|
22
26
|
normalizeWidgetPreset,
|
|
23
27
|
normalizeWidgetVariant,
|
|
@@ -41,6 +45,12 @@ export type WidgetSessionConfig = {
|
|
|
41
45
|
widgetVariant: WidgetVariant;
|
|
42
46
|
widgetPreset: WidgetPreset;
|
|
43
47
|
widgetPlacement: WidgetPlacement;
|
|
48
|
+
/**
|
|
49
|
+
* Mobile presentation of the open panel. Optional on the wire so an
|
|
50
|
+
* embed running against an older API (which doesn't send the field)
|
|
51
|
+
* still boots — it falls back to the edge-to-edge default.
|
|
52
|
+
*/
|
|
53
|
+
mobileHeight?: WidgetMobileHeight | null;
|
|
44
54
|
allowedDomains: string[];
|
|
45
55
|
triggerRule: WidgetTriggerRule | null;
|
|
46
56
|
};
|