@convai/web-sdk 1.8.0-beta.7 → 1.8.0-beta.8

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.
Files changed (39) hide show
  1. package/README.md +19 -1
  2. package/dist/core/index.d.ts +2 -0
  3. package/dist/core/index.d.ts.map +1 -1
  4. package/dist/core/index.js +6 -0
  5. package/dist/core/index.js.map +1 -1
  6. package/dist/embed/ConvaiChatElement.d.ts +77 -1
  7. package/dist/embed/ConvaiChatElement.d.ts.map +1 -1
  8. package/dist/embed/ConvaiChatElement.js +433 -54
  9. package/dist/embed/ConvaiChatElement.js.map +1 -1
  10. package/dist/embed/chat-embed-v1.js +135 -35
  11. package/dist/embed/chat-embed-v1.js.map +3 -3
  12. package/dist/react/components/rtc-widget/components/MarkdownRenderer.d.ts +7 -0
  13. package/dist/react/components/rtc-widget/components/MarkdownRenderer.d.ts.map +1 -1
  14. package/dist/react/components/rtc-widget/components/MarkdownRenderer.js +111 -17
  15. package/dist/react/components/rtc-widget/components/MarkdownRenderer.js.map +1 -1
  16. package/dist/react/components/rtc-widget/components/UserMessage.js +1 -1
  17. package/dist/react/components/rtc-widget/components/UserMessage.js.map +1 -1
  18. package/dist/react/index.d.ts +3 -0
  19. package/dist/react/index.d.ts.map +1 -1
  20. package/dist/react/index.js +7 -0
  21. package/dist/react/index.js.map +1 -1
  22. package/dist/utils/inlineMarkdown.d.ts +22 -0
  23. package/dist/utils/inlineMarkdown.d.ts.map +1 -1
  24. package/dist/utils/inlineMarkdown.js +84 -7
  25. package/dist/utils/inlineMarkdown.js.map +1 -1
  26. package/dist/vanilla/ConvaiWidget.d.ts.map +1 -1
  27. package/dist/vanilla/ConvaiWidget.js +137 -11
  28. package/dist/vanilla/ConvaiWidget.js.map +1 -1
  29. package/dist/vanilla/index.d.ts +2 -0
  30. package/dist/vanilla/index.d.ts.map +1 -1
  31. package/dist/vanilla/index.js +2 -0
  32. package/dist/vanilla/index.js.map +1 -1
  33. package/dist/vanilla/styles.d.ts.map +1 -1
  34. package/dist/vanilla/styles.js +12 -0
  35. package/dist/vanilla/styles.js.map +1 -1
  36. package/dist/version.d.ts +1 -1
  37. package/dist/version.js +1 -1
  38. package/dist/version.js.map +1 -1
  39. package/package.json +32 -10
@@ -6,47 +6,146 @@ const VISIBLE_MESSAGE_TYPES = new Set([
6
6
  "bot-llm-text",
7
7
  "bot-output",
8
8
  ]);
