@chat21/chat21-web-widget 5.1.32-rc3 → 5.1.32-rc8
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 +17 -0
- package/package.json +1 -1
- package/src/app/app.module.ts +2 -0
- package/src/app/component/conversation-detail/conversation/conversation.component.html +2 -1
- package/src/app/component/conversation-detail/conversation/conversation.component.scss +10 -0
- package/src/app/component/conversation-detail/conversation/conversation.component.ts +11 -0
- package/src/app/component/conversation-detail/conversation-content/conversation-content.component.html +2 -2
- package/src/app/component/conversation-detail/conversation-content/conversation-content.component.scss +9 -0
- package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.html +4 -18
- package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.scss +12 -51
- package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.ts +20 -26
- package/src/app/component/conversation-detail/stream-audio-spectrum/stream-audio-spectrum.component.html +19 -0
- package/src/app/component/conversation-detail/stream-audio-spectrum/stream-audio-spectrum.component.scss +26 -0
- package/src/app/component/conversation-detail/stream-audio-spectrum/stream-audio-spectrum.component.ts +62 -0
- package/src/app/component/message/audio-sync/audio-sync.component.html +0 -1
- package/src/app/component/message/audio-sync/audio-sync.component.ts +352 -16
- package/src/app/providers/tts-audio-playback-coordinator.service.ts +71 -0
- package/src/app/providers/voice/voice.service.ts +34 -4
- package/src/app/sass/_variables.scss +2 -0
- package/src/launch.js +41 -32
- package/src/launch_template.js +41 -32
- package/deploy_amazon_prod.sh +0 -41
package/CHANGELOG.md
CHANGED
|
@@ -6,10 +6,27 @@
|
|
|
6
6
|
### **Copyrigth**:
|
|
7
7
|
*Tiledesk SRL*
|
|
8
8
|
|
|
9
|
+
# 5.1.32-rc8
|
|
10
|
+
- **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
|
+
|
|
12
|
+
# 5.1.32-rc7
|
|
13
|
+
- **added**: `StreamAudioSpectrum` component for audio visualization in the streaming footer UI
|
|
14
|
+
- **added**: TTS playback coordinator queue — ensures TTS messages play sequentially without interrupting the previous one
|
|
15
|
+
- **changed**: `chat-audio-sync` — updated TTS audio handling to support streaming playback and improved autoplay/animation timing
|
|
16
|
+
- **changed**: iframe loader (`launch.js`, `launch_template.js`) — streamlined loading logic and improved error handling, with fixes for localhost environments
|
|
17
|
+
|
|
18
|
+
# 5.1.32-rc4
|
|
19
|
+
- **added**: “Close stream” control (`.close-stream-button`) — content and sheet bottom offset in fullscreen using `--chat-footer-stream-button-height` only while the stream is listening (`isStreamAudioActive`); variables in `_variables.scss`.
|
|
20
|
+
- **added**: `VoiceService.discardCurrentRecordingSegment()` — when a message arrives from another sender during streaming, the current WebM segment is discarded (no upload) without stopping mic/VAD; `interruptStreamDueToPeerMessage()` in the footer no longer clears `isStreamAudioActive`.
|
|
21
|
+
- **changed**: `#streamAudioAlert` — band above the footer with a frosted-glass look (`backdrop-filter`, semi-transparent `color-mix`).
|
|
22
|
+
|
|
9
23
|
# 5.1.32-rc3
|
|
24
|
+
- **changed**: `nginx.conf` (Docker image) — explicit MIME types for `.mjs`, `.wasm`, `.onnx` and `default_type` at `http` level (avoids `text/plain` on ONNX/VAD modules behind containerized deploys).
|
|
25
|
+
- **chore**: removed deprecated Amazon beta/prod deploy scripts from the repository.
|
|
10
26
|
|
|
11
27
|
# 5.1.32-rc2
|
|
12
28
|
- **bug fixed**: minor streaming icon UI fixed
|
|
29
|
+
- **changed**: Refactor stream audio button UI in the conversation footer (layout / classes).
|
|
13
30
|
|
|
14
31
|
# 5.1.32-rc1
|
|
15
32
|
- **added**: Voice pipeline — VAD (`@ricky0123/vad-web`) with ONNX Runtime WASM served from `/assets/onnx` (`copy-onnx-wasm`), `VoiceService` with `audioSegment$` (WebM segments) and optional STT/TTS via unified OpenAI provider using `HttpClient`, transcript / error fields on segment payloads.
|
package/package.json
CHANGED
package/src/app/app.module.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { ConversationFooterComponent } from './component/conversation-detail/con
|
|
|
16
16
|
import { ConversationInternalFrameComponent } from './component/conversation-detail/conversation-internal-frame/conversation-internal-frame.component';
|
|
17
17
|
import { ConversationPreviewComponent } from './component/conversation-detail/conversation-preview/conversation-preview.component';
|
|
18
18
|
import { ConversationAudioRecorderComponent } from './component/conversation-detail/conversation-audio-recorder/conversation-audio-recorder.component';
|
|
19
|
+
import { StreamAudioSpectrumComponent } from './component/conversation-detail/stream-audio-spectrum/stream-audio-spectrum.component';
|
|
19
20
|
/** CONVERSATION-DETAIL COMPONENTS */
|
|
20
21
|
import { BubbleMessageComponent } from './component/message/bubble-message/bubble-message.component';
|
|
21
22
|
import { AvatarComponent } from './component/message/avatar/avatar.component';
|
|
@@ -294,6 +295,7 @@ export function uploadFactory(http: HttpClient, appConfig: AppConfigService, app
|
|
|
294
295
|
ConversationPreviewComponent,
|
|
295
296
|
ConversationInternalFrameComponent,
|
|
296
297
|
ConversationAudioRecorderComponent,
|
|
298
|
+
StreamAudioSpectrumComponent,
|
|
297
299
|
BubbleMessageComponent,
|
|
298
300
|
AvatarComponent,
|
|
299
301
|
FrameComponent,
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
<div id="chat21-conversation-component"
|
|
5
5
|
#afConversationComponent
|
|
6
6
|
tabindex="1500"
|
|
7
|
-
aria-modal="true"
|
|
7
|
+
aria-modal="true"
|
|
8
|
+
[class.chat21-conversation--close-stream-active]="closeStreamButtonActiveForSheetBottom()">
|
|
8
9
|
|
|
9
10
|
<!-- HEADER -->
|
|
10
11
|
<chat-conversation-header
|
|
@@ -242,6 +242,16 @@ dialog:-internal-dialog-in-top-layer{
|
|
|
242
242
|
::ng-deep .chat21-sheet-content{
|
|
243
243
|
bottom: calc(var(--chat-footer-logo-height) + var(--chat-footer-height) + var(--chat-footer-close-button-height) + 34px)!important;
|
|
244
244
|
}
|
|
245
|
+
|
|
246
|
+
/* Con `.close-stream-button` (stream in ascolto): spazio per alert stream sopra il footer */
|
|
247
|
+
#chat21-conversation-component.chat21-conversation--close-stream-active ::ng-deep .chat21-sheet-content {
|
|
248
|
+
bottom: calc(
|
|
249
|
+
var(--chat-footer-logo-height) +
|
|
250
|
+
var(--chat-footer-height) +
|
|
251
|
+
var(--chat-footer-stream-button-height) +
|
|
252
|
+
34px
|
|
253
|
+
) !important;
|
|
254
|
+
}
|
|
245
255
|
|
|
246
256
|
}
|
|
247
257
|
|
|
@@ -827,6 +827,10 @@ export class ConversationComponent implements OnInit, AfterViewInit, OnChanges {
|
|
|
827
827
|
this.showThinkingMessage = false;
|
|
828
828
|
}
|
|
829
829
|
|
|
830
|
+
if (this.isStreamAudioActive && msg.sender !== this.senderId) {
|
|
831
|
+
this.conversationFooter?.interruptStreamDueToPeerMessage();
|
|
832
|
+
}
|
|
833
|
+
|
|
830
834
|
that.newMessageAdded(msg);
|
|
831
835
|
// Update badge based on the latest message received from the server.
|
|
832
836
|
// We rely on `messages` being kept in-sync by the conversation handler.
|
|
@@ -1414,6 +1418,13 @@ export class ConversationComponent implements OnInit, AfterViewInit, OnChanges {
|
|
|
1414
1418
|
}
|
|
1415
1419
|
// =========== END: event emitter function ====== //
|
|
1416
1420
|
|
|
1421
|
+
/**
|
|
1422
|
+
* True quando è visibile il pulsante chiudi stream (`.close-stream-button`, `isStreamAudioActive`).
|
|
1423
|
+
* Solo in quel caso il bottom del foglio include `--chat-footer-stream-button-height`.
|
|
1424
|
+
*/
|
|
1425
|
+
closeStreamButtonActiveForSheetBottom(): boolean {
|
|
1426
|
+
return !!(this.g?.showAudioStreamFooterButton && this.isStreamAudioActive);
|
|
1427
|
+
}
|
|
1417
1428
|
|
|
1418
1429
|
openInputFiles() {
|
|
1419
1430
|
alert('ok');
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
<!-- message RECIPIENT:: -->
|
|
50
50
|
<div role="messaggio" *ngIf="messageType(MESSAGE_TYPE_OTHERS, message)" class="msg_container base_receive">
|
|
51
51
|
|
|
52
|
-
<chat-avatar-image *ngIf="!isSameSender(message?.sender, i)"
|
|
52
|
+
<chat-avatar-image *ngIf="!isSameSender(message?.sender, i) && !isStreamAudioActive"
|
|
53
53
|
[ngClass]="{'slide-in-left': false}"
|
|
54
54
|
[senderID]="message?.sender"
|
|
55
55
|
[senderFullname]="message?.sender_fullname"
|
|
@@ -62,7 +62,7 @@
|
|
|
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]="isSameSender(message?.sender, i)? 'calc(var(--avatar-width) + 10px)': null"
|
|
65
|
+
[style.margin-left]="isStreamAudioActive ? '0px' : (isSameSender(message?.sender, i) ? 'calc(var(--avatar-width) + 10px)' : null)"
|
|
66
66
|
[ngStyle]="{'background': stylesMap.get('bubbleReceivedBackground'), 'color': stylesMap.get('bubbleReceivedTextColor'), 'width':isFrame(message) ?'100%' : null}"
|
|
67
67
|
[isSameSender]="isSameSender(message?.sender, i)"
|
|
68
68
|
[message]="message"
|
|
@@ -270,6 +270,15 @@
|
|
|
270
270
|
}// end c21-body-container
|
|
271
271
|
}// end c21-body
|
|
272
272
|
|
|
273
|
+
/* Solo con pulsante chiudi stream (stream in ascolto): altezza extra come #streamAudioAlert */
|
|
274
|
+
:host-context(#chat21-conversation-component.chat21-conversation--close-stream-active) .c21-body .c21-body-container .c21-body-content .chat21-sheet-content {
|
|
275
|
+
bottom: calc(
|
|
276
|
+
var(--chat-footer-logo-height) +
|
|
277
|
+
var(--chat-footer-height) +
|
|
278
|
+
var(--chat-footer-stream-button-height)
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
273
282
|
@keyframes thinking-dot {
|
|
274
283
|
0%, 80%, 100% {
|
|
275
284
|
opacity: 0.2;
|
package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.html
CHANGED
|
@@ -14,24 +14,10 @@
|
|
|
14
14
|
|
|
15
15
|
<!-- STREAM AUDIO: cerchio con onde animate -->
|
|
16
16
|
<div id="streamAudioAlert" *ngIf="!hideTextAreaContent && isStreamAudioActive" class="fade-in-bottom stream-audio-alert" [class.hideTextReply]="hideTextReply" role="status" [attr.aria-label]="translationMap?.get('STREAM_AUDIO_LISTENING') || 'Stream audio attivo'">
|
|
17
|
-
<
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
<g class="stream-audio-alert__wave-layer stream-audio-alert__wave-layer--1">
|
|
22
|
-
<path [attr.d]="wavePath1"></path>
|
|
23
|
-
</g>
|
|
24
|
-
|
|
25
|
-
<g class="stream-audio-alert__wave-layer stream-audio-alert__wave-layer--2">
|
|
26
|
-
<path [attr.d]="wavePath2"></path>
|
|
27
|
-
</g>
|
|
28
|
-
|
|
29
|
-
<g class="stream-audio-alert__wave-layer stream-audio-alert__wave-layer--3">
|
|
30
|
-
<path [attr.d]="wavePath3"></path>
|
|
31
|
-
</g>
|
|
32
|
-
</g>
|
|
33
|
-
</svg>
|
|
34
|
-
</div>
|
|
17
|
+
<chat-stream-audio-spectrum
|
|
18
|
+
[volume]="currentVolume"
|
|
19
|
+
[accentColor]="stylesMap?.get('themeColor')">
|
|
20
|
+
</chat-stream-audio-spectrum>
|
|
35
21
|
</div>
|
|
36
22
|
|
|
37
23
|
</div>
|
package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.scss
CHANGED
|
@@ -393,67 +393,28 @@ textarea:active{
|
|
|
393
393
|
#streamAudioAlert {
|
|
394
394
|
bottom: 100%;
|
|
395
395
|
width: 100%;
|
|
396
|
-
min-height:
|
|
396
|
+
min-height: var(--chat-footer-stream-button-height);
|
|
397
397
|
display: flex;
|
|
398
398
|
align-items: center;
|
|
399
399
|
justify-content: center;
|
|
400
|
-
background-color: var(--content-background-color);
|
|
401
400
|
position: absolute;
|
|
402
|
-
padding:
|
|
401
|
+
padding: var(--chat-footer-stream-button-padding);
|
|
402
|
+
/* Satinato / vetro: più trasparenza, blur più marcato */
|
|
403
|
+
background-color: color-mix(in srgb, var(--content-background-color) 34%, transparent);
|
|
404
|
+
backdrop-filter: blur(20px) saturate(1.2);
|
|
405
|
+
-webkit-backdrop-filter: blur(20px) saturate(1.2);
|
|
406
|
+
box-shadow:
|
|
407
|
+
inset 0 1px 0 rgba(255, 255, 255, 0.28),
|
|
408
|
+
0 1px 0 rgba(0, 0, 0, 0.05);
|
|
403
409
|
|
|
404
410
|
&.hideTextReply {
|
|
405
411
|
position: unset;
|
|
406
412
|
min-height: auto;
|
|
407
413
|
padding: 16px 0;
|
|
408
414
|
box-shadow: none;
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
.stream-audio-alert__orb {
|
|
413
|
-
display: flex;
|
|
414
|
-
align-items: center;
|
|
415
|
-
justify-content: center;
|
|
416
|
-
width: 88px;
|
|
417
|
-
height: 88px;
|
|
418
|
-
border-radius: 50%;
|
|
419
|
-
border: 2px solid currentColor;
|
|
420
|
-
background: var(--content-background-color);
|
|
421
|
-
box-shadow: inset 0 0 24px rgba(0, 0, 0, 0.04);
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
.stream-audio-alert__svg {
|
|
425
|
-
width: 72px;
|
|
426
|
-
height: 72px;
|
|
427
|
-
display: block;
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
.stream-audio-alert__wave-layer {
|
|
431
|
-
transform-origin: 50px 50px;
|
|
432
|
-
transform-box: fill-box;
|
|
433
|
-
animation: stream-wave-float 1.35s ease-in-out infinite;
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
.stream-audio-alert__wave-layer--1 {
|
|
437
|
-
animation-delay: 0s;
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
.stream-audio-alert__wave-layer--2 {
|
|
441
|
-
animation-delay: 0.18s;
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
.stream-audio-alert__wave-layer--3 {
|
|
445
|
-
animation-delay: 0.36s;
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
@keyframes stream-wave-float {
|
|
449
|
-
0%,
|
|
450
|
-
100% {
|
|
451
|
-
transform: translateY(0);
|
|
452
|
-
opacity: 0.85;
|
|
453
|
-
}
|
|
454
|
-
50% {
|
|
455
|
-
transform: translateY(-6px);
|
|
456
|
-
opacity: 1;
|
|
415
|
+
backdrop-filter: none;
|
|
416
|
+
-webkit-backdrop-filter: none;
|
|
417
|
+
background-color: var(--content-background-color);
|
|
457
418
|
}
|
|
458
419
|
}
|
|
459
420
|
|
package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.ts
CHANGED
|
@@ -97,10 +97,8 @@ export class ConversationFooterComponent implements OnInit, OnChanges, OnDestroy
|
|
|
97
97
|
private voiceAudioSubscription?: Subscription;
|
|
98
98
|
/** Sottoscrizione al volume audio (real-time) dal {@link VoiceService}. */
|
|
99
99
|
private voiceVolumeSubscription?: Subscription;
|
|
100
|
+
/** Passato a {@link StreamAudioSpectrumComponent} per disegnare la linea spettro. */
|
|
100
101
|
currentVolume = 0;
|
|
101
|
-
wavePath1 = '';
|
|
102
|
-
wavePath2 = '';
|
|
103
|
-
wavePath3 = '';
|
|
104
102
|
|
|
105
103
|
file_size_limit = FILE_SIZE_LIMIT;
|
|
106
104
|
attachmentTooltip: string = '';
|
|
@@ -118,7 +116,6 @@ export class ConversationFooterComponent implements OnInit, OnChanges, OnDestroy
|
|
|
118
116
|
// this.updateAttachmentTooltip();
|
|
119
117
|
}
|
|
120
118
|
|
|
121
|
-
|
|
122
119
|
ngOnChanges(changes: SimpleChanges){
|
|
123
120
|
if(changes['conversationWith'] && changes['conversationWith'].currentValue !== undefined){
|
|
124
121
|
this.conversationHandlerService = this.chatManager.getConversationHandlerByConversationId(this.conversationWith);
|
|
@@ -176,40 +173,36 @@ export class ConversationFooterComponent implements OnInit, OnChanges, OnDestroy
|
|
|
176
173
|
});
|
|
177
174
|
this.voiceVolumeSubscription = this.voiceService.volume$.subscribe((volume) => {
|
|
178
175
|
this.currentVolume = volume;
|
|
179
|
-
this.updateWave(volume);
|
|
180
176
|
});
|
|
181
177
|
await this.voiceService.startSession();
|
|
182
178
|
}
|
|
183
179
|
|
|
184
|
-
async stopVoice() {
|
|
180
|
+
async stopVoice(options?: { discardInProgressSegment?: boolean }) {
|
|
185
181
|
this.voiceAudioSubscription?.unsubscribe();
|
|
186
182
|
this.voiceAudioSubscription = undefined;
|
|
187
183
|
|
|
188
184
|
this.voiceVolumeSubscription?.unsubscribe();
|
|
189
185
|
this.voiceVolumeSubscription = undefined;
|
|
190
186
|
|
|
191
|
-
await this.voiceService.stopSession();
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
updateWave(volume: number) {
|
|
195
|
-
const intensity = Math.min(volume / 80, 1); // più sensibile
|
|
196
|
-
|
|
197
|
-
const amp1 = 4 + intensity * 22;
|
|
198
|
-
const amp2 = 2 + intensity * 16;
|
|
199
|
-
const amp3 = 1 + intensity * 12;
|
|
200
|
-
|
|
201
|
-
this.wavePath1 = this.buildWave(42, amp1);
|
|
202
|
-
this.wavePath2 = this.buildWave(50, amp2);
|
|
203
|
-
this.wavePath3 = this.buildWave(58, amp3);
|
|
187
|
+
await this.voiceService.stopSession(options);
|
|
188
|
+
this.currentVolume = 0;
|
|
204
189
|
}
|
|
205
190
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
191
|
+
/**
|
|
192
|
+
* CHIAMATO DA: conversation.component.ts
|
|
193
|
+
* Messaggio in arrivo da un altro mittente mentre lo stream è attivo: scarta solo il segmento
|
|
194
|
+
* registrato in quel momento (nessun upload); mic + VAD restano attivi, `isStreamAudioActive` true.
|
|
195
|
+
*/
|
|
196
|
+
interruptStreamDueToPeerMessage(): void {
|
|
197
|
+
if (!this.isStreamAudioActive) {
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
this.logger.log('[CONV-FOOTER] discard recording segment: incoming message from peer (stream stays on)');
|
|
201
|
+
try {
|
|
202
|
+
this.voiceService.discardCurrentRecordingSegment();
|
|
203
|
+
} catch (e) {
|
|
204
|
+
this.logger.error('[CONV-FOOTER] interruptStreamDueToPeerMessage', e);
|
|
205
|
+
}
|
|
213
206
|
}
|
|
214
207
|
|
|
215
208
|
ngOnDestroy() {
|
|
@@ -741,6 +734,7 @@ export class ConversationFooterComponent implements OnInit, OnChanges, OnDestroy
|
|
|
741
734
|
const turningOn = !this.isStreamAudioActive;
|
|
742
735
|
if (turningOn) {
|
|
743
736
|
try {
|
|
737
|
+
this.currentVolume = 0;
|
|
744
738
|
await this.initVoice();
|
|
745
739
|
this.isStreamAudioActive = true;
|
|
746
740
|
} catch (e) {
|
|
@@ -0,0 +1,19 @@
|
|
|
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">
|
|
3
|
+
<defs>
|
|
4
|
+
<linearGradient [attr.id]="gradientId" x1="12" y1="50" x2="88" y2="50" gradientUnits="userSpaceOnUse">
|
|
5
|
+
<stop offset="0%" stop-color="currentColor" stop-opacity="0.45"/>
|
|
6
|
+
<stop offset="50%" stop-color="currentColor" stop-opacity="1"/>
|
|
7
|
+
<stop offset="100%" stop-color="currentColor" stop-opacity="0.45"/>
|
|
8
|
+
</linearGradient>
|
|
9
|
+
</defs>
|
|
10
|
+
<circle cx="50" cy="50" r="46" fill="currentColor" opacity="0.14"/>
|
|
11
|
+
<path class="stream-audio-spectrum__line"
|
|
12
|
+
[attr.d]="spectrumLinePath"
|
|
13
|
+
fill="none"
|
|
14
|
+
[attr.stroke]="'url(#' + gradientId + ')'"
|
|
15
|
+
stroke-width="2.4"
|
|
16
|
+
stroke-linecap="round"
|
|
17
|
+
stroke-linejoin="round"/>
|
|
18
|
+
</svg>
|
|
19
|
+
</div>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
:host {
|
|
2
|
+
display: block;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
.stream-audio-spectrum__orb {
|
|
6
|
+
display: flex;
|
|
7
|
+
align-items: center;
|
|
8
|
+
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);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.stream-audio-spectrum__svg {
|
|
18
|
+
width: 72px;
|
|
19
|
+
height: 72px;
|
|
20
|
+
display: block;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.stream-audio-spectrum__line {
|
|
24
|
+
pointer-events: none;
|
|
25
|
+
filter: drop-shadow(0 0 1px color-mix(in srgb, currentColor 35%, transparent));
|
|
26
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Icona stream: cerchio con linea orizzontale tipo spettro, reattiva al volume del microfono.
|
|
5
|
+
* Il parent (es. conversation-footer) aggiorna solo {@link volume} da VoiceService.
|
|
6
|
+
*/
|
|
7
|
+
@Component({
|
|
8
|
+
selector: 'chat-stream-audio-spectrum',
|
|
9
|
+
templateUrl: './stream-audio-spectrum.component.html',
|
|
10
|
+
styleUrl: './stream-audio-spectrum.component.scss',
|
|
11
|
+
})
|
|
12
|
+
export class StreamAudioSpectrumComponent implements OnInit, OnChanges {
|
|
13
|
+
private static gradSeq = 0;
|
|
14
|
+
readonly gradientId = `streamSpectrumGrad-${++StreamAudioSpectrumComponent.gradSeq}`;
|
|
15
|
+
|
|
16
|
+
/** Volume normalizzato come emesso da VoiceService (stessa scala del footer). */
|
|
17
|
+
@Input() volume = 0;
|
|
18
|
+
/** Colore tema (stroke / gradient); opzionale. */
|
|
19
|
+
@Input() accentColor?: string;
|
|
20
|
+
|
|
21
|
+
spectrumLinePath = 'M12,50 L88,50';
|
|
22
|
+
|
|
23
|
+
ngOnInit(): void {
|
|
24
|
+
this.refreshPath();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
ngOnChanges(changes: SimpleChanges): void {
|
|
28
|
+
if (changes['volume']) {
|
|
29
|
+
this.refreshPath();
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
private refreshPath(): void {
|
|
34
|
+
const intensity = Math.min(this.volume / 80, 1);
|
|
35
|
+
const t = Date.now() / 175;
|
|
36
|
+
this.spectrumLinePath = this.buildSpectrumLinePath(intensity, t);
|
|
37
|
+
}
|
|
38
|
+
|
|
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;
|
|
45
|
+
const parts: string[] = [];
|
|
46
|
+
for (let i = 0; i <= segments; i++) {
|
|
47
|
+
const p = i / segments;
|
|
48
|
+
const x = x0 + p * (x1 - x0);
|
|
49
|
+
const u = p * Math.PI * 6;
|
|
50
|
+
const wobble =
|
|
51
|
+
Math.sin(u + t) * 0.34 +
|
|
52
|
+
Math.sin(u * 2.35 + t * 1.12) * 0.24 +
|
|
53
|
+
Math.sin(u * 4.2 + t * 0.72) * 0.18 +
|
|
54
|
+
Math.sin(u * 6.8 + t * 1.05) * 0.14 +
|
|
55
|
+
Math.sin(u * 9.1 + t * 0.88) * 0.1;
|
|
56
|
+
const y = cy + amp * wobble;
|
|
57
|
+
const yClamped = Math.min(68, Math.max(32, y));
|
|
58
|
+
parts.push(i === 0 ? `M${x.toFixed(2)},${yClamped.toFixed(2)}` : `L${x.toFixed(2)},${yClamped.toFixed(2)}`);
|
|
59
|
+
}
|
|
60
|
+
return parts.join('');
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -10,6 +10,8 @@ import {
|
|
|
10
10
|
ViewChild,
|
|
11
11
|
} from '@angular/core';
|
|
12
12
|
import { MessageModel } from 'src/chat21-core/models/message';
|
|
13
|
+
import { TtsAudioPlaybackCoordinator } from 'src/app/providers/tts-audio-playback-coordinator.service';
|
|
14
|
+
import { Globals } from 'src/app/utils/globals';
|
|
13
15
|
|
|
14
16
|
/** HAVE_METADATA: metadati già disponibili (tipico audio servito da cache). */
|
|
15
17
|
const HAVE_METADATA = 1;
|
|
@@ -41,7 +43,19 @@ export class AudioSyncComponent implements AfterViewInit, OnChanges, OnDestroy {
|
|
|
41
43
|
private onMetadataLoaded: () => void;
|
|
42
44
|
private onPlaybackEnded: () => void;
|
|
43
45
|
|
|
44
|
-
|
|
46
|
+
/** Id univoco per il coordinatore (di solito `message.uid`). */
|
|
47
|
+
private playbackOwnerId = '';
|
|
48
|
+
private destroyed = false;
|
|
49
|
+
private playbackRequested = false;
|
|
50
|
+
private playbackStarted = false;
|
|
51
|
+
private streamAbort?: AbortController;
|
|
52
|
+
private mediaSourceObjectUrl?: string;
|
|
53
|
+
|
|
54
|
+
constructor(
|
|
55
|
+
private readonly cdr: ChangeDetectorRef,
|
|
56
|
+
private readonly ttsPlayback: TtsAudioPlaybackCoordinator,
|
|
57
|
+
private readonly globals: Globals,
|
|
58
|
+
) {}
|
|
45
59
|
|
|
46
60
|
/** `false` = messaggio già in storico: niente autoplay / karaoke. Da `message.isJustRecived`. */
|
|
47
61
|
private get skipSyncAnimation(): boolean {
|
|
@@ -53,11 +67,14 @@ export class AudioSyncComponent implements AfterViewInit, OnChanges, OnDestroy {
|
|
|
53
67
|
return;
|
|
54
68
|
}
|
|
55
69
|
if (this.audioRef?.nativeElement && this.timingReady) {
|
|
56
|
-
|
|
70
|
+
const d = this.audioRef.nativeElement.duration;
|
|
71
|
+
if (Number.isFinite(d) && d > 0) {
|
|
72
|
+
this.duration = d;
|
|
73
|
+
}
|
|
57
74
|
this.buildFakeTiming();
|
|
58
75
|
if (this.skipSyncAnimation) {
|
|
59
76
|
this.markAllWordsPast();
|
|
60
|
-
} else {
|
|
77
|
+
} else if (this.playbackStarted) {
|
|
61
78
|
this.syncStatesFromCurrentTime();
|
|
62
79
|
}
|
|
63
80
|
}
|
|
@@ -66,7 +83,14 @@ export class AudioSyncComponent implements AfterViewInit, OnChanges, OnDestroy {
|
|
|
66
83
|
ngAfterViewInit(): void {
|
|
67
84
|
const audio = this.audioRef.nativeElement;
|
|
68
85
|
|
|
86
|
+
this.playbackOwnerId =
|
|
87
|
+
(this.message?.uid && String(this.message.uid).trim()) ||
|
|
88
|
+
`tts-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
|
|
89
|
+
|
|
69
90
|
this.onPlaybackEnded = () => {
|
|
91
|
+
this.playbackStarted = false;
|
|
92
|
+
this.cleanupStreaming();
|
|
93
|
+
this.ttsPlayback.releaseIfCurrent(this.playbackOwnerId);
|
|
70
94
|
if (this.skipSyncAnimation) {
|
|
71
95
|
return;
|
|
72
96
|
}
|
|
@@ -78,38 +102,75 @@ export class AudioSyncComponent implements AfterViewInit, OnChanges, OnDestroy {
|
|
|
78
102
|
};
|
|
79
103
|
|
|
80
104
|
this.onMetadataLoaded = () => {
|
|
81
|
-
|
|
82
|
-
|
|
105
|
+
// La durata potrebbe arrivare tardi (specie con streaming).
|
|
106
|
+
const d = audio.duration;
|
|
107
|
+
if (Number.isFinite(d) && d > 0) {
|
|
108
|
+
this.duration = d;
|
|
109
|
+
} else if (!this.timingReady) {
|
|
110
|
+
this.duration = this.estimateDurationSecondsFromText();
|
|
83
111
|
}
|
|
112
|
+
|
|
84
113
|
this.timingReady = true;
|
|
85
|
-
this.duration = audio.duration || 1;
|
|
86
114
|
this.buildFakeTiming();
|
|
87
115
|
if (this.skipSyncAnimation) {
|
|
88
116
|
this.markAllWordsPast();
|
|
89
117
|
this.cdr.detectChanges();
|
|
90
118
|
return;
|
|
91
119
|
}
|
|
92
|
-
this.
|
|
120
|
+
if (this.playbackStarted) {
|
|
121
|
+
this.syncStatesFromCurrentTime();
|
|
122
|
+
}
|
|
93
123
|
this.cdr.detectChanges();
|
|
94
|
-
|
|
95
|
-
setTimeout(() => {
|
|
96
|
-
audio.play().catch(() => {
|
|
97
|
-
this.syncStatesFromCurrentTime();
|
|
98
|
-
this.cdr.detectChanges();
|
|
99
|
-
});
|
|
100
|
-
}, 200);
|
|
101
124
|
};
|
|
102
125
|
|
|
103
126
|
audio.addEventListener('loadedmetadata', this.onMetadataLoaded);
|
|
104
127
|
audio.addEventListener('ended', this.onPlaybackEnded);
|
|
105
128
|
|
|
106
|
-
|
|
107
|
-
|
|
129
|
+
// Prepara subito le parole (durata stimata) e poi aggiorna quando arriva la metadata reale.
|
|
130
|
+
this.duration = this.estimateDurationSecondsFromText();
|
|
131
|
+
this.timingReady = true;
|
|
132
|
+
this.buildFakeTiming();
|
|
133
|
+
if (this.skipSyncAnimation) {
|
|
134
|
+
this.markAllWordsPast();
|
|
135
|
+
this.cdr.detectChanges();
|
|
136
|
+
return;
|
|
108
137
|
}
|
|
138
|
+
this.cdr.detectChanges();
|
|
139
|
+
|
|
140
|
+
setTimeout(() => {
|
|
141
|
+
if (this.playbackRequested || this.destroyed) {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
this.playbackRequested = true;
|
|
145
|
+
this.ttsPlayback.requestStart(this.playbackOwnerId, () => {
|
|
146
|
+
if (this.destroyed) {
|
|
147
|
+
this.ttsPlayback.releaseIfCurrent(this.playbackOwnerId);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
this.playbackStarted = true;
|
|
151
|
+
this.syncStatesFromCurrentTime();
|
|
152
|
+
this.cdr.detectChanges();
|
|
153
|
+
this.startPlayback(audio);
|
|
154
|
+
});
|
|
155
|
+
}, 200);
|
|
109
156
|
}
|
|
110
157
|
|
|
111
158
|
ngOnDestroy(): void {
|
|
159
|
+
this.destroyed = true;
|
|
160
|
+
this.playbackStarted = false;
|
|
161
|
+
this.cleanupStreaming();
|
|
162
|
+
|
|
112
163
|
const audio = this.audioRef?.nativeElement;
|
|
164
|
+
if (audio) {
|
|
165
|
+
try {
|
|
166
|
+
audio.pause();
|
|
167
|
+
audio.currentTime = 0;
|
|
168
|
+
} catch {
|
|
169
|
+
/* ignore */
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
this.ttsPlayback.release(this.playbackOwnerId);
|
|
173
|
+
|
|
113
174
|
if (!audio) {
|
|
114
175
|
return;
|
|
115
176
|
}
|
|
@@ -121,6 +182,266 @@ export class AudioSyncComponent implements AfterViewInit, OnChanges, OnDestroy {
|
|
|
121
182
|
}
|
|
122
183
|
}
|
|
123
184
|
|
|
185
|
+
private startPlayback(audio: HTMLAudioElement): void {
|
|
186
|
+
const src = (this.message as any)?.metadata?.src as string | undefined;
|
|
187
|
+
if (!src) {
|
|
188
|
+
this.playbackStarted = false;
|
|
189
|
+
this.ttsPlayback.releaseIfCurrent(this.playbackOwnerId);
|
|
190
|
+
this.markAllWordsPast();
|
|
191
|
+
if (this.message) {
|
|
192
|
+
this.message.isJustRecived = false;
|
|
193
|
+
}
|
|
194
|
+
this.cdr.detectChanges();
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (this.message?.type === 'tts') {
|
|
199
|
+
this.startStreamingFromEndpoint(audio, src);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
audio.src = src;
|
|
204
|
+
try {
|
|
205
|
+
audio.currentTime = 0;
|
|
206
|
+
} catch {
|
|
207
|
+
/* ignore */
|
|
208
|
+
}
|
|
209
|
+
audio.play().catch(() => this.handlePlaybackError());
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
private startStreamingFromEndpoint(audio: HTMLAudioElement, endpoint: string): void {
|
|
213
|
+
this.cleanupStreaming();
|
|
214
|
+
|
|
215
|
+
const jwt = this.getJwtToken();
|
|
216
|
+
const voiceSettings = this.getVoiceSettingsBody();
|
|
217
|
+
const requestBody = this.buildTtsRequestBody(voiceSettings);
|
|
218
|
+
// <audio src="..."> non può inviare header/body: serve fetch().
|
|
219
|
+
const hasMse = typeof (window as any).MediaSource !== 'undefined';
|
|
220
|
+
if (!hasMse) {
|
|
221
|
+
this.fetchAsBlobAndPlay(audio, endpoint, jwt, requestBody);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const MediaSourceCtor = (window as any).MediaSource as typeof MediaSource;
|
|
226
|
+
const mediaSource = new MediaSourceCtor();
|
|
227
|
+
const objectUrl = URL.createObjectURL(mediaSource);
|
|
228
|
+
this.mediaSourceObjectUrl = objectUrl;
|
|
229
|
+
audio.src = objectUrl;
|
|
230
|
+
|
|
231
|
+
const abort = new AbortController();
|
|
232
|
+
this.streamAbort = abort;
|
|
233
|
+
|
|
234
|
+
const onSourceOpen = async () => {
|
|
235
|
+
mediaSource.removeEventListener('sourceopen', onSourceOpen);
|
|
236
|
+
try {
|
|
237
|
+
const headers: Record<string, string> = {
|
|
238
|
+
'Content-Type': 'application/json',
|
|
239
|
+
'Authorization': `${jwt}`
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
const response = await fetch(endpoint, {
|
|
243
|
+
method: 'POST',
|
|
244
|
+
headers,
|
|
245
|
+
body: JSON.stringify(requestBody),
|
|
246
|
+
signal: abort.signal,
|
|
247
|
+
});
|
|
248
|
+
if (!response.ok || !response.body) {
|
|
249
|
+
throw new Error(`TTS stream request failed (${response.status})`);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const headerType = (response.headers.get('content-type') || '').split(';')[0].trim();
|
|
253
|
+
const mime = (headerType && MediaSourceCtor.isTypeSupported(headerType))
|
|
254
|
+
? headerType
|
|
255
|
+
: 'audio/mpeg';
|
|
256
|
+
|
|
257
|
+
if (!MediaSourceCtor.isTypeSupported(mime)) {
|
|
258
|
+
this.cleanupStreaming();
|
|
259
|
+
// Fallback: fetch completo e play via blob (no streaming).
|
|
260
|
+
this.fetchAsBlobAndPlay(audio, endpoint, jwt, requestBody);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const sourceBuffer = mediaSource.addSourceBuffer(mime);
|
|
265
|
+
sourceBuffer.mode = 'sequence';
|
|
266
|
+
|
|
267
|
+
const reader = response.body.getReader();
|
|
268
|
+
const queue: Uint8Array[] = [];
|
|
269
|
+
let doneReading = false;
|
|
270
|
+
let started = false;
|
|
271
|
+
|
|
272
|
+
const tryEndOfStream = () => {
|
|
273
|
+
if (doneReading && queue.length === 0 && !sourceBuffer.updating) {
|
|
274
|
+
try {
|
|
275
|
+
mediaSource.endOfStream();
|
|
276
|
+
} catch {
|
|
277
|
+
/* ignore */
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
const pump = () => {
|
|
283
|
+
if (abort.signal.aborted) {
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
if (sourceBuffer.updating) {
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
const chunk = queue.shift();
|
|
290
|
+
if (!chunk) {
|
|
291
|
+
tryEndOfStream();
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
try {
|
|
295
|
+
const ab = chunk.buffer.slice(
|
|
296
|
+
chunk.byteOffset,
|
|
297
|
+
chunk.byteOffset + chunk.byteLength,
|
|
298
|
+
) as ArrayBuffer;
|
|
299
|
+
sourceBuffer.appendBuffer(ab);
|
|
300
|
+
} catch {
|
|
301
|
+
this.cleanupStreaming();
|
|
302
|
+
this.fetchAsBlobAndPlay(audio, endpoint, jwt, requestBody);
|
|
303
|
+
}
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
sourceBuffer.addEventListener('updateend', () => {
|
|
307
|
+
if (!started && this.playbackStarted && !this.destroyed) {
|
|
308
|
+
started = true;
|
|
309
|
+
audio.play().catch(() => this.handlePlaybackError());
|
|
310
|
+
}
|
|
311
|
+
pump();
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
// Primo pump (se arrivano subito chunk)
|
|
315
|
+
pump();
|
|
316
|
+
|
|
317
|
+
while (!abort.signal.aborted) {
|
|
318
|
+
const { value, done } = await reader.read();
|
|
319
|
+
if (done) {
|
|
320
|
+
doneReading = true;
|
|
321
|
+
break;
|
|
322
|
+
}
|
|
323
|
+
if (value && value.byteLength > 0) {
|
|
324
|
+
queue.push(value);
|
|
325
|
+
pump();
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
doneReading = true;
|
|
330
|
+
tryEndOfStream();
|
|
331
|
+
} catch {
|
|
332
|
+
if (!abort.signal.aborted) {
|
|
333
|
+
this.handlePlaybackError();
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
mediaSource.addEventListener('sourceopen', onSourceOpen);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
private handlePlaybackError(): void {
|
|
342
|
+
this.playbackStarted = false;
|
|
343
|
+
this.cleanupStreaming();
|
|
344
|
+
this.ttsPlayback.releaseIfCurrent(this.playbackOwnerId);
|
|
345
|
+
this.markAllWordsPast();
|
|
346
|
+
if (this.message) {
|
|
347
|
+
this.message.isJustRecived = false;
|
|
348
|
+
}
|
|
349
|
+
this.cdr.detectChanges();
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
private cleanupStreaming(): void {
|
|
353
|
+
try {
|
|
354
|
+
this.streamAbort?.abort();
|
|
355
|
+
} catch {
|
|
356
|
+
/* ignore */
|
|
357
|
+
}
|
|
358
|
+
this.streamAbort = undefined;
|
|
359
|
+
|
|
360
|
+
if (this.mediaSourceObjectUrl) {
|
|
361
|
+
try {
|
|
362
|
+
URL.revokeObjectURL(this.mediaSourceObjectUrl);
|
|
363
|
+
} catch {
|
|
364
|
+
/* ignore */
|
|
365
|
+
}
|
|
366
|
+
this.mediaSourceObjectUrl = undefined;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
private getJwtToken(): string | null {
|
|
371
|
+
const token = (this.globals?.tiledeskToken || this.globals?.jwt || '').trim();
|
|
372
|
+
return token.length > 0 ? token : null;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
private getVoiceSettingsBody(): unknown {
|
|
376
|
+
const raw = (this.message as any)?.metadata?.voiceSettings;
|
|
377
|
+
if (raw === null || raw === undefined) {
|
|
378
|
+
return {};
|
|
379
|
+
}
|
|
380
|
+
if (typeof raw === 'string') {
|
|
381
|
+
const s = raw.trim();
|
|
382
|
+
if (!s) return {};
|
|
383
|
+
try {
|
|
384
|
+
return JSON.parse(s);
|
|
385
|
+
} catch {
|
|
386
|
+
// se non è JSON valido, invialo come stringa (il backend può gestirlo)
|
|
387
|
+
return { voiceSettings: raw };
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
return raw;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
private async fetchAsBlobAndPlay(
|
|
394
|
+
audio: HTMLAudioElement,
|
|
395
|
+
endpoint: string,
|
|
396
|
+
jwt: string | null,
|
|
397
|
+
requestBody: unknown,
|
|
398
|
+
): Promise<void> {
|
|
399
|
+
try {
|
|
400
|
+
const headers: Record<string, string> = {
|
|
401
|
+
'Content-Type': 'application/json',
|
|
402
|
+
'Authorization': `${jwt}`
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
console.log('headers', headers);
|
|
406
|
+
console.log('requestBody', requestBody);
|
|
407
|
+
|
|
408
|
+
const response = await fetch(endpoint, {
|
|
409
|
+
method: 'POST',
|
|
410
|
+
headers,
|
|
411
|
+
body: JSON.stringify(requestBody ?? {}),
|
|
412
|
+
signal: this.streamAbort?.signal,
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
if (!response.ok) {
|
|
416
|
+
throw new Error(`TTS request failed (${response.status})`);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const blob = await response.blob();
|
|
420
|
+
if (this.destroyed) {
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const objectUrl = URL.createObjectURL(blob);
|
|
425
|
+
this.mediaSourceObjectUrl = objectUrl;
|
|
426
|
+
audio.src = objectUrl;
|
|
427
|
+
audio.play().catch(() => this.handlePlaybackError());
|
|
428
|
+
} catch {
|
|
429
|
+
this.handlePlaybackError();
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
private buildTtsRequestBody(voiceSettings: unknown): unknown {
|
|
434
|
+
const text = this.message?.text ?? '';
|
|
435
|
+
if (
|
|
436
|
+
voiceSettings &&
|
|
437
|
+
typeof voiceSettings === 'object' &&
|
|
438
|
+
!Array.isArray(voiceSettings)
|
|
439
|
+
) {
|
|
440
|
+
return { ...(voiceSettings as Record<string, unknown>), text, streaming: true };
|
|
441
|
+
}
|
|
442
|
+
return { voiceSettings, text, streaming: true };
|
|
443
|
+
}
|
|
444
|
+
|
|
124
445
|
private markAllWordsPast(): void {
|
|
125
446
|
this.words.forEach((w) => {
|
|
126
447
|
w.state = 'past';
|
|
@@ -128,6 +449,18 @@ export class AudioSyncComponent implements AfterViewInit, OnChanges, OnDestroy {
|
|
|
128
449
|
this.activeIndex = -1;
|
|
129
450
|
}
|
|
130
451
|
|
|
452
|
+
private estimateDurationSecondsFromText(): number {
|
|
453
|
+
const rawWords = (this.message?.text || '')
|
|
454
|
+
.trim()
|
|
455
|
+
.split(/\s+/)
|
|
456
|
+
.filter((w) => w.length > 0);
|
|
457
|
+
if (rawWords.length === 0) {
|
|
458
|
+
return 1;
|
|
459
|
+
}
|
|
460
|
+
// ~140 WPM → ~0.43s/word
|
|
461
|
+
return Math.max(1, rawWords.length * 0.43);
|
|
462
|
+
}
|
|
463
|
+
|
|
131
464
|
buildFakeTiming(): void {
|
|
132
465
|
const rawWords = (this.message?.text || '')
|
|
133
466
|
.trim()
|
|
@@ -176,6 +509,9 @@ export class AudioSyncComponent implements AfterViewInit, OnChanges, OnDestroy {
|
|
|
176
509
|
}
|
|
177
510
|
|
|
178
511
|
onTimeUpdate(): void {
|
|
512
|
+
if (!this.playbackStarted) {
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
179
515
|
this.syncStatesFromCurrentTime();
|
|
180
516
|
}
|
|
181
517
|
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { Injectable } from '@angular/core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Garantisce un solo messaggio TTS in riproduzione alla volta.
|
|
5
|
+
* Se arrivano più messaggi TTS, vengono riprodotti in coda (FIFO) senza interrompere quello corrente.
|
|
6
|
+
*/
|
|
7
|
+
@Injectable({ providedIn: 'root' })
|
|
8
|
+
export class TtsAudioPlaybackCoordinator {
|
|
9
|
+
private currentOwnerId: string | null = null;
|
|
10
|
+
private readonly queue: Array<{ ownerId: string; start: () => void }> = [];
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Richiede l'avvio della riproduzione TTS per `ownerId`.
|
|
14
|
+
* Se non c'è nessun TTS attivo, parte subito; altrimenti viene messo in coda.
|
|
15
|
+
*/
|
|
16
|
+
requestStart(ownerId: string, start: () => void): void {
|
|
17
|
+
const id = (ownerId || '').trim();
|
|
18
|
+
if (!id) {
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
if (this.currentOwnerId === id) {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
if (this.queue.some((j) => j.ownerId === id)) {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
if (this.currentOwnerId) {
|
|
28
|
+
this.queue.push({ ownerId: id, start });
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
this.currentOwnerId = id;
|
|
32
|
+
try {
|
|
33
|
+
start();
|
|
34
|
+
} catch {
|
|
35
|
+
this.releaseIfCurrent(id);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Chiamare a fine riproduzione naturale (`ended`) se questo messaggio era ancora “attivo”. */
|
|
40
|
+
releaseIfCurrent(ownerId: string): void {
|
|
41
|
+
const id = (ownerId || '').trim();
|
|
42
|
+
if (!id) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (this.currentOwnerId !== id) {
|
|
46
|
+
// Se era in coda, rimuovilo.
|
|
47
|
+
const idx = this.queue.findIndex((j) => j.ownerId === id);
|
|
48
|
+
if (idx !== -1) {
|
|
49
|
+
this.queue.splice(idx, 1);
|
|
50
|
+
}
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
this.currentOwnerId = null;
|
|
55
|
+
const next = this.queue.shift();
|
|
56
|
+
if (!next) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
this.currentOwnerId = next.ownerId;
|
|
60
|
+
try {
|
|
61
|
+
next.start();
|
|
62
|
+
} catch {
|
|
63
|
+
this.releaseIfCurrent(next.ownerId);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Distruzione componente o stop esplicito. */
|
|
68
|
+
release(ownerId: string): void {
|
|
69
|
+
this.releaseIfCurrent(ownerId);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -100,9 +100,20 @@ export class VoiceService {
|
|
|
100
100
|
this.startVolumeLoop();
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
103
|
+
/**
|
|
104
|
+
* @param options.discardInProgressSegment — non inviare STT/upload per il segmento WebM corrente (es. interruzione da messaggio in arrivo).
|
|
105
|
+
*/
|
|
106
|
+
async stopSession(options?: { discardInProgressSegment?: boolean }): Promise<void> {
|
|
107
|
+
const discard = options?.discardInProgressSegment === true;
|
|
108
|
+
|
|
109
|
+
if (this.mediaRecorder) {
|
|
110
|
+
if (discard) {
|
|
111
|
+
this.mediaRecorder.onstop = null;
|
|
112
|
+
this.mediaRecorder.ondataavailable = null;
|
|
113
|
+
}
|
|
114
|
+
if (this.mediaRecorder.state === 'recording') {
|
|
115
|
+
this.mediaRecorder.stop();
|
|
116
|
+
}
|
|
106
117
|
}
|
|
107
118
|
|
|
108
119
|
this.mediaRecorder = undefined;
|
|
@@ -134,6 +145,23 @@ export class VoiceService {
|
|
|
134
145
|
this.onRecordingComplete = undefined;
|
|
135
146
|
}
|
|
136
147
|
|
|
148
|
+
/**
|
|
149
|
+
* Scarta il segmento WebM in corso (nessun upload/STT) senza chiudere VAD, mic o sessione.
|
|
150
|
+
* Lo stream resta in ascolto per il prossimo `onSpeechStart`.
|
|
151
|
+
*/
|
|
152
|
+
discardCurrentRecordingSegment(): void {
|
|
153
|
+
if (this.mediaRecorder) {
|
|
154
|
+
this.mediaRecorder.onstop = null;
|
|
155
|
+
this.mediaRecorder.ondataavailable = null;
|
|
156
|
+
if (this.mediaRecorder.state === 'recording') {
|
|
157
|
+
this.mediaRecorder.stop();
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
this.mediaRecorder = undefined;
|
|
161
|
+
this.audioChunks = [];
|
|
162
|
+
this.logger.log('[VoiceService] discarded in-progress segment; VAD session unchanged');
|
|
163
|
+
}
|
|
164
|
+
|
|
137
165
|
/**
|
|
138
166
|
* 🎧 AUDIO ANALYSER INIT
|
|
139
167
|
*/
|
|
@@ -161,7 +189,9 @@ export class VoiceService {
|
|
|
161
189
|
return;
|
|
162
190
|
}
|
|
163
191
|
|
|
164
|
-
this.analyser.getByteFrequencyData(
|
|
192
|
+
this.analyser.getByteFrequencyData(
|
|
193
|
+
this.dataArray as Parameters<AnalyserNode['getByteFrequencyData']>[0],
|
|
194
|
+
);
|
|
165
195
|
|
|
166
196
|
let sum = 0;
|
|
167
197
|
for (let i = 0; i < this.dataArray.length; i++) {
|
|
@@ -38,6 +38,8 @@
|
|
|
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;
|
|
42
|
+
--chat-footer-stream-button-padding: 10px 0;
|
|
41
43
|
--chat-footer-background-color: #f6f7fb;
|
|
42
44
|
--chat-footer-color: #1a1a1a;
|
|
43
45
|
--chat-footer-border-color-error: #aa0404;
|
package/src/launch.js
CHANGED
|
@@ -218,67 +218,76 @@ function loadIframe(tiledeskScriptBaseLocation) {
|
|
|
218
218
|
iDiv.appendChild(ifrm);
|
|
219
219
|
|
|
220
220
|
// Funzione helper per caricare iframe con fallback per compatibilità CSP (Wix, etc.)
|
|
221
|
-
//
|
|
222
|
-
|
|
223
|
-
|
|
221
|
+
// Priorità: document.write / srcdoc prima della Blob URL. Le Blob URL spesso danno origine opaca
|
|
222
|
+
// (blob:null): l'iframe non può leggere window.parent.tiledeskSettings → projectid mancante.
|
|
223
|
+
function loadIframeContent(iframe, htmlContent) {
|
|
224
224
|
var blobUrl = null;
|
|
225
|
-
|
|
226
|
-
//
|
|
227
|
-
|
|
225
|
+
|
|
226
|
+
// 1) document.write: iframe stessa origine della pagina host → tiledeskSettings sul parent accessibile
|
|
227
|
+
try {
|
|
228
|
+
var cw = iframe.contentWindow;
|
|
229
|
+
if (cw && cw.document) {
|
|
230
|
+
cw.document.open();
|
|
231
|
+
cw.document.write(htmlContent);
|
|
232
|
+
cw.document.close();
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
} catch (e) {
|
|
236
|
+
console.warn('[Tiledesk] iframe document.write failed, trying srcdoc/blob:', e);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// 2) srcdoc: stessa origine del parent (HTML5); utile se document.write è bloccato
|
|
240
|
+
if ('srcdoc' in iframe) {
|
|
241
|
+
try {
|
|
242
|
+
iframe.srcdoc = htmlContent;
|
|
243
|
+
return;
|
|
244
|
+
} catch (e) {
|
|
245
|
+
console.warn('[Tiledesk] iframe srcdoc failed, trying blob:', e);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// 3) Blob URL (spesso permesso da CSP dove srcdoc/write no; può rompere lettura parent.tiledeskSettings)
|
|
228
250
|
if (typeof Blob !== 'undefined' && typeof URL !== 'undefined' && URL.createObjectURL) {
|
|
229
251
|
try {
|
|
230
252
|
var blob = new Blob([htmlContent], { type: 'text/html;charset=utf-8' });
|
|
231
253
|
blobUrl = URL.createObjectURL(blob);
|
|
232
254
|
iframe.src = blobUrl;
|
|
233
|
-
|
|
234
|
-
// Cleanup del blob URL dopo il caricamento per liberare memoria
|
|
255
|
+
|
|
235
256
|
var originalOnload = iframe.onload;
|
|
236
257
|
iframe.onload = function() {
|
|
237
|
-
// Revoca il blob URL dopo un delay per assicurarsi che tutto sia caricato
|
|
238
258
|
setTimeout(function() {
|
|
239
259
|
if (blobUrl) {
|
|
240
260
|
try {
|
|
241
261
|
URL.revokeObjectURL(blobUrl);
|
|
242
262
|
blobUrl = null;
|
|
243
|
-
} catch(
|
|
244
|
-
console.warn('Error revoking blob URL:',
|
|
263
|
+
} catch (err) {
|
|
264
|
+
console.warn('Error revoking blob URL:', err);
|
|
245
265
|
}
|
|
246
266
|
}
|
|
247
267
|
}, 1000);
|
|
248
268
|
if (originalOnload) originalOnload.call(this);
|
|
249
269
|
};
|
|
250
|
-
return;
|
|
251
|
-
} catch(e) {
|
|
252
|
-
console.warn('Blob URL not available
|
|
270
|
+
return;
|
|
271
|
+
} catch (e) {
|
|
272
|
+
console.warn('Blob URL not available:', e);
|
|
253
273
|
}
|
|
254
274
|
}
|
|
255
|
-
|
|
256
|
-
//
|
|
257
|
-
|
|
258
|
-
if (!isLocalhost && 'srcdoc' in iframe) {
|
|
259
|
-
try {
|
|
260
|
-
iframe.srcdoc = htmlContent;
|
|
261
|
-
return; // srcdoc impostato
|
|
262
|
-
} catch(e) {
|
|
263
|
-
console.warn('srcdoc not allowed, trying document.write:', e);
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
// Metodo 3: document.write (fallback finale, funziona su localhost e browser vecchi)
|
|
268
|
-
if (isLocalhost || (iframe.contentWindow && iframe.contentWindow.document)) {
|
|
275
|
+
|
|
276
|
+
// 4) Ultimo tentativo document.write (iframe magari non pronto al primo passo)
|
|
277
|
+
if (iframe.contentWindow && iframe.contentWindow.document) {
|
|
269
278
|
try {
|
|
270
279
|
iframe.contentWindow.document.open();
|
|
271
280
|
iframe.contentWindow.document.write(htmlContent);
|
|
272
281
|
iframe.contentWindow.document.close();
|
|
273
|
-
return;
|
|
274
|
-
} catch(e) {
|
|
275
|
-
console.error('All iframe loading methods failed:', e);
|
|
282
|
+
return;
|
|
283
|
+
} catch (e) {
|
|
284
|
+
console.error('[Tiledesk] All iframe loading methods failed:', e);
|
|
276
285
|
}
|
|
277
286
|
}
|
|
278
287
|
}
|
|
279
288
|
|
|
280
289
|
// Carica il contenuto dell'iframe con fallback automatico
|
|
281
|
-
loadIframeContent(ifrm, srcTileDesk
|
|
290
|
+
loadIframeContent(ifrm, srcTileDesk);
|
|
282
291
|
|
|
283
292
|
|
|
284
293
|
}
|
package/src/launch_template.js
CHANGED
|
@@ -219,67 +219,76 @@ function loadIframe(tiledeskScriptBaseLocation) {
|
|
|
219
219
|
iDiv.appendChild(ifrm);
|
|
220
220
|
|
|
221
221
|
// Funzione helper per caricare iframe con fallback per compatibilità CSP (Wix, etc.)
|
|
222
|
-
//
|
|
223
|
-
|
|
224
|
-
|
|
222
|
+
// Priorità: document.write / srcdoc prima della Blob URL. Le Blob URL spesso danno origine opaca
|
|
223
|
+
// (blob:null): l'iframe non può leggere window.parent.tiledeskSettings → projectid mancante.
|
|
224
|
+
function loadIframeContent(iframe, htmlContent) {
|
|
225
225
|
var blobUrl = null;
|
|
226
|
-
|
|
227
|
-
//
|
|
228
|
-
|
|
226
|
+
|
|
227
|
+
// 1) document.write: iframe stessa origine della pagina host → tiledeskSettings sul parent accessibile
|
|
228
|
+
try {
|
|
229
|
+
var cw = iframe.contentWindow;
|
|
230
|
+
if (cw && cw.document) {
|
|
231
|
+
cw.document.open();
|
|
232
|
+
cw.document.write(htmlContent);
|
|
233
|
+
cw.document.close();
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
} catch (e) {
|
|
237
|
+
console.warn('[Tiledesk] iframe document.write failed, trying srcdoc/blob:', e);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// 2) srcdoc: stessa origine del parent (HTML5); utile se document.write è bloccato
|
|
241
|
+
if ('srcdoc' in iframe) {
|
|
242
|
+
try {
|
|
243
|
+
iframe.srcdoc = htmlContent;
|
|
244
|
+
return;
|
|
245
|
+
} catch (e) {
|
|
246
|
+
console.warn('[Tiledesk] iframe srcdoc failed, trying blob:', e);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// 3) Blob URL (spesso permesso da CSP dove srcdoc/write no; può rompere lettura parent.tiledeskSettings)
|
|
229
251
|
if (typeof Blob !== 'undefined' && typeof URL !== 'undefined' && URL.createObjectURL) {
|
|
230
252
|
try {
|
|
231
253
|
var blob = new Blob([htmlContent], { type: 'text/html;charset=utf-8' });
|
|
232
254
|
blobUrl = URL.createObjectURL(blob);
|
|
233
255
|
iframe.src = blobUrl;
|
|
234
|
-
|
|
235
|
-
// Cleanup del blob URL dopo il caricamento per liberare memoria
|
|
256
|
+
|
|
236
257
|
var originalOnload = iframe.onload;
|
|
237
258
|
iframe.onload = function() {
|
|
238
|
-
// Revoca il blob URL dopo un delay per assicurarsi che tutto sia caricato
|
|
239
259
|
setTimeout(function() {
|
|
240
260
|
if (blobUrl) {
|
|
241
261
|
try {
|
|
242
262
|
URL.revokeObjectURL(blobUrl);
|
|
243
263
|
blobUrl = null;
|
|
244
|
-
} catch(
|
|
245
|
-
console.warn('Error revoking blob URL:',
|
|
264
|
+
} catch (err) {
|
|
265
|
+
console.warn('Error revoking blob URL:', err);
|
|
246
266
|
}
|
|
247
267
|
}
|
|
248
268
|
}, 1000);
|
|
249
269
|
if (originalOnload) originalOnload.call(this);
|
|
250
270
|
};
|
|
251
|
-
return;
|
|
252
|
-
} catch(e) {
|
|
253
|
-
console.warn('Blob URL not available
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
// Metodo 2: srcdoc (fallback se Blob URL non disponibile)
|
|
258
|
-
// Skip per localhost (usa document.write per compatibilità sviluppo)
|
|
259
|
-
if (!isLocalhost && 'srcdoc' in iframe) {
|
|
260
|
-
try {
|
|
261
|
-
iframe.srcdoc = htmlContent;
|
|
262
|
-
return; // srcdoc impostato
|
|
263
|
-
} catch(e) {
|
|
264
|
-
console.warn('srcdoc not allowed, trying document.write:', e);
|
|
271
|
+
return;
|
|
272
|
+
} catch (e) {
|
|
273
|
+
console.warn('Blob URL not available:', e);
|
|
265
274
|
}
|
|
266
275
|
}
|
|
267
|
-
|
|
268
|
-
//
|
|
269
|
-
if (
|
|
276
|
+
|
|
277
|
+
// 4) Ultimo tentativo document.write (iframe magari non pronto al primo passo)
|
|
278
|
+
if (iframe.contentWindow && iframe.contentWindow.document) {
|
|
270
279
|
try {
|
|
271
280
|
iframe.contentWindow.document.open();
|
|
272
281
|
iframe.contentWindow.document.write(htmlContent);
|
|
273
282
|
iframe.contentWindow.document.close();
|
|
274
|
-
return;
|
|
275
|
-
} catch(e) {
|
|
276
|
-
console.error('All iframe loading methods failed:', e);
|
|
283
|
+
return;
|
|
284
|
+
} catch (e) {
|
|
285
|
+
console.error('[Tiledesk] All iframe loading methods failed:', e);
|
|
277
286
|
}
|
|
278
287
|
}
|
|
279
288
|
}
|
|
280
289
|
|
|
281
290
|
// Carica il contenuto dell'iframe con fallback automatico
|
|
282
|
-
loadIframeContent(ifrm, srcTileDesk
|
|
291
|
+
loadIframeContent(ifrm, srcTileDesk);
|
|
283
292
|
|
|
284
293
|
|
|
285
294
|
}
|
package/deploy_amazon_prod.sh
DELETED
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
# npm version patch
|
|
2
|
-
version=`node -e 'console.log(require("./package.json").version)'`
|
|
3
|
-
echo "version $version"
|
|
4
|
-
|
|
5
|
-
npm i
|
|
6
|
-
|
|
7
|
-
cp src/environments/real_data/environment.prod.ts src/environments/environment.prod.ts
|
|
8
|
-
|
|
9
|
-
# --build-optimizer=false if localstorage is disabled (webview) appears https://github.com/firebase/angularfire/issues/970
|
|
10
|
-
ng build --configuration="prod" --aot=true
|
|
11
|
-
##--base-href='./v5/' --output-hashing none
|
|
12
|
-
|
|
13
|
-
### SET HASHING : START ###
|
|
14
|
-
cp ./src/launch_template.js ./dist/browser/launch.js
|
|
15
|
-
node ./src/build_launch.js
|
|
16
|
-
### SET HASHING : END ###
|
|
17
|
-
|
|
18
|
-
#### FIREBASE #####
|
|
19
|
-
# cd dist
|
|
20
|
-
# # aws s3 sync . s3://tiledesk-widget/v5/latest/
|
|
21
|
-
# aws s3 sync . s3://tiledesk-widget/v5/$version/ --cache-control max-age=300
|
|
22
|
-
# aws s3 sync . s3://tiledesk-widget/v5/ --cache-control max-age=300
|
|
23
|
-
# cd ..
|
|
24
|
-
|
|
25
|
-
# #### MQTT #####
|
|
26
|
-
cd dist/browser
|
|
27
|
-
# aws s3 sync . s3://tiledesk-widget/v5/latest/
|
|
28
|
-
aws s3 sync . s3://tiledesk-widget/v6/$version/ --cache-control max-age=86400 --exclude='launch.js' #8days
|
|
29
|
-
aws s3 sync . s3://tiledesk-widget/v6/$version/ --cache-control "no-store,no-cache,private" --exclude='*' --include='launch.js'
|
|
30
|
-
aws s3 sync . s3://tiledesk-widget/v6/ --cache-control max-age=86400 --exclude='launch.js' #8days
|
|
31
|
-
aws s3 sync . s3://tiledesk-widget/v6/ --cache-control "no-store,no-cache,private" --exclude='*' --include='launch.js'
|
|
32
|
-
cd ../..
|
|
33
|
-
|
|
34
|
-
aws cloudfront create-invalidation --distribution-id E3EJDWEHY08CZZ --paths "/*"
|
|
35
|
-
|
|
36
|
-
git restore src/environments/environment.prod.ts
|
|
37
|
-
|
|
38
|
-
echo new version deployed $version on s3://tiledesk-widget/v6
|
|
39
|
-
echo available on https://s3.eu-west-1.amazonaws.com/tiledesk-widget/v6/index.html
|
|
40
|
-
echo https://widget.tiledesk.com/v6/index.html
|
|
41
|
-
echo https://widget.tiledesk.com/v6/$version/index.html
|