@chat21/chat21-web-widget 5.1.33-rc9 → 5.1.34-rc1

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 (101) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/package.json +1 -1
  3. package/src/app/component/conversation-detail/conversation/conversation.component.ts +3 -1
  4. package/src/app/component/conversation-detail/conversation-content/conversation-content.component.spec.ts +0 -7
  5. package/src/app/component/conversation-detail/conversation-content/conversation-content.component.ts +7 -5
  6. package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.html +4 -3
  7. package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.scss +18 -18
  8. package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.ts +6 -0
  9. package/src/app/component/conversation-detail/stream-audio-spectrum/stream-audio-spectrum.component.html +8 -5
  10. package/src/app/component/conversation-detail/stream-audio-spectrum/stream-audio-spectrum.component.scss +5 -1
  11. package/src/app/component/form/inputs/form-text/form-text.component.ts +9 -3
  12. package/src/app/component/message/bubble-message/bubble-message.component.scss +5 -0
  13. package/src/app/component/message/bubble-message/bubble-message.component.ts +14 -0
  14. package/src/app/component/message/json-sources/json-sources.component.scss +12 -8
  15. package/src/app/pipe/marked.pipe.ts +51 -41
  16. package/src/app/providers/global-settings.service.ts +31 -0
  17. package/src/app/providers/json-sources-parser.service.ts +25 -32
  18. package/src/app/providers/voice/voice-streaming.service.ts +11 -19
  19. package/src/app/providers/voice/voice-streaming.types.ts +0 -1
  20. package/src/app/providers/voice/voice.service.spec.ts +12 -45
  21. package/src/app/providers/voice/voice.service.ts +215 -45
  22. package/src/app/utils/globals.ts +10 -0
  23. package/src/assets/i18n/en.json +106 -125
  24. package/src/assets/i18n/es.json +1 -0
  25. package/src/assets/i18n/fr.json +1 -0
  26. package/src/assets/i18n/it.json +1 -0
  27. package/src/assets/sounds/keyboard.mp3 +0 -0
  28. package/src/assets/twp/chatbot-panel.html +3 -1
  29. package/src/chat21-core/utils/utils-message.ts +15 -5
  30. package/src/widget-config-template.json +1 -0
  31. package/src/widget-config.json +30 -28
  32. package/.playwright-mcp/console-2026-05-08T15-31-09-000Z.log +0 -17
  33. package/.playwright-mcp/console-2026-05-08T15-32-19-412Z.log +0 -89
  34. package/.playwright-mcp/console-2026-05-08T16-18-48-424Z.log +0 -133
  35. package/.playwright-mcp/console-2026-05-11T12-54-06-869Z.log +0 -13
  36. package/.playwright-mcp/console-2026-05-11T12-54-56-229Z.log +0 -147
  37. package/.playwright-mcp/console-2026-05-11T12-55-47-174Z.log +0 -183
  38. package/.playwright-mcp/console-2026-05-11T15-34-03-590Z.log +0 -210
  39. package/.playwright-mcp/console-2026-05-12T15-07-31-880Z.log +0 -118
  40. package/.playwright-mcp/page-2026-05-08T15-32-19-900Z.yml +0 -851
  41. package/.playwright-mcp/page-2026-05-08T15-32-47-264Z.yml +0 -857
  42. package/.playwright-mcp/page-2026-05-08T15-33-17-089Z.yml +0 -1110
  43. package/.playwright-mcp/page-2026-05-08T15-33-23-486Z.yml +0 -1069
  44. package/.playwright-mcp/page-2026-05-08T15-33-45-390Z.yml +0 -1076
  45. package/.playwright-mcp/page-2026-05-08T15-33-52-666Z.yml +0 -1072
  46. package/.playwright-mcp/page-2026-05-08T15-34-01-338Z.yml +0 -1085
  47. package/.playwright-mcp/page-2026-05-08T15-34-07-227Z.yml +0 -1072
  48. package/.playwright-mcp/page-2026-05-08T15-34-13-875Z.yml +0 -1072
  49. package/.playwright-mcp/page-2026-05-08T15-34-21-885Z.yml +0 -1109
  50. package/.playwright-mcp/page-2026-05-08T15-34-32-755Z.yml +0 -1109
  51. package/.playwright-mcp/page-2026-05-08T15-35-09-607Z.yml +0 -1119
  52. package/.playwright-mcp/page-2026-05-08T15-35-14-242Z.yml +0 -1109
  53. package/.playwright-mcp/page-2026-05-08T16-18-48-671Z.yml +0 -44
  54. package/.playwright-mcp/page-2026-05-08T16-18-52-753Z.png +0 -0
  55. package/.playwright-mcp/page-2026-05-08T16-19-13-919Z.yml +0 -68
  56. package/.playwright-mcp/page-2026-05-08T16-19-17-977Z.png +0 -0
  57. package/.playwright-mcp/page-2026-05-08T16-19-25-733Z.yml +0 -120
  58. package/.playwright-mcp/page-2026-05-08T16-19-29-252Z.png +0 -0
  59. package/.playwright-mcp/page-2026-05-08T16-19-39-269Z.yml +0 -80
  60. package/.playwright-mcp/page-2026-05-08T16-19-43-915Z.png +0 -0
  61. package/.playwright-mcp/page-2026-05-08T16-20-04-407Z.yml +0 -81
  62. package/.playwright-mcp/page-2026-05-08T16-20-08-984Z.png +0 -0
  63. package/.playwright-mcp/page-2026-05-08T16-20-32-397Z.png +0 -0
  64. package/.playwright-mcp/page-2026-05-08T16-20-58-658Z.png +0 -0
  65. package/.playwright-mcp/page-2026-05-08T16-21-12-320Z.yml +0 -86
  66. package/.playwright-mcp/page-2026-05-08T16-21-39-154Z.yml +0 -91
  67. package/.playwright-mcp/page-2026-05-08T16-21-45-420Z.png +0 -0
  68. package/.playwright-mcp/page-2026-05-08T16-22-21-062Z.yml +0 -0
  69. package/.playwright-mcp/page-2026-05-08T16-22-58-232Z.yml +0 -91
  70. package/.playwright-mcp/page-2026-05-08T16-23-36-520Z.yml +0 -0
  71. package/.playwright-mcp/page-2026-05-08T16-23-46-805Z.yml +0 -100
  72. package/.playwright-mcp/page-2026-05-08T16-23-55-169Z.png +0 -0
  73. package/.playwright-mcp/page-2026-05-08T16-24-26-574Z.yml +0 -91
  74. package/.playwright-mcp/page-2026-05-08T16-25-34-414Z.png +0 -0
  75. package/.playwright-mcp/page-2026-05-08T16-25-59-831Z.png +0 -0
  76. package/.playwright-mcp/page-2026-05-08T16-26-21-809Z.yml +0 -91
  77. package/.playwright-mcp/page-2026-05-08T16-26-47-443Z.yml +0 -105
  78. package/.playwright-mcp/page-2026-05-08T16-26-56-136Z.png +0 -0
  79. package/.playwright-mcp/page-2026-05-08T16-27-59-610Z.yml +0 -48
  80. package/.playwright-mcp/page-2026-05-11T12-54-07-180Z.yml +0 -44
  81. package/.playwright-mcp/page-2026-05-11T12-54-56-946Z.yml +0 -4
  82. package/.playwright-mcp/page-2026-05-11T12-55-47-503Z.yml +0 -24
  83. package/.playwright-mcp/page-2026-05-11T12-56-00-766Z.yml +0 -28
  84. package/.playwright-mcp/page-2026-05-11T12-56-06-438Z.yml +0 -90
  85. package/.playwright-mcp/page-2026-05-11T12-57-56-838Z.yml +0 -106
  86. package/.playwright-mcp/page-2026-05-11T12-58-00-124Z.yml +0 -106
  87. package/.playwright-mcp/page-2026-05-11T12-59-08-836Z.yml +0 -61
  88. package/.playwright-mcp/page-2026-05-11T12-59-12-088Z.yml +0 -61
  89. package/.playwright-mcp/page-2026-05-11T12-59-26-215Z.yml +0 -69
  90. package/.playwright-mcp/page-2026-05-11T12-59-29-519Z.yml +0 -69
  91. package/.playwright-mcp/page-2026-05-11T12-59-37-309Z.yml +0 -0
  92. package/.playwright-mcp/page-2026-05-11T12-59-39-968Z.yml +0 -79
  93. package/.playwright-mcp/page-2026-05-11T12-59-45-983Z.yml +0 -78
  94. package/.playwright-mcp/page-2026-05-11T12-59-49-951Z.yml +0 -78
  95. package/.playwright-mcp/page-2026-05-11T15-34-04-515Z.yml +0 -0
  96. package/.playwright-mcp/page-2026-05-12T15-07-32-171Z.yml +0 -44
  97. package/.playwright-mcp/page-2026-05-12T15-08-09-820Z.yml +0 -119
  98. package/docs/TILEDESK_WIDGET_ACCESSIBILITY_STATEMENT_COMPLETE.md +0 -379
  99. package/playwright-report/index.html +0 -90
  100. package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component copy.html +0 -172
  101. package/test-results/.last-run.json +0 -4
@@ -7,25 +7,19 @@ import { mergeJsonSourcesMissingFields } from 'src/app/utils/json-sources-utils'
7
7
 
8
8
  export type UrlPreviewMessage = {
9
9
  type?: string; // "url_preview"
10
- activeMode?: 'form' | 'list' | 'text' | string;
11
- form?: { sources?: any[] };
12
- list?: string;
13
10
  text?: string;
14
11
  };
15
12
 
16
13
  /**
17
14
  * Parse and enrich "url_preview" messages into `JsonSourceItem[]`.
18
15
  *
19
- * This service is intentionally isolated so it can be replaced/removed easily.
20
- *
21
16
  * Rules:
22
- * - It expects the full url_preview message object: `{ type: 'url_preview', activeMode: 'form'|'list'|'text', ... }`
23
- * - `activeMode` selects the source field to use:
24
- * - `form`: reads `msg.form.sources` (array of `{source_name, source_file_name, ...}`)
25
- * - `list`: reads `msg.list` (free text) and extracts URLs (max 10)
26
- * - `text`: reads `msg.text` (a JSON array string with the same schema as `form.sources`)
27
- * - After building the initial array, it calls `url-preview` only for items that miss title or description,
28
- * and merges the missing fields only (never overwriting existing values).
17
+ * - The payload is always read from `msg.text`, regardless of `activeMode`.
18
+ * - `msg.text` may be either:
19
+ * - a JSON array of source objects (`{source_name, source_file_name, ...}`), or
20
+ * - a plain string from which URLs are extracted (split by whitespace/punctuation).
21
+ * - After building the initial array, `url-preview` is called only for items that miss
22
+ * title or description, and missing fields are merged in (never overwriting).
29
23
  */
30
24
  @Injectable({ providedIn: 'root' })
31
25
  export class JsonSourcesParserService {
@@ -98,23 +92,11 @@ export class JsonSourcesParserService {
98
92
  private parseBaseJsonSources(msg?: UrlPreviewMessage | null): JsonSourceItem[] | null {
99
93
  if (!msg || msg.type !== 'url_preview') return null;
100
94
 
101
- const mode = (msg.activeMode || '').toString().trim();
102
- const normalizedMode = mode === 'json_sources' ? 'form' : mode; // backward compat
103
-
104
- if (normalizedMode === 'form') {
105
- return this.mapSourcesArray(msg.form?.sources);
106
- }
107
- if (normalizedMode === 'list') {
108
- return this.mapListToSources(msg.list);
109
- }
110
- if (normalizedMode === 'text') {
111
- return this.mapTextToSources(msg.text);
112
- }
113
-
114
- // best-effort fallback order
115
- return this.mapSourcesArray(msg.form?.sources)
116
- || this.mapTextToSources(msg.text)
117
- || this.mapListToSources(msg.list);
95
+ // Regardless of `activeMode`, the payload is always read from `msg.text`.
96
+ // It can be either a JSON array of source objects, or a plain string with URLs.
97
+ return this.isJsonArrayOfObjects(msg.text)
98
+ ? this.mapTextToSources(msg.text)
99
+ : this.mapListToSources(msg.text);
118
100
  }
119
101
 
120
102
  private mapListToSources(listValue?: string): JsonSourceItem[] | null {
@@ -122,6 +104,16 @@ export class JsonSourcesParserService {
122
104
  return urls.length ? urls.map(u => ({ link: u, title: u })) : null;
123
105
  }
124
106
 
107
+ private isJsonArrayOfObjects(text?: string): boolean {
108
+ if (!text) return false;
109
+ try {
110
+ const parsed = this.parseJsonLenient(text);
111
+ return Array.isArray(parsed) && parsed.some(it => it && typeof it === 'object' && !Array.isArray(it));
112
+ } catch {
113
+ return false;
114
+ }
115
+ }
116
+
125
117
  private mapTextToSources(text?: string): JsonSourceItem[] | null {
126
118
  if (!text) return null;
127
119
  try {
@@ -137,9 +129,10 @@ export class JsonSourcesParserService {
137
129
  if (!arr || arr.length === 0) return null;
138
130
  const mapped = arr
139
131
  .filter((s: any) => s && typeof s === 'object' && typeof s[JSON_SOURCE_FIELD_URL] === 'string')
140
- .map((s: any): JsonSourceItem => {
132
+ .map((s: any): JsonSourceItem | null => {
141
133
  const rawUrl = (s[JSON_SOURCE_FIELD_URL] || '').toString().trim();
142
- const normalized = extractUrlsFromText(rawUrl, 1)[0] || rawUrl;
134
+ const normalized = extractUrlsFromText(rawUrl, 1)[0];
135
+ if (!normalized) return null;
143
136
  return {
144
137
  link: normalized,
145
138
  title: (s[JSON_SOURCE_FIELD_TITLE] || rawUrl).toString(),
@@ -147,7 +140,7 @@ export class JsonSourcesParserService {
147
140
  image: typeof s.source_image === 'string' ? s.source_image : undefined
148
141
  };
149
142
  })
150
- .filter((x: JsonSourceItem) => !!x.link);
143
+ .filter((x: JsonSourceItem | null): x is JsonSourceItem => !!x && !!x.link);
151
144
  return mapped.length ? mapped : null;
152
145
  }
153
146
 
@@ -13,10 +13,11 @@ import {
13
13
  VoiceWsControlMessage,
14
14
  } from './voice-streaming.types';
15
15
 
16
- // Flux docs recommend 80ms chunks for optimal latency; 250ms is a practical
17
- // balance for WebM containerization overhead in the browser.
16
+ // Flux docs recommend 80ms chunks for optimal latency; 160ms is a practical
17
+ // balance for WebM containerization overhead in the browser while providing
18
+ // good STT accuracy.
18
19
  // Source: https://developers.deepgram.com/docs/flux/quickstart
19
- const DEFAULT_TIMESLICE_MS = 250;
20
+ const DEFAULT_TIMESLICE_MS = 160;
20
21
  const READY_TIMEOUT_MS = 10_000;
21
22
  const SESSION_STARTED_TIMEOUT_MS = 10_000;
22
23
 
@@ -258,6 +259,12 @@ export class VoiceStreamingService {
258
259
  this.mediaStream = shared
259
260
  ? shared
260
261
  : await navigator.mediaDevices.getUserMedia({ audio: true });
262
+ const tracks = this.mediaStream.getAudioTracks();
263
+ this.logger.info('[VoiceStreaming] microphone acquired', {
264
+ shared: !!shared,
265
+ tracks: tracks.length,
266
+ label: tracks[0]?.label ?? '(unknown)',
267
+ });
261
268
  const recorderOpts: MediaRecorderOptions = {};
262
269
  if (mime) {
263
270
  recorderOpts.mimeType = mime;
@@ -571,7 +578,7 @@ export class VoiceStreamingService {
571
578
 
572
579
  /**
573
580
  * Send `{ event: "tts_playback_complete" }` to the proxy, signalling that TTS
574
- * playback has finished and the microphone is ready to receive user speech.
581
+ * playback has finished and the microphone is now safe to receive user speech.
575
582
  */
576
583
  sendPlaybackComplete(): void {
577
584
  if (this.ws?.readyState === WebSocket.OPEN) {
@@ -580,21 +587,6 @@ export class VoiceStreamingService {
580
587
  }
581
588
  }
582
589
 
583
- /**
584
- * Send `{ event: "barge_in" }` to the proxy, requesting an immediate interruption
585
- * of the ongoing TTS playback. Use when the user explicitly wants to speak while
586
- * the bot is talking (e.g. via a UI button or a client-side VAD onset).
587
- *
588
- * The proxy will stop the TTS stream and transition to LISTENING; the widget should
589
- * handle the server-sent `barge_in` and `listening` events to update local state.
590
- */
591
- sendBargeIn(): void {
592
- if (this.ws?.readyState === WebSocket.OPEN) {
593
- this.ws.send(JSON.stringify({ event: 'barge_in' }));
594
- this.logger.info('[VoiceStreaming] barge_in sent');
595
- }
596
- }
597
-
598
590
  private cleanup(): void {
599
591
  this.logger.info('[VoiceStreaming] cleanup', { state: this._currentState, sessionId: this.currentSessionId });
600
592
  this.audioChunkCount = 0;
@@ -86,7 +86,6 @@ export type VoiceWsServerEventName =
86
86
  | 'thinking'
87
87
  | 'speaking'
88
88
  | 'done'
89
- | 'barge_in'
90
89
  | 'error';
91
90
 
92
91
  /** Messaggio di controllo JSON dal proxy (`msg.event`); altri campi sono ignorati se non gestiti. */
@@ -44,7 +44,7 @@ describe('VoiceService', () => {
44
44
 
45
45
  voiceStreamingMock = jasmine.createSpyObj<VoiceStreamingService>(
46
46
  'VoiceStreamingService',
47
- ['start', 'stop', 'setAudioMuted', 'sendPlaybackComplete', 'sendBargeIn'],
47
+ ['start', 'stop', 'setAudioMuted', 'sendPlaybackComplete', 'pauseRecording', 'resumeRecording'],
48
48
  );
49
49
  voiceStreamingMock.start.and.returnValue(Promise.resolve());
50
50
  voiceStreamingMock.stop.and.returnValue(
@@ -65,6 +65,8 @@ describe('VoiceService', () => {
65
65
  ],
66
66
  });
67
67
  service = TestBed.inject(VoiceService);
68
+ spyOn(service as any, '_startKeyboardSound').and.stub();
69
+ spyOn(service as any, '_stopKeyboardSound').and.stub();
68
70
  });
69
71
 
70
72
  // ── Existing session lifecycle tests ──────────────────────────────────────
@@ -156,22 +158,21 @@ describe('VoiceService', () => {
156
158
  expect(voiceStreamingMock.setAudioMuted).not.toHaveBeenCalled();
157
159
  });
158
160
 
159
- it('empty-audio path: sendPlaybackComplete immediately but acquisition stays blocked until "listening"', async () => {
161
+ it('empty-audio path: sendPlaybackComplete after flush but acquisition stays blocked until "listening"', async () => {
160
162
  const blocked = await startWssSession();
161
163
  const initialLen = blocked.length;
162
164
 
163
- // Simulate done arriving with NO binary audio (_activeTtsSources === 0)
165
+ // done with no binary audio arms unblock; flush sends playback complete to proxy
164
166
  wsControl$.next({ event: 'speaking', text: 'hello' } as VoiceWsControlMessage);
165
167
  wsControl$.next({ event: 'done' } as VoiceWsControlMessage);
166
168
 
167
- // Proxy signalled immediately
169
+ expect(voiceStreamingMock.sendPlaybackComplete).not.toHaveBeenCalled();
170
+ (service as any)._flushTtsUnblock(false);
168
171
  expect(voiceStreamingMock.sendPlaybackComplete).toHaveBeenCalledTimes(1);
169
172
 
170
- // Acquisition must still be blocked — proxy hasn't confirmed LISTENING yet
171
173
  const afterDone = blocked.slice(initialLen);
172
174
  expect(afterDone.every((v) => v === true)).toBeTrue();
173
175
 
174
- // Unblock only after proxy confirms
175
176
  wsControl$.next({ event: 'listening' } as VoiceWsControlMessage);
176
177
  expect(blocked[blocked.length - 1]).toBeFalse();
177
178
  });
@@ -189,22 +190,19 @@ describe('VoiceService', () => {
189
190
 
190
191
  // ── Audio preemption tests (SPEC-002) ────────────────────────────────────
191
192
 
192
- it('second "speaking" cancels first audio: sendPlaybackComplete called exactly once for the new turn', async () => {
193
+ it('second "speaking" cancels first audio: sendPlaybackComplete only after flush for the new turn', async () => {
193
194
  await startWssSession();
194
195
  voiceStreamingMock.sendPlaybackComplete.calls.reset();
195
196
 
196
- // First turn: audio chunk arrives → _activeTtsSources = 1 (sync) → done sets _unblockAfterTts
197
197
  wsControl$.next({ event: 'speaking', text: 'first' } as VoiceWsControlMessage);
198
- ttsBinaryChunk$.next(new ArrayBuffer(4)); // _activeTtsSources++ synchronously
199
- wsControl$.next({ event: 'done' } as VoiceWsControlMessage); // _unblockAfterTts = true
198
+ ttsBinaryChunk$.next(new ArrayBuffer(4));
199
+ wsControl$.next({ event: 'done' } as VoiceWsControlMessage);
200
200
 
201
- // Second turn preempts while first audio is still "playing"
202
201
  wsControl$.next({ event: 'speaking', text: 'second' } as VoiceWsControlMessage);
203
- // _cancelAllTtsAudio() resets _activeTtsSources=0, _unblockAfterTts=false
204
-
205
- // done with no audio → sendPlaybackComplete immediately (new turn, _activeTtsSources = 0)
206
202
  wsControl$.next({ event: 'done' } as VoiceWsControlMessage);
207
203
 
204
+ expect(voiceStreamingMock.sendPlaybackComplete).not.toHaveBeenCalled();
205
+ (service as any)._flushTtsUnblock(false);
208
206
  expect(voiceStreamingMock.sendPlaybackComplete).toHaveBeenCalledTimes(1);
209
207
  });
210
208
 
@@ -226,35 +224,4 @@ describe('VoiceService', () => {
226
224
  expect(voiceStreamingMock.sendPlaybackComplete).not.toHaveBeenCalled();
227
225
  });
228
226
 
229
- // ── Barge-in ──────────────────────────────────────────────────────────────
230
-
231
- it('barge_in event cancels TTS audio and unblocks acquisition without sending tts_playback_complete', async () => {
232
- await startWssSession();
233
- voiceStreamingMock.sendPlaybackComplete.calls.reset();
234
-
235
- // Simulate bot speaking with audio in flight
236
- wsControl$.next({ event: 'speaking', text: 'hello' } as VoiceWsControlMessage);
237
- ttsBinaryChunk$.next(new ArrayBuffer(4)); // _activeTtsSources++ synchronously
238
- wsControl$.next({ event: 'done' } as VoiceWsControlMessage); // _unblockAfterTts = true
239
-
240
- // Proxy detects user speech and sends barge_in
241
- wsControl$.next({ event: 'barge_in' } as VoiceWsControlMessage);
242
-
243
- // tts_playback_complete must NOT be sent — it was an interruption, not a completion
244
- expect(voiceStreamingMock.sendPlaybackComplete).not.toHaveBeenCalled();
245
- expect(voiceStreamingMock.setAudioMuted).not.toHaveBeenCalled();
246
- expect((service as any)._isAcquisitionBlocked$.getValue()).toBe(false);
247
- });
248
-
249
- it('barge_in while no TTS is active does not throw and still unblocks acquisition', async () => {
250
- await startWssSession();
251
- voiceStreamingMock.sendPlaybackComplete.calls.reset();
252
-
253
- // No speaking event — mic was never muted
254
- expect(() => {
255
- wsControl$.next({ event: 'barge_in' } as VoiceWsControlMessage);
256
- }).not.toThrow();
257
-
258
- expect(voiceStreamingMock.sendPlaybackComplete).not.toHaveBeenCalled();
259
- });
260
227
  });