@chat21/chat21-web-widget 5.1.32-rc8 → 5.1.32-rc9

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/CHANGELOG.md CHANGED
@@ -6,6 +6,14 @@
6
6
  ### **Copyrigth**:
7
7
  *Tiledesk SRL*
8
8
 
9
+ # 5.1.32-rc9
10
+ - **added**: mic-triggered TTS interruption — when VAD detects user speech, stop current TTS playback, clear the queue, and reveal the full message text
11
+ - **added**: global TTS cancel API (`TtsAudioPlaybackCoordinator.cancelAll()` + `cancelAll$`) to stop current + queued TTS playback from UI/events (e.g. close stream)
12
+ - **changed**: `chat-audio-sync` TTS playback now streams audio via authenticated POST to `message.metadata.src`, sending `voiceSettings` + `text` and `streaming: true`
13
+ - **changed**: stream UI spectrum — removed circular orb and stretched the spectrum line to fill the `#streamAudioAlert` width with 10px side padding
14
+ - **changed**: conversation content layout while streaming — adjusted received bubble left margin and loading spinner margins for full-size mode
15
+
16
+
9
17
  # 5.1.32-rc8
10
18
  - **changed**: updated the dev environment defaults to align with the stage setup (remote config URL, API endpoints, logging level, storage prefix, and related settings)
11
19
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@chat21/chat21-web-widget",
3
3
  "author": "Tiledesk SRL",
4
- "version": "5.1.32-rc8",
4
+ "version": "5.1.32-rc9",
5
5
  "license": "MIT",
6
6
  "homepage": "https://www.tiledesk.com",
7
7
  "repository": {
@@ -62,7 +62,8 @@
62
62
  [ngClass]="{'slide-in-left': false}"
63
63
  [class.no-background]="(isImage(message) || isFrame(message) || isCarousel(message)) && ((message?.text && message?.text.trim() === '') || !message?.text)"
64
64
  [class.emoticon]="isEmojii(message?.text)"
65
- [style.margin-left]="isStreamAudioActive ? '0px' : (isSameSender(message?.sender, i) ? 'calc(var(--avatar-width) + 10px)' : null)"
65
+ [class.fullSizeMessage]="isStreamAudioActive"
66
+ [style.margin-left]="isSameSender(message?.sender, i) ? 'calc(var(--avatar-width) + 10px)' : null"
66
67
  [ngStyle]="{'background': stylesMap.get('bubbleReceivedBackground'), 'color': stylesMap.get('bubbleReceivedTextColor'), 'width':isFrame(message) ?'100%' : null}"
67
68
  [isSameSender]="isSameSender(message?.sender, i)"
68
69
  [message]="message"
@@ -134,9 +135,10 @@
134
135
  [senderFullname]="nameUserTypingNow"
135
136
  [baseLocation]="baseLocation">
136
137
  </chat-avatar-image>
138
+
137
139
  <user-typing
138
- [ngClass]="{'userTypingNowExist': !idUserTypingNow}"
139
140
  [color]="stylesMap?.get('iconColor')"
141
+ [ngClass]="{'userTypingNowExist': !idUserTypingNow}"
140
142
  [translationMap]="translationMap"
141
143
  [idUserTypingNow]="idUserTypingNow"
142
144
  [nameUserTypingNow]="nameUserTypingNow">
@@ -145,6 +147,7 @@
145
147
 
146
148
  <div *ngIf="showThinkingMessage && lastServerSenderKind === 'bot'" class="msg_container base_receive thinking_receive">
147
149
  <user-typing class="loading thinking-dots"
150
+ [class.fullSize]="isStreamAudioActive"
148
151
  [color]="stylesMap?.get('iconColor')"
149
152
  [translationMap]="translationMap"
150
153
  [idUserTypingNow]="idUserTypingNow"
@@ -27,6 +27,11 @@
27
27
  margin: 25px 50px
28
28
  }
29
29
 
30
+
31
+ :host .loading.fullSize ::ng-deep > div.spinner{
32
+ margin: 50px 0px !important;
33
+ }
34
+
30
35
  // ============= CSS c21-body ================= //
31
36
  .c21-body {
32
37
  // -webkit-box-shadow: inset 0 10px 10px -10px rgba(0,0,0,0.4);
@@ -236,6 +241,11 @@
236
241
  height: fit-content;
237
242
  width: auto;
238
243
 
244
+ &.fullSizeMessage {
245
+ max-width: 100%;
246
+ margin: auto 0 auto 0 !important;
247
+ }
248
+
239
249
  }
240
250
 
241
251
 
@@ -17,6 +17,7 @@ import { findAndRemoveEmoji, isImage } from 'src/chat21-core/utils/utils-message
17
17
  import { ProjectModel } from 'src/models/project';
18
18
  import { Subscription } from 'rxjs';
19
19
  import { VoiceService } from 'src/app/providers/voice/voice.service';
20
+ import { TtsAudioPlaybackCoordinator } from 'src/app/providers/tts-audio-playback-coordinator.service';
20
21
 
21
22
  @Component({
22
23
  selector: 'chat-conversation-footer',
@@ -110,7 +111,8 @@ export class ConversationFooterComponent implements OnInit, OnChanges, OnDestroy
110
111
  constructor(private chatManager: ChatManager,
111
112
  private typingService: TypingService,
112
113
  private uploadService: UploadService,
113
- private voiceService: VoiceService) { }
114
+ private voiceService: VoiceService,
115
+ private ttsPlayback: TtsAudioPlaybackCoordinator) { }
114
116
 
115
117
  ngOnInit() {
116
118
  // this.updateAttachmentTooltip();
@@ -120,6 +122,7 @@ export class ConversationFooterComponent implements OnInit, OnChanges, OnDestroy
120
122
  if(changes['conversationWith'] && changes['conversationWith'].currentValue !== undefined){
121
123
  this.conversationHandlerService = this.chatManager.getConversationHandlerByConversationId(this.conversationWith);
122
124
  this.isStreamAudioActive = false;
125
+ this.ttsPlayback.cancelAll();
123
126
  void this.stopVoice();
124
127
  }
125
128
  if(changes['hideTextReply'] && changes['hideTextReply'].currentValue !== undefined){
@@ -740,10 +743,13 @@ export class ConversationFooterComponent implements OnInit, OnChanges, OnDestroy
740
743
  } catch (e) {
741
744
  this.logger.error('[CONV-FOOTER] onStreamPressed: initVoice failed', e);
742
745
  this.isStreamAudioActive = false;
746
+ this.ttsPlayback.cancelAll();
743
747
  }
744
748
  } else {
745
749
  await this.stopVoice();
746
750
  this.isStreamAudioActive = false;
751
+ // Close-stream-button clicked: stop any playing/queued TTS audio.
752
+ this.ttsPlayback.cancelAll();
747
753
  }
748
754
  this.onStreamAudioActiveChange.emit(this.isStreamAudioActive);
749
755
  this.logger.log('[CONV-FOOTER] isStreamAudioActive', this.isStreamAudioActive);
@@ -1,13 +1,12 @@
1
- <div class="stream-audio-spectrum__orb" [ngStyle]="accentColor ? { color: accentColor } : null">
2
- <svg class="stream-audio-spectrum__svg" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
1
+ <div class="stream-audio-spectrum" [ngStyle]="accentColor ? { color: accentColor } : null">
2
+ <svg class="stream-audio-spectrum__svg" viewBox="0 0 100 32" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
3
3
  <defs>
4
- <linearGradient [attr.id]="gradientId" x1="12" y1="50" x2="88" y2="50" gradientUnits="userSpaceOnUse">
4
+ <linearGradient [attr.id]="gradientId" x1="0" y1="16" x2="100" y2="16" gradientUnits="userSpaceOnUse">
5
5
  <stop offset="0%" stop-color="currentColor" stop-opacity="0.45"/>
6
6
  <stop offset="50%" stop-color="currentColor" stop-opacity="1"/>
7
7
  <stop offset="100%" stop-color="currentColor" stop-opacity="0.45"/>
8
8
  </linearGradient>
9
9
  </defs>
10
- <circle cx="50" cy="50" r="46" fill="currentColor" opacity="0.14"/>
11
10
  <path class="stream-audio-spectrum__line"
12
11
  [attr.d]="spectrumLinePath"
13
12
  fill="none"
@@ -1,22 +1,21 @@
1
1
  :host {
2
2
  display: block;
3
+ width: 100%;
4
+ flex: 1 1 auto;
3
5
  }
4
6
 
5
- .stream-audio-spectrum__orb {
7
+ .stream-audio-spectrum {
6
8
  display: flex;
7
9
  align-items: center;
8
10
  justify-content: center;
9
- width: 88px;
10
- height: 88px;
11
- border-radius: 50%;
12
- border: 2px solid currentColor;
13
- background: var(--content-background-color);
14
- box-shadow: inset 0 0 24px rgba(0, 0, 0, 0.04);
11
+ width: 100%;
12
+ padding: 0 10px;
13
+ box-sizing: border-box;
15
14
  }
16
15
 
17
16
  .stream-audio-spectrum__svg {
18
- width: 72px;
19
- height: 72px;
17
+ width: 100%;
18
+ height: 32px;
20
19
  display: block;
21
20
  }
22
21
 
@@ -18,7 +18,7 @@ export class StreamAudioSpectrumComponent implements OnInit, OnChanges {
18
18
  /** Colore tema (stroke / gradient); opzionale. */
19
19
  @Input() accentColor?: string;
20
20
 
21
- spectrumLinePath = 'M12,50 L88,50';
21
+ spectrumLinePath = 'M0,16 L100,16';
22
22
 
23
23
  ngOnInit(): void {
24
24
  this.refreshPath();
@@ -37,11 +37,11 @@ export class StreamAudioSpectrumComponent implements OnInit, OnChanges {
37
37
  }
38
38
 
39
39
  private buildSpectrumLinePath(intensity: number, t: number): string {
40
- const x0 = 12;
41
- const x1 = 88;
42
- const cy = 50;
43
- const segments = 80;
44
- const amp = 1.2 + intensity * 16;
40
+ const x0 = 0;
41
+ const x1 = 100;
42
+ const cy = 16;
43
+ const segments = 100;
44
+ const amp = 0.8 + intensity * 6.5;
45
45
  const parts: string[] = [];
46
46
  for (let i = 0; i <= segments; i++) {
47
47
  const p = i / segments;
@@ -54,7 +54,7 @@ export class StreamAudioSpectrumComponent implements OnInit, OnChanges {
54
54
  Math.sin(u * 6.8 + t * 1.05) * 0.14 +
55
55
  Math.sin(u * 9.1 + t * 0.88) * 0.1;
56
56
  const y = cy + amp * wobble;
57
- const yClamped = Math.min(68, Math.max(32, y));
57
+ const yClamped = Math.min(30, Math.max(2, y));
58
58
  parts.push(i === 0 ? `M${x.toFixed(2)},${yClamped.toFixed(2)}` : `L${x.toFixed(2)},${yClamped.toFixed(2)}`);
59
59
  }
60
60
  return parts.join('');
@@ -17,7 +17,6 @@
17
17
  .lyrics {
18
18
  font-size: inherit;
19
19
  margin: 0;
20
- line-height: 1.4em;
21
20
  font-style: normal;
22
21
  letter-spacing: normal;
23
22
  font-stretch: normal;
@@ -9,9 +9,11 @@ import {
9
9
  SimpleChanges,
10
10
  ViewChild,
11
11
  } from '@angular/core';
12
+ import { Subscription } from 'rxjs';
12
13
  import { MessageModel } from 'src/chat21-core/models/message';
13
14
  import { TtsAudioPlaybackCoordinator } from 'src/app/providers/tts-audio-playback-coordinator.service';
14
15
  import { Globals } from 'src/app/utils/globals';
16
+ import { VoiceService } from 'src/app/providers/voice/voice.service';
15
17
 
16
18
  /** HAVE_METADATA: metadati già disponibili (tipico audio servito da cache). */
17
19
  const HAVE_METADATA = 1;
@@ -48,13 +50,17 @@ export class AudioSyncComponent implements AfterViewInit, OnChanges, OnDestroy {
48
50
  private destroyed = false;
49
51
  private playbackRequested = false;
50
52
  private playbackStarted = false;
53
+ private micInterrupted = false;
51
54
  private streamAbort?: AbortController;
52
55
  private mediaSourceObjectUrl?: string;
56
+ private cancelAllSub?: Subscription;
57
+ private micSpeechSub?: Subscription;
53
58
 
54
59
  constructor(
55
60
  private readonly cdr: ChangeDetectorRef,
56
61
  private readonly ttsPlayback: TtsAudioPlaybackCoordinator,
57
62
  private readonly globals: Globals,
63
+ private readonly voiceService: VoiceService,
58
64
  ) {}
59
65
 
60
66
  /** `false` = messaggio già in storico: niente autoplay / karaoke. Da `message.isJustRecived`. */
@@ -87,6 +93,28 @@ export class AudioSyncComponent implements AfterViewInit, OnChanges, OnDestroy {
87
93
  (this.message?.uid && String(this.message.uid).trim()) ||
88
94
  `tts-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
89
95
 
96
+ // Se l’utente parla al microfono mentre sta ascoltando, interrompi TUTTO (corrente + coda).
97
+ this.micSpeechSub = this.voiceService.speechStart$.subscribe(() => {
98
+ if (this.destroyed) {
99
+ return;
100
+ }
101
+ // interrompi solo se questo messaggio era in riproduzione o in attesa
102
+ if (this.playbackStarted || this.playbackRequested) {
103
+ this.micInterrupted = true;
104
+ this.ttsPlayback.cancelAll();
105
+ this.interruptPlaybackAndRevealText();
106
+ }
107
+ });
108
+
109
+ // Stop globale (es. mic) notificato dal coordinatore: ogni istanza deve fermarsi e mostrare testo intero.
110
+ this.cancelAllSub = this.ttsPlayback.cancelAll$.subscribe(() => {
111
+ if (this.destroyed) {
112
+ return;
113
+ }
114
+ this.micInterrupted = true;
115
+ this.interruptPlaybackAndRevealText();
116
+ });
117
+
90
118
  this.onPlaybackEnded = () => {
91
119
  this.playbackStarted = false;
92
120
  this.cleanupStreaming();
@@ -138,12 +166,19 @@ export class AudioSyncComponent implements AfterViewInit, OnChanges, OnDestroy {
138
166
  this.cdr.detectChanges();
139
167
 
140
168
  setTimeout(() => {
141
- if (this.playbackRequested || this.destroyed) {
169
+ if (this.playbackRequested || this.destroyed || this.micInterrupted) {
170
+ if (this.micInterrupted) {
171
+ this.markAllWordsPast();
172
+ if (this.message) {
173
+ this.message.isJustRecived = false;
174
+ }
175
+ this.cdr.detectChanges();
176
+ }
142
177
  return;
143
178
  }
144
179
  this.playbackRequested = true;
145
180
  this.ttsPlayback.requestStart(this.playbackOwnerId, () => {
146
- if (this.destroyed) {
181
+ if (this.destroyed || this.micInterrupted) {
147
182
  this.ttsPlayback.releaseIfCurrent(this.playbackOwnerId);
148
183
  return;
149
184
  }
@@ -159,6 +194,8 @@ export class AudioSyncComponent implements AfterViewInit, OnChanges, OnDestroy {
159
194
  this.destroyed = true;
160
195
  this.playbackStarted = false;
161
196
  this.cleanupStreaming();
197
+ this.cancelAllSub?.unsubscribe();
198
+ this.micSpeechSub?.unsubscribe();
162
199
 
163
200
  const audio = this.audioRef?.nativeElement;
164
201
  if (audio) {
@@ -182,6 +219,31 @@ export class AudioSyncComponent implements AfterViewInit, OnChanges, OnDestroy {
182
219
  }
183
220
  }
184
221
 
222
+ private interruptPlaybackAndRevealText(): void {
223
+ this.playbackStarted = false;
224
+ this.cleanupStreaming();
225
+
226
+ const audio = this.audioRef?.nativeElement;
227
+ if (audio) {
228
+ try {
229
+ audio.pause();
230
+ audio.currentTime = 0;
231
+ } catch {
232
+ /* ignore */
233
+ }
234
+ }
235
+
236
+ // Rimuove se era in coda (o rilascia se era corrente).
237
+ this.ttsPlayback.releaseIfCurrent(this.playbackOwnerId);
238
+
239
+ // Mostra tutto il testo (niente "future" invisibili).
240
+ this.markAllWordsPast();
241
+ if (this.message) {
242
+ this.message.isJustRecived = false;
243
+ }
244
+ this.cdr.detectChanges();
245
+ }
246
+
185
247
  private startPlayback(audio: HTMLAudioElement): void {
186
248
  const src = (this.message as any)?.metadata?.src as string | undefined;
187
249
  if (!src) {
@@ -236,8 +298,10 @@ export class AudioSyncComponent implements AfterViewInit, OnChanges, OnDestroy {
236
298
  try {
237
299
  const headers: Record<string, string> = {
238
300
  'Content-Type': 'application/json',
239
- 'Authorization': `${jwt}`
240
301
  };
302
+ if (jwt) {
303
+ headers['Authorization'] = jwt;
304
+ }
241
305
 
242
306
  const response = await fetch(endpoint, {
243
307
  method: 'POST',
@@ -399,11 +463,10 @@ export class AudioSyncComponent implements AfterViewInit, OnChanges, OnDestroy {
399
463
  try {
400
464
  const headers: Record<string, string> = {
401
465
  'Content-Type': 'application/json',
402
- 'Authorization': `${jwt}`
403
466
  };
404
-
405
- console.log('headers', headers);
406
- console.log('requestBody', requestBody);
467
+ if (jwt) {
468
+ headers['Authorization'] = jwt;
469
+ }
407
470
 
408
471
  const response = await fetch(endpoint, {
409
472
  method: 'POST',
@@ -1,4 +1,5 @@
1
1
  import { Injectable } from '@angular/core';
2
+ import { Observable, Subject } from 'rxjs';
2
3
 
3
4
  /**
4
5
  * Garantisce un solo messaggio TTS in riproduzione alla volta.
@@ -9,6 +10,10 @@ export class TtsAudioPlaybackCoordinator {
9
10
  private currentOwnerId: string | null = null;
10
11
  private readonly queue: Array<{ ownerId: string; start: () => void }> = [];
11
12
 
13
+ private readonly cancelAllSource = new Subject<void>();
14
+ /** Emesso quando la riproduzione TTS va interrotta globalmente (es. l’utente parla al microfono). */
15
+ readonly cancelAll$: Observable<void> = this.cancelAllSource.asObservable();
16
+
12
17
  /**
13
18
  * Richiede l'avvio della riproduzione TTS per `ownerId`.
14
19
  * Se non c'è nessun TTS attivo, parte subito; altrimenti viene messo in coda.
@@ -68,4 +73,14 @@ export class TtsAudioPlaybackCoordinator {
68
73
  release(ownerId: string): void {
69
74
  this.releaseIfCurrent(ownerId);
70
75
  }
76
+
77
+ /**
78
+ * Interrompe TUTTA la riproduzione TTS (corrente + coda) e notifica i componenti.
79
+ * I componenti devono fermare l’audio e mostrare il testo per intero.
80
+ */
81
+ cancelAll(): void {
82
+ this.queue.splice(0, this.queue.length);
83
+ this.currentOwnerId = null;
84
+ this.cancelAllSource.next();
85
+ }
71
86
  }
@@ -33,6 +33,10 @@ export class VoiceService {
33
33
  /** Emesso a ogni fine segmento parlato: audio WebM + opzionalmente `transcript` / `transcriptionError`. */
34
34
  readonly audioSegment$: Observable<VoiceSegmentPayload> = this.audioSegmentSubject.asObservable();
35
35
 
36
+ private readonly speechStartSubject = new Subject<void>();
37
+ /** Emesso quando il microfono intercetta parlato (VAD speech start). */
38
+ readonly speechStart$: Observable<void> = this.speechStartSubject.asObservable();
39
+
36
40
  // 🔊 REALTIME VOLUME STREAM
37
41
  private readonly volumeSubject = new BehaviorSubject<number>(0);
38
42
  readonly volume$: Observable<number> = this.volumeSubject.asObservable();
@@ -83,6 +87,7 @@ export class VoiceService {
83
87
  },
84
88
  onSpeechStart: () => {
85
89
  this.logger.log('[VoiceService] speech start');
90
+ this.speechStartSubject.next();
86
91
  this.startMediaRecorderSegment();
87
92
  },
88
93
  onSpeechEnd: () => {
@@ -38,7 +38,7 @@
38
38
  --chat-footer-logo-height: 30px;
39
39
  --chat-footer-close-button-height: 30px;
40
40
  --chat-footer-border-radius: 16px;
41
- --chat-footer-stream-button-height: 96px;
41
+ --chat-footer-stream-button-height: 50px;
42
42
  --chat-footer-stream-button-padding: 10px 0;
43
43
  --chat-footer-background-color: #f6f7fb;
44
44
  --chat-footer-color: #1a1a1a;