9
+ /** Placeholder session id the client reports before the server assigns one. */
10
+ const UNASSIGNED_SESSION_ID = "-1";
11
+ const COPY_ICON = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V6a2 2 0 0 1 2-2h9"/></svg>`;
12
+ const RESET_ICON = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M3 12a9 9 0 1 0 3-6.7M3 4v5h5"/></svg>`;
13
+ const SEND_ICON = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" aria-hidden="true"><path d="M5 12h14M13 6l6 6-6 6"/></svg>`;
14
+ /**
15
+ * Every colour, radius and font is a custom property so an embedder can retheme
16
+ * the widget from the host page without reaching into the shadow root. The
17
+ * defaults match the Convai playground chat.
18
+ */
9
19
  const styles = `
10
20
  :host {
21
+ --convai-chat-bg: #0F1113;
22
+ --convai-chat-surface: #181A1D;
23
+ --convai-chat-raised: #24272B;
24
+ --convai-chat-border: rgba(145, 158, 171, 0.20);
25
+ --convai-chat-text: #FFFFFF;
26
+ --convai-chat-muted: #919EAB;
27
+ --convai-chat-faint: #637381;
28
+ --convai-chat-accent: #00A76F;
29
+ --convai-chat-accent-strong: #5BE49B;
30
+ --convai-chat-on-accent: #FFFFFF;
31
+ --convai-chat-user-bubble: #BCDBC7;
32
+ --convai-chat-user-text: #0F1113;
33
+ --convai-chat-danger: #FFAC82;
34
+ /* The widget's own corner. Radii inside the chat — bubbles, the composer
35
+ pill, the circular avatars and send button — are part of its shape, not
36
+ its theme, and are fixed. */
37
+ --convai-chat-radius: 16px;
38
+ --convai-chat-font: "Public Sans", Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
39
+
11
40
  color-scheme: dark;
12
41
  display: block;
13
42
  min-height: 560px;
14
43
  width: 100%;
15
- font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
44
+ font-family: var(--convai-chat-font);
16
45
  }
46
+ :host([full-window]) { min-height: 100dvh; }
17
47
  * { box-sizing: border-box; }
48
+
18
49
  .shell {
19
- background: #111118;
20
- border: 1px solid rgba(255, 255, 255, 0.1);
21
- border-radius: 16px;
22
- color: #fff;
50
+ background: var(--convai-chat-bg);
51
+ border: 1px solid var(--convai-chat-border);
52
+ border-radius: var(--convai-chat-radius);
53
+ color: var(--convai-chat-text);
23
54
  display: grid;
24
55
  grid-template-rows: auto minmax(0, 1fr) auto;
25
56
  height: 100%;
26
57
  min-height: inherit;
27
58
  overflow: hidden;
28
59
  }
29
- header { align-items: center; border-bottom: 1px solid rgba(255,255,255,.1); display: flex; gap: 12px; padding: 16px 20px; }
30
- .avatar { background: #654dcc; border-radius: 50%; height: 44px; object-fit: cover; width: 44px; }
60
+ :host([full-window]) .shell { border: 0; border-radius: 0; }
61
+
62
+ header {
63
+ align-items: center;
64
+ background: var(--convai-chat-surface);
65
+ border-bottom: 1px solid var(--convai-chat-border);
66
+ display: flex;
67
+ gap: 12px;
68
+ padding: 12px 18px;
69
+ }
70
+ .avatar {
71
+ background: var(--convai-chat-accent);
72
+ border-radius: 50%;
73
+ flex: none;
74
+ height: 40px;
75
+ object-fit: cover;
76
+ width: 40px;
77
+ }
78
+ .avatar-fallback { align-items: center; color: var(--convai-chat-on-accent); display: inline-flex; font-size: 12px; font-weight: 600; justify-content: center; text-transform: uppercase; }
31
79
  .identity { min-width: 0; }
32
- h2 { font-size: 17px; line-height: 1.3; margin: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
33
- .status { color: rgba(255,255,255,.62); font-size: 13px; margin: 3px 0 0; }
34
- .messages { display: flex; flex-direction: column; gap: 12px; overflow-y: auto; padding: 20px; }
35
- .empty { align-items: center; color: rgba(255,255,255,.7); display: flex; flex: 1; justify-content: center; min-height: 240px; text-align: center; }
36
- .message { border-radius: 14px; line-height: 1.45; max-width: 82%; overflow-wrap: anywhere; padding: 10px 14px; white-space: pre-wrap; }
37
- .message.user { align-self: flex-end; background: #654dcc; }
38
- .message.character { align-self: flex-start; background: rgba(255,255,255,.09); }
39
- .error { color: #ff9e9e; font-size: 13px; margin: 0; padding: 0 20px 12px; }
40
- form { border-top: 1px solid rgba(255,255,255,.1); display: flex; gap: 10px; padding: 16px; }
41
- input { background: rgba(255,255,255,.07); border: 1px solid rgba(255,255,255,.13); border-radius: 12px; color: #fff; flex: 1; font: inherit; min-width: 0; outline: none; padding: 11px 13px; }
42
- input:focus { border-color: #8d7ae4; }
43
- button { background: #654dcc; border: 0; border-radius: 12px; color: #fff; cursor: pointer; font: inherit; font-weight: 600; padding: 10px 16px; }
44
- button:disabled { cursor: default; opacity: .45; }
80
+ h2 { font-size: 15px; font-weight: 600; line-height: 1.3; margin: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
81
+ .status { color: var(--convai-chat-muted); font-size: 12px; margin: 2px 0 0; }
82
+ .header-actions { display: flex; gap: 2px; margin-left: auto; }
83
+ .icon-button {
84
+ align-items: center;
85
+ background: transparent;
86
+ border: 0;
87
+ border-radius: 8px;
88
+ color: var(--convai-chat-muted);
89
+ cursor: pointer;
90
+ display: inline-flex;
91
+ height: 34px;
92
+ justify-content: center;
93
+ padding: 0;
94
+ width: 34px;
95
+ }
96
+ .icon-button:hover:not(:disabled) { background: var(--convai-chat-raised); color: var(--convai-chat-text); }
97
+ .icon-button:disabled { cursor: default; opacity: .4; }
98
+ .icon-button svg { height: 17px; width: 17px; }
99
+
100
+ .messages { display: flex; flex-direction: column; gap: 14px; overflow-y: auto; padding: 20px 18px; }
101
+
102
+ .intro { align-items: center; align-self: center; display: flex; flex-direction: column; gap: 8px; margin: auto 0; max-width: 44ch; text-align: center; }
103
+ .intro .avatar { height: 56px; width: 56px; }
104
+ .intro-name { font-size: 16px; font-weight: 600; }
105
+ .intro-description { color: var(--convai-chat-muted); font-size: 13px; margin: 0; }
106
+
107
+ .row { align-items: flex-end; display: flex; gap: 10px; max-width: 82%; }
108
+ .row.user { align-self: flex-end; flex-direction: row-reverse; }
109
+ .row.character { align-self: flex-start; }
110
+ .row .avatar { height: 26px; width: 26px; }
111
+ .bubble { border-radius: 14px; line-height: 1.5; overflow-wrap: anywhere; padding: 10px 14px; white-space: pre-wrap; }
112
+ .row.user .bubble { background: var(--convai-chat-user-bubble); border-bottom-right-radius: 4px; color: var(--convai-chat-user-text); }
113
+ .row.character .bubble { background: var(--convai-chat-raised); border-bottom-left-radius: 4px; }
114
+
115
+ .typing { display: inline-flex; gap: 4px; padding: 13px 14px; }
116
+ .typing span { animation: convai-blink 1.2s infinite; background: var(--convai-chat-muted); border-radius: 50%; height: 6px; width: 6px; }
117
+ .typing span:nth-child(2) { animation-delay: .2s; }
118
+ .typing span:nth-child(3) { animation-delay: .4s; }
119
+ @keyframes convai-blink { 0%, 80%, 100% { opacity: .25 } 40% { opacity: 1 } }
120
+
121
+ .error { color: var(--convai-chat-danger); font-size: 13px; margin: 0; padding: 0 18px 10px; }
45
122
  .retry { align-self: center; margin-top: 12px; }
123
+
124
+ footer { background: var(--convai-chat-surface); border-top: 1px solid var(--convai-chat-border); display: grid; gap: 8px; padding: 12px 18px 10px; }
125
+ form { align-items: center; background: var(--convai-chat-bg); border: 1px solid var(--convai-chat-border); border-radius: 999px; display: flex; gap: 8px; padding: 4px 4px 4px 16px; }
126
+ input { background: transparent; border: 0; color: var(--convai-chat-text); flex: 1; font: inherit; min-width: 0; outline: none; padding: 9px 0; }
127
+ /* The input drops its own outline, so the pill carries keyboard focus. */
128
+ form:focus-within { border-color: var(--convai-chat-accent); box-shadow: 0 0 0 2px var(--convai-chat-accent-strong); }
129
+ input::placeholder { color: var(--convai-chat-faint); }
130
+ button { background: var(--convai-chat-accent); border: 0; border-radius: 999px; color: var(--convai-chat-on-accent); cursor: pointer; font: inherit; font-weight: 600; padding: 9px 16px; }
131
+ button:disabled { cursor: default; opacity: .45; }
132
+ .send { align-items: center; border-radius: 50%; display: inline-flex; height: 38px; justify-content: center; padding: 0; width: 38px; }
133
+ .send svg { height: 17px; width: 17px; }
134
+ .interrupt { background: var(--convai-chat-raised); color: var(--convai-chat-text); padding: 8px 14px; }
135
+
136
+ .meta { align-items: center; color: var(--convai-chat-faint); display: flex; font-size: 11px; gap: 8px; justify-content: space-between; }
137
+ .session { background: transparent; border: 0; border-radius: 4px; color: var(--convai-chat-faint); cursor: pointer; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11px; font-weight: 400; padding: 2px 6px; }
138
+ .session:hover { background: var(--convai-chat-raised); color: var(--convai-chat-muted); }
139
+ .session[hidden] { display: none; }
140
+
46
141
  @media (max-width: 560px) {
47
142
  :host { min-height: 100dvh; }
48
143
  .shell { border: 0; border-radius: 0; }
49
144
  .messages { padding: 16px; }
145
+ .row { max-width: 90%; }
146
+ }
147
+ @media (prefers-reduced-motion: reduce) {
148
+ .typing span { animation: none; }
50
149
  }
51
150
  `;
52
151
  function errorText(error) {
@@ -54,6 +153,12 @@ function errorText(error) {
54
153
  ? error.message
55
154
  : "Published chat is temporarily unavailable";
56
155
  }
156
+ /** Short, copy-friendly form for a long opaque session id. */
157
+ function shortSessionId(sessionId) {
158
+ return sessionId.length > 12
159
+ ? `${sessionId.slice(0, 4)}…${sessionId.slice(-4)}`
160
+ : sessionId;
161
+ }
57
162
  export class ConvaiChatElement extends HTMLElement {
58
163
  constructor() {
59
164
  super(...arguments);
@@ -65,6 +170,11 @@ export class ConvaiChatElement extends HTMLElement {
65
170
  this.messages = [];
66
171
  this.descriptor = null;
67
172
  this.failureReported = false;
173
+ /** A session that could still produce a reply: set when the client is wired
174
+ * up, cleared when it ends. Not a wait flag — see `isAwaitingReply()`. */
175
+ this.sessionLive = false;
176
+ this.sessionId = null;
177
+ this.retryVisible = false;
68
178
  }
69
179
  connectedCallback() {
70
180
  if (this.mounted)
@@ -96,14 +206,24 @@ export class ConvaiChatElement extends HTMLElement {
96
206
  <h2>Character chat</h2>
97
207
  <p class="status" role="status">Preparing chat…</p>
98
208
  </div>
209
+ <div class="header-actions">
210
+ <button class="icon-button copy-chat" type="button" title="Copy chat" aria-label="Copy chat" disabled>${COPY_ICON}</button>
211
+ <button class="icon-button reset-session" type="button" title="Reset session" aria-label="Reset session" disabled>${RESET_ICON}</button>
212
+ </div>
99
213
  </header>
100
- <div class="messages" aria-live="polite"><div class="empty">Preparing a private session…</div></div>
214
+ <div class="messages" aria-live="polite"></div>
101
215
  <p class="error" role="alert" hidden></p>
102
- <form>
103
- <input aria-label="Chat message" maxlength="4000" placeholder="Preparing chat…" disabled />
104
- <button class="interrupt" type="button" hidden>Stop</button>
105
- <button class="send" type="submit" disabled>Send</button>
106
- </form>
216
+ <footer>
217
+ <form>
218
+ <input aria-label="Chat message" maxlength="4000" placeholder="Preparing chat…" disabled />
219
+ <button class="interrupt" type="button" hidden>Stop</button>
220
+ <button class="send" type="submit" aria-label="Send" disabled>${SEND_ICON}</button>
221
+ </form>
222
+ <div class="meta">
223
+ <span class="powered">Powered by Convai</span>
224
+ <button class="session" type="button" title="Copy session ID" hidden></button>
225
+ </div>
226
+ </footer>
107
227
  </section>
108
228
  `;
109
229
  const input = root.querySelector("input");
@@ -128,23 +248,49 @@ export class ConvaiChatElement extends HTMLElement {
128
248
  });
129
249
  root.querySelector(".interrupt")?.addEventListener("click", () => {
130
250
  this.client?.sendInterruptMessage();
131
- this.setResponseActive(false);
251
+ this.setResponding(false);
252
+ this.finalizeOpenTurn();
253
+ });
254
+ root.querySelector(".copy-chat")?.addEventListener("click", () => {
255
+ void this.copyTranscript();
132
256
  });
257
+ root.querySelector(".reset-session")?.addEventListener("click", () => {
258
+ this.resetSession();
259
+ });
260
+ root.querySelector(".session")?.addEventListener("click", () => {
261
+ if (this.sessionId)
262
+ void this.writeToClipboard(this.sessionId);
263
+ });
264
+ this.renderDescriptor();
265
+ this.renderMessages();
133
266
  }
134
267
  async start() {
135
268
  this.controller?.abort();
136
269
  const controller = new AbortController();
137
270
  this.controller = controller;
138
- await this.cleanupClient();
139
- if (controller.signal.aborted || this.controller !== controller || !this.mounted)
140
- return;
271
+ // Retire the previous session before awaiting anything. `cleanupClient()`
272
+ // waits on the room teardown, and until that resolves the visitor would
273
+ // otherwise keep a live composer, a Stop control and a typing row driven
274
+ // by a client that has already gone.
141
275
  this.ready = false;
142
276
  this.failureReported = false;
143
277
  this.messages = [];
278
+ this.sessionLive = false;
279
+ this.setResponding(false);
280
+ this.retryVisible = false;
281
+ // A reused element may now point at a different publication. Drop the old
282
+ // descriptor so the header falls back to the host's seed (or the generic
283
+ // title) instead of showing the previous character until the fetch lands.
284
+ this.descriptor = null;
285
+ this.setSessionId(null);
286
+ this.renderDescriptor();
144
287
  this.renderMessages();
145
288
  this.setError("");
146
289
  this.setStatus("Preparing chat…");
147
290
  this.updateControls(false);
291
+ await this.cleanupClient();
292
+ if (controller.signal.aborted || this.controller !== controller || !this.mounted)
293
+ return;
148
294
  try {
149
295
  const publicationId = this.requireAttribute("publication-id");
150
296
  const publicationApiUrl = this.getAttribute("publication-api-url")?.trim();
@@ -157,6 +303,7 @@ export class ConvaiChatElement extends HTMLElement {
157
303
  return;
158
304
  this.descriptor = descriptor;
159
305
  this.renderDescriptor();
306
+ this.renderMessages();
160
307
  const client = new ConvaiClient();
161
308
  this.client = client;
162
309
  this.unsubs = [
@@ -165,8 +312,14 @@ export class ConvaiChatElement extends HTMLElement {
165
312
  this.renderMessages();
166
313
  }),
167
314
  client.on("stateChange", (state) => {
168
- this.setResponseActive(state.isThinking || state.isSpeaking);
315
+ this.setResponding(state.isThinking || state.isSpeaking);
169
316
  }),
317
+ client.on("characterSessionId", (sessionId) => {
318
+ this.setSessionId(sessionId);
319
+ }),
320
+ // The abstain marker in the transcript ends the wait on its own; this
321
+ // only retires the Stop control, which no frame lowers.
322
+ client.on("llmNoResponse", () => this.setResponding(false)),
170
323
  client.on("botReady", () => {
171
324
  this.ready = true;
172
325
  this.setStatus("Ready to chat");
@@ -174,6 +327,7 @@ export class ConvaiChatElement extends HTMLElement {
174
327
  this.dispatch("convai-ready");
175
328
  }),
176
329
  client.on("disconnect", () => {
330
+ this.endSession();
177
331
  this.setStatus("Session ended");
178
332
  this.updateControls(false);
179
333
  this.showRetry();
@@ -185,8 +339,16 @@ export class ConvaiChatElement extends HTMLElement {
185
339
  }
186
340
  }),
187
341
  ];
342
+ // Messages can arrive from here on, so the session is live before the
343
+ // connect resolves.
344
+ this.sessionLive = true;
188
345
  this.setStatus("Connecting…");
189
346
  await client.connectWithPublicationGrant(launchToken, { ...(coreUrl ? { url: coreUrl } : {}), signal: controller.signal });
347
+ // The published connect path assigns the character session id from the
348
+ // connection response without emitting `characterSessionId`, so the
349
+ // listener above never sees the initial value. Read it once here; the
350
+ // listener still covers later changes.
351
+ this.setSessionId(client.characterSessionId);
190
352
  if (!this.ready)
191
353
  this.setStatus("Connected; waiting for character…");
192
354
  }
@@ -201,6 +363,9 @@ export class ConvaiChatElement extends HTMLElement {
201
363
  return;
202
364
  this.failureReported = true;
203
365
  const message = errorText(error);
366
+ // cleanupClient() removes the state listener before disconnecting, so the
367
+ // session has to be closed out here or nothing else will.
368
+ this.endSession();
204
369
  this.setStatus("Unable to connect");
205
370
  this.setError(message);
206
371
  this.updateControls(false);
@@ -209,46 +374,195 @@ export class ConvaiChatElement extends HTMLElement {
209
374
  await this.cleanupClient();
210
375
  }
211
376
  async cleanupClient() {
377
+ this.sessionLive = false;
212
378
  this.unsubs.splice(0).forEach((unsubscribe) => unsubscribe());
213
379
  const client = this.client;
214
380
  this.client = null;
215
381
  if (client)
216
382
  await client.disconnect().catch(() => undefined);
217
383
  }
384
+ /**
385
+ * A host that already knows who the character is can seed the header through
386
+ * `display-name` and `image-url`, so the identity paints before the
387
+ * publication descriptor resolves. The fetched descriptor always wins.
388
+ */
389
+ displayName() {
390
+ if (this.descriptor)
391
+ return this.descriptor.display_name || "Character chat";
392
+ return this.getAttribute("display-name")?.trim() || "Character chat";
393
+ }
394
+ /** `null` is a valid descriptor value, so a loaded descriptor without an
395
+ * image clears the seed rather than falling back to it. */
396
+ imageUrl() {
397
+ if (this.descriptor)
398
+ return this.descriptor.image_url;
399
+ return this.getAttribute("image-url")?.trim() || null;
400
+ }
218
401
  renderDescriptor() {
219
402
  const root = this.shadowRoot;
220
- if (!root || !this.descriptor)
403
+ if (!root)
221
404
  return;
222
405
  const title = root.querySelector("h2");
223
406
  if (title)
224
- title.textContent = this.descriptor.display_name;
225
- const avatar = root.querySelector(".avatar");
226
- if (avatar && this.descriptor.image_url) {
227
- avatar.src = this.descriptor.image_url;
228
- avatar.alt = this.descriptor.display_name;
229
- avatar.hidden = false;
407
+ title.textContent = this.displayName();
408
+ const imageUrl = this.imageUrl();
409
+ root.querySelectorAll("header .avatar").forEach((avatar) => {
410
+ if (imageUrl) {
411
+ avatar.src = imageUrl;
412
+ avatar.alt = "";
413
+ avatar.hidden = false;
414
+ return;
415
+ }
416
+ avatar.removeAttribute("src");
417
+ avatar.hidden = true;
418
+ });
419
+ }
420
+ /**
421
+ * The character's own avatar, reused as the icon beside each of its replies.
422
+ * Without an image this is the character's initial on the accent disc, not a
423
+ * bare `<img>` — which the avatar rule would paint as an empty disc.
424
+ */
425
+ avatarElement() {
426
+ const imageUrl = this.imageUrl();
427
+ if (imageUrl) {
428
+ const avatar = document.createElement("img");
429
+ avatar.className = "avatar";
430
+ avatar.alt = "";
431
+ avatar.src = imageUrl;
432
+ return avatar;
230
433
  }
434
+ const fallback = document.createElement("span");
435
+ fallback.className = "avatar avatar-fallback";
436
+ fallback.setAttribute("aria-hidden", "true");
437
+ fallback.textContent = this.displayName().trim().charAt(0) || "?";
438
+ return fallback;
439
+ }
440
+ /**
441
+ * Whether the character owes a reply that has produced no text yet — the
442
+ * question the typing indicator asks.
443
+ *
444
+ * Derived from the message list on every render rather than tracked as
445
+ * state, because the list already records every event that starts or ends a
446
+ * wait: a send appends a `user-llm-text` row synchronously
447
+ * (`MessageHandler.addUserTextMessage`), a turn begins as an empty
448
+ * `bot-llm-text` row (`bot-llm-started`), the first token fills that row,
449
+ * and an abstain appends `llm-no-response`. Held as flags instead, each of
450
+ * those events needed its own handler and every one I missed was a bug:
451
+ * dots through an entire reply, no dots for a character-initiated greeting
452
+ * or a follow-up turn, dots forever after an abstain.
453
+ *
454
+ * The scan reads the unfiltered list: the rows that mark a turn's start and
455
+ * its abstain are exactly the ones the transcript hides. An empty bot row
456
+ * means the turn is open only while it is still streaming — `bot-llm-stopped`
457
+ * finalizes the row in place, so an empty finalized row is a turn that ended
458
+ * without text (an interrupt, or a stop before the first token).
459
+ */
460
+ isAwaitingReply() {
461
+ if (!this.sessionLive)
462
+ return false;
463
+ for (let index = this.messages.length - 1; index >= 0; index -= 1) {
464
+ const message = this.messages[index];
465
+ // The character ended its turn without saying anything.
466
+ if (message.type === "llm-no-response")
467
+ return false;
468
+ if (!VISIBLE_MESSAGE_TYPES.has(message.type))
469
+ continue;
470
+ if (message.type.startsWith("user"))
471
+ return true;
472
+ return Boolean(message.isStreaming) && !message.content?.trim();
473
+ }
474
+ return false;
475
+ }
476
+ /**
477
+ * Close the open turn in this element's own copy of the transcript.
478
+ *
479
+ * `sendInterruptMessage()` transmits the interrupt; it does not finalize the
480
+ * empty streaming row, which stays open until a `bot-llm-stopped` frame
481
+ * arrives — delayed, or never, if the acknowledgement is lost. The visitor
482
+ * asked the character to stop, so the wait ends with the click. A later
483
+ * `messagesChange` replaces this array wholesale, so nothing diverges.
484
+ */
485
+ finalizeOpenTurn() {
486
+ for (let index = this.messages.length - 1; index >= 0; index -= 1) {
487
+ const message = this.messages[index];
488
+ if (!VISIBLE_MESSAGE_TYPES.has(message.type))
489
+ continue;
490
+ if (message.type.startsWith("user"))
491
+ return;
492
+ if (message.isStreaming) {
493
+ this.messages[index] = { ...message, isStreaming: false };
494
+ this.renderMessages();
495
+ }
496
+ return;
497
+ }
498
+ }
499
+ visibleMessages() {
500
+ return this.messages.filter((message) => VISIBLE_MESSAGE_TYPES.has(message.type) && Boolean(message.content?.trim()));
231
501
  }
232
502
  renderMessages() {
233
503
  const container = this.shadowRoot?.querySelector(".messages");
234
504
  if (!container)
235
505
  return;
236
506
  container.replaceChildren();
237
- const visible = this.messages.filter((message) => VISIBLE_MESSAGE_TYPES.has(message.type) && Boolean(message.content?.trim()));
507
+ const visible = this.visibleMessages();
508
+ const awaiting = this.isAwaitingReply();
238
509
  if (visible.length === 0) {
239
- const empty = document.createElement("div");
240
- empty.className = "empty";
241
- empty.textContent = this.descriptor?.description || "Start a new private conversation.";
242
- container.appendChild(empty);
510
+ container.appendChild(this.introElement());
511
+ // A character-initiated greeting is an empty bot row the transcript
512
+ // hides, so the opening state can be waiting too.
513
+ if (awaiting)
514
+ container.appendChild(this.typingElement());
515
+ if (this.retryVisible)
516
+ container.appendChild(this.retryElement());
517
+ this.updateTranscriptControls();
243
518
  return;
244
519
  }
245
520
  visible.forEach((message) => {
246
- const item = document.createElement("div");
247
- item.className = `message ${message.type.startsWith("user") ? "user" : "character"}`;
248
- item.textContent = message.content;
249
- container.appendChild(item);
521
+ const isUser = message.type.startsWith("user");
522
+ const row = document.createElement("div");
523
+ row.className = `row ${isUser ? "user" : "character"}`;
524
+ if (!isUser)
525
+ row.appendChild(this.avatarElement());
526
+ const bubble = document.createElement("div");
527
+ bubble.className = "bubble";
528
+ bubble.textContent = message.content;
529
+ row.appendChild(bubble);
530
+ container.appendChild(row);
250
531
  });
532
+ if (awaiting)
533
+ container.appendChild(this.typingElement());
534
+ if (this.retryVisible)
535
+ container.appendChild(this.retryElement());
251
536
  container.scrollTop = container.scrollHeight;
537
+ this.updateTranscriptControls();
538
+ }
539
+ /** Opening state: who the visitor is talking to, not a bare instruction. */
540
+ introElement() {
541
+ const intro = document.createElement("div");
542
+ intro.className = "intro";
543
+ intro.appendChild(this.avatarElement());
544
+ const name = document.createElement("div");
545
+ name.className = "intro-name";
546
+ name.textContent = this.displayName();
547
+ intro.appendChild(name);
548
+ const description = document.createElement("p");
549
+ description.className = "intro-description";
550
+ description.textContent =
551
+ this.descriptor?.description?.trim() || "Send a message to start a private conversation.";
552
+ intro.appendChild(description);
553
+ return intro;
554
+ }
555
+ typingElement() {
556
+ const row = document.createElement("div");
557
+ row.className = "row character";
558
+ row.appendChild(this.avatarElement());
559
+ const typing = document.createElement("div");
560
+ typing.className = "bubble typing";
561
+ typing.setAttribute("aria-label", "Character is replying");
562
+ for (let index = 0; index < 3; index += 1)
563
+ typing.appendChild(document.createElement("span"));
564
+ row.appendChild(typing);
565
+ return row;
252
566
  }
253
567
  setStatus(message) {
254
568
  const status = this.shadowRoot?.querySelector(".status");
@@ -262,31 +576,96 @@ export class ConvaiChatElement extends HTMLElement {
262
576
  error.textContent = message;
263
577
  error.hidden = !message;
264
578
  }
579
+ setSessionId(sessionId) {
580
+ const assigned = sessionId && sessionId !== UNASSIGNED_SESSION_ID ? sessionId : null;
581
+ this.sessionId = assigned;
582
+ const button = this.shadowRoot?.querySelector(".session");
583
+ if (!button)
584
+ return;
585
+ button.hidden = !assigned;
586
+ button.textContent = assigned ? `Session ${shortSessionId(assigned)}` : "";
587
+ }
265
588
  updateControls(enabled) {
266
589
  const input = this.shadowRoot?.querySelector("input");
267
590
  const button = this.shadowRoot?.querySelector(".send");
591
+ const reset = this.shadowRoot?.querySelector(".reset-session");
268
592
  if (input) {
269
593
  input.disabled = !enabled;
270
- input.placeholder = enabled ? "Type a message…" : "Preparing chat…";
594
+ input.placeholder = enabled
595
+ ? `Message ${this.displayName()}…`
596
+ : "Preparing chat…";
271
597
  }
272
598
  if (button)
273
599
  button.disabled = !enabled || !input?.value.trim();
600
+ if (reset)
601
+ reset.disabled = !enabled;
602
+ this.updateTranscriptControls();
603
+ }
604
+ updateTranscriptControls() {
605
+ const copy = this.shadowRoot?.querySelector(".copy-chat");
606
+ if (copy)
607
+ copy.disabled = this.visibleMessages().length === 0;
274
608
  }
275
- setResponseActive(active) {
609
+ /** Shows or hides Stop. The typing indicator no longer rides on this. */
610
+ setResponding(responding) {
276
611
  const interrupt = this.shadowRoot?.querySelector(".interrupt");
277
612
  if (interrupt)
278
- interrupt.hidden = !active;
613
+ interrupt.hidden = !responding;
279
614
  }
280
- showRetry() {
281
- const messages = this.shadowRoot?.querySelector(".messages");
282
- if (!messages || messages.querySelector(".retry"))
615
+ /** The session can no longer produce a reply: stop waiting, stop offering
616
+ * Stop, and redraw so the transcript agrees. */
617
+ endSession() {
618
+ this.sessionLive = false;
619
+ this.setResponding(false);
620
+ this.renderMessages();
621
+ }
622
+ /** Same `Author: text` shape the playground's Copy chat produces. */
623
+ async copyTranscript() {
624
+ const characterName = this.displayName();
625
+ const transcript = this.visibleMessages()
626
+ .map((message) => `${message.type.startsWith("user") ? "User" : characterName}: ${message.content}`)
627
+ .join("\n");
628
+ if (transcript)
629
+ await this.writeToClipboard(transcript);
630
+ }
631
+ async writeToClipboard(value) {
632
+ try {
633
+ await navigator.clipboard.writeText(value);
634
+ this.dispatch("convai-copy", { value });
635
+ }
636
+ catch {
637
+ this.setError("Could not copy to the clipboard");
638
+ }
639
+ }
640
+ /**
641
+ * A published session is bound to the room it connected with: the character
642
+ * session id travels in the connect request, not in each `user_text_message`,
643
+ * so `ConvaiClient.resetSession()` would clear the transcript while the server
644
+ * kept the previous conversation. Reconnecting on a fresh launch grant is the
645
+ * only reset the published flow supports, and `start()` already tears the old
646
+ * client down before requesting one.
647
+ */
648
+ resetSession() {
649
+ if (!this.client?.state.isConnected)
283
650
  return;
651
+ this.dispatch("convai-session-reset");
652
+ void this.start();
653
+ }
654
+ retryElement() {
284
655
  const retry = document.createElement("button");
285
656
  retry.className = "retry";
286
657
  retry.type = "button";
287
658
  retry.textContent = "Try again";
288
659
  retry.addEventListener("click", () => void this.start(), { once: true });
289
- messages.appendChild(retry);
660
+ return retry;
661
+ }
662
+ /** Held as state, not a one-off append: a typing or message re-render would
663
+ * otherwise clear the only control left after a failed connection. */
664
+ showRetry() {
665
+ if (this.retryVisible)
666
+ return;
667
+ this.retryVisible = true;
668
+ this.renderMessages();
290
669
  }
291
670
  dispatch(name, detail) {
292
671
  this.dispatchEvent(new CustomEvent(name, { bubbles: true, composed: true, detail }));