@chat21/chat21-web-widget 5.1.30 → 5.1.32-rc2
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/.github/workflows/docker-community-push-latest.yml +23 -13
- package/.github/workflows/docker-image-tag-community-tag-push.yml +22 -12
- package/CHANGELOG.md +41 -2
- package/Dockerfile +4 -5
- package/angular.json +5 -2
- package/deploy_amazon_beta.sh +17 -7
- package/docs/changelog/this-branch.md +36 -0
- package/package.json +4 -1
- package/src/app/app.component.ts +10 -9
- package/src/app/app.module.ts +9 -0
- package/src/app/component/conversation-detail/conversation/conversation.component.html +7 -1
- package/src/app/component/conversation-detail/conversation/conversation.component.scss +2 -2
- package/src/app/component/conversation-detail/conversation/conversation.component.ts +34 -5
- 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 +1 -1
- package/src/app/component/conversation-detail/conversation-content/conversation-content.component.ts +2 -0
- package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.html +146 -79
- package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.scss +131 -13
- package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.ts +108 -7
- package/src/app/component/last-message/last-message.component.ts +4 -1
- package/src/app/component/message/audio/audio.component.ts +0 -5
- package/src/app/component/message/audio-sync/audio-sync.component.html +19 -0
- package/src/app/component/message/audio-sync/audio-sync.component.scss +65 -0
- package/src/app/component/message/audio-sync/audio-sync.component.spec.ts +23 -0
- package/src/app/component/message/audio-sync/audio-sync.component.ts +197 -0
- package/src/app/component/message/bubble-message/bubble-message.component.html +6 -1
- package/src/app/component/message/bubble-message/bubble-message.component.ts +2 -1
- package/src/app/providers/global-settings.service.ts +21 -0
- package/src/app/providers/translator.service.ts +2 -0
- package/src/app/providers/voice/STT&TTS/openai-voice.config.ts +12 -0
- package/src/app/providers/voice/STT&TTS/openai-voice.provider.ts +171 -0
- package/src/app/providers/voice/STT&TTS/speech-provider.abstract.ts +39 -0
- package/src/app/providers/voice/audio.types.ts +34 -0
- package/src/app/providers/voice/vad.service.spec.ts +28 -0
- package/src/app/providers/voice/vad.service.ts +70 -0
- package/src/app/providers/voice/voice.service.spec.ts +60 -0
- package/src/app/providers/voice/voice.service.ts +264 -0
- package/src/app/sass/_variables.scss +1 -0
- package/src/app/shims/onnxruntime-web-wasm.ts +4 -0
- package/src/app/utils/conversation-sender-classifier.ts +21 -0
- package/src/app/utils/globals.ts +7 -1
- package/src/assets/i18n/en.json +1 -0
- package/src/assets/i18n/es.json +1 -0
- package/src/assets/i18n/fr.json +1 -0
- package/src/assets/i18n/it.json +1 -0
- package/src/assets/onnx/ort-wasm-simd-threaded.mjs +59 -0
- package/src/assets/onnx/ort-wasm-simd-threaded.wasm +0 -0
- package/src/assets/vad/silero_vad_legacy.onnx +0 -0
- package/src/assets/vad/vad.worklet.bundle.min.js +1 -0
- package/src/chat21-core/models/message.ts +2 -1
- package/src/chat21-core/providers/firebase/firebase-conversation-handler.ts +3 -2
- package/src/chat21-core/providers/mqtt/mqtt-conversation-handler.ts +12 -0
- package/src/chat21-core/providers/tiledesk/tiledesk-requests.service.ts +1 -1
- package/src/chat21-core/utils/utils-message.ts +7 -0
- package/src/chat21-core/utils/utils.ts +5 -2
- package/tsconfig.json +5 -0
|
@@ -154,15 +154,10 @@ export class AudioComponent implements AfterViewInit {
|
|
|
154
154
|
// });
|
|
155
155
|
|
|
156
156
|
const response = await fetch(this.rawAudioUrl!);
|
|
157
|
-
this.logger.debug('getAudioDuration: response ---> ', response)
|
|
158
157
|
const arrayBuffer = await response.arrayBuffer();
|
|
159
|
-
this.logger.debug('getAudioDuration: arrayBuffer ---> ', arrayBuffer)
|
|
160
158
|
const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
|
|
161
|
-
this.logger.debug('getAudioDuration: audioContext ---> ', audioContext)
|
|
162
159
|
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
|
|
163
|
-
this.logger.debug('getAudioDuration: audioBuffer ---> ', audioBuffer)
|
|
164
160
|
this.audioDuration = audioBuffer.duration;
|
|
165
|
-
this.logger.debug('getAudioDuration: audioDuration ---> ', this.audioDuration)
|
|
166
161
|
|
|
167
162
|
}
|
|
168
163
|
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
<div class="lyrics-container">
|
|
2
|
+
|
|
3
|
+
<audio
|
|
4
|
+
#audioPlayer
|
|
5
|
+
[src]="message?.metadata?.src"
|
|
6
|
+
(timeupdate)="onTimeUpdate()"
|
|
7
|
+
style="display:none">
|
|
8
|
+
</audio>
|
|
9
|
+
|
|
10
|
+
<p class="lyrics message_innerhtml marked" #transcriptBox [style.color]="color">
|
|
11
|
+
<span
|
|
12
|
+
*ngFor="let w of words; let i = index; trackBy: trackByIndex"
|
|
13
|
+
class="word"
|
|
14
|
+
[ngClass]="w.state">
|
|
15
|
+
{{ w.text }}
|
|
16
|
+
</span>
|
|
17
|
+
</p>
|
|
18
|
+
|
|
19
|
+
</div>
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
:host {
|
|
2
|
+
display: block;
|
|
3
|
+
font-size: var(--font-size-bubble-message, 14px);
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
/* Allineato a text.component.scss (.message_innerhtml, p) */
|
|
7
|
+
.message_innerhtml {
|
|
8
|
+
margin: 0;
|
|
9
|
+
|
|
10
|
+
&.marked {
|
|
11
|
+
padding: 12px 16px;
|
|
12
|
+
margin-block-start: 0em !important;
|
|
13
|
+
margin-block-end: 0em !important;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.lyrics {
|
|
18
|
+
font-size: inherit;
|
|
19
|
+
margin: 0;
|
|
20
|
+
line-height: 1.4em;
|
|
21
|
+
font-style: normal;
|
|
22
|
+
letter-spacing: normal;
|
|
23
|
+
font-stretch: normal;
|
|
24
|
+
font-variant: normal;
|
|
25
|
+
font-weight: 300;
|
|
26
|
+
overflow: hidden;
|
|
27
|
+
|
|
28
|
+
display: flex;
|
|
29
|
+
flex-wrap: wrap;
|
|
30
|
+
gap: 6px;
|
|
31
|
+
/* Colore bubble: da [style.color] / @Input() color — ereditato dalle .word */
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/* base word */
|
|
35
|
+
.word {
|
|
36
|
+
transition:
|
|
37
|
+
transform 0.3s cubic-bezier(0.2, 0.8, 0.2, 1),
|
|
38
|
+
color 0.3s ease,
|
|
39
|
+
opacity 0.3s ease,
|
|
40
|
+
filter 0.3s ease;
|
|
41
|
+
|
|
42
|
+
will-change: transform;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/* FUTURE */
|
|
46
|
+
.word.future {
|
|
47
|
+
opacity: 0;
|
|
48
|
+
transform: scale(0.98);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/* PAST: stesso colore del testo bubble (@Input color sul <p>) */
|
|
52
|
+
.word.past {
|
|
53
|
+
opacity: 1;
|
|
54
|
+
color: inherit;
|
|
55
|
+
transform: scale(1);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/* ACTIVE (solo momentaneo, tipo “karaoke flash”) */
|
|
59
|
+
.word.active {
|
|
60
|
+
opacity: 1;
|
|
61
|
+
color: #00c3ff;
|
|
62
|
+
font-weight: 700;
|
|
63
|
+
transform: scale(1.18);
|
|
64
|
+
text-shadow: 0 0 10px rgba(0, 195, 255, 0.35);
|
|
65
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
|
2
|
+
|
|
3
|
+
import { AudioSyncComponent } from './audio-sync.component';
|
|
4
|
+
|
|
5
|
+
describe('AudioSyncComponent', () => {
|
|
6
|
+
let component: AudioSyncComponent;
|
|
7
|
+
let fixture: ComponentFixture<AudioSyncComponent>;
|
|
8
|
+
|
|
9
|
+
beforeEach(async () => {
|
|
10
|
+
await TestBed.configureTestingModule({
|
|
11
|
+
imports: [AudioSyncComponent]
|
|
12
|
+
})
|
|
13
|
+
.compileComponents();
|
|
14
|
+
|
|
15
|
+
fixture = TestBed.createComponent(AudioSyncComponent);
|
|
16
|
+
component = fixture.componentInstance;
|
|
17
|
+
fixture.detectChanges();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('should create', () => {
|
|
21
|
+
expect(component).toBeTruthy();
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AfterViewInit,
|
|
3
|
+
ChangeDetectorRef,
|
|
4
|
+
Component,
|
|
5
|
+
ElementRef,
|
|
6
|
+
Input,
|
|
7
|
+
OnChanges,
|
|
8
|
+
OnDestroy,
|
|
9
|
+
SimpleChanges,
|
|
10
|
+
ViewChild,
|
|
11
|
+
} from '@angular/core';
|
|
12
|
+
import { MessageModel } from 'src/chat21-core/models/message';
|
|
13
|
+
|
|
14
|
+
/** HAVE_METADATA: metadati già disponibili (tipico audio servito da cache). */
|
|
15
|
+
const HAVE_METADATA = 1;
|
|
16
|
+
|
|
17
|
+
@Component({
|
|
18
|
+
selector: 'chat-audio-sync',
|
|
19
|
+
templateUrl: './audio-sync.component.html',
|
|
20
|
+
styleUrl: './audio-sync.component.scss',
|
|
21
|
+
})
|
|
22
|
+
export class AudioSyncComponent implements AfterViewInit, OnChanges, OnDestroy {
|
|
23
|
+
@Input() message: MessageModel | null = null;
|
|
24
|
+
@Input() color?: string;
|
|
25
|
+
|
|
26
|
+
@ViewChild('audioPlayer') audioRef!: ElementRef<HTMLAudioElement>;
|
|
27
|
+
@ViewChild('transcriptBox') transcriptBox!: ElementRef<HTMLElement>;
|
|
28
|
+
|
|
29
|
+
words: {
|
|
30
|
+
text: string;
|
|
31
|
+
start: number;
|
|
32
|
+
end: number;
|
|
33
|
+
state: 'future' | 'active' | 'past';
|
|
34
|
+
}[] = [];
|
|
35
|
+
|
|
36
|
+
currentTime = 0;
|
|
37
|
+
duration = 1;
|
|
38
|
+
activeIndex = -1;
|
|
39
|
+
|
|
40
|
+
private timingReady = false;
|
|
41
|
+
private onMetadataLoaded: () => void;
|
|
42
|
+
private onPlaybackEnded: () => void;
|
|
43
|
+
|
|
44
|
+
constructor(private readonly cdr: ChangeDetectorRef) {}
|
|
45
|
+
|
|
46
|
+
/** `false` = messaggio già in storico: niente autoplay / karaoke. Da `message.isJustRecived`. */
|
|
47
|
+
private get skipSyncAnimation(): boolean {
|
|
48
|
+
return this.message?.isJustRecived === false;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
ngOnChanges(changes: SimpleChanges): void {
|
|
52
|
+
if (!changes['message']) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (this.audioRef?.nativeElement && this.timingReady) {
|
|
56
|
+
this.duration = this.audioRef.nativeElement.duration || 1;
|
|
57
|
+
this.buildFakeTiming();
|
|
58
|
+
if (this.skipSyncAnimation) {
|
|
59
|
+
this.markAllWordsPast();
|
|
60
|
+
} else {
|
|
61
|
+
this.syncStatesFromCurrentTime();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
ngAfterViewInit(): void {
|
|
67
|
+
const audio = this.audioRef.nativeElement;
|
|
68
|
+
|
|
69
|
+
this.onPlaybackEnded = () => {
|
|
70
|
+
if (this.skipSyncAnimation) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
this.markAllWordsPast();
|
|
74
|
+
if (this.message) {
|
|
75
|
+
this.message.isJustRecived = false;
|
|
76
|
+
}
|
|
77
|
+
this.cdr.detectChanges();
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
this.onMetadataLoaded = () => {
|
|
81
|
+
if (this.timingReady) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
this.timingReady = true;
|
|
85
|
+
this.duration = audio.duration || 1;
|
|
86
|
+
this.buildFakeTiming();
|
|
87
|
+
if (this.skipSyncAnimation) {
|
|
88
|
+
this.markAllWordsPast();
|
|
89
|
+
this.cdr.detectChanges();
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
this.syncStatesFromCurrentTime();
|
|
93
|
+
this.cdr.detectChanges();
|
|
94
|
+
|
|
95
|
+
setTimeout(() => {
|
|
96
|
+
audio.play().catch(() => {
|
|
97
|
+
this.syncStatesFromCurrentTime();
|
|
98
|
+
this.cdr.detectChanges();
|
|
99
|
+
});
|
|
100
|
+
}, 200);
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
audio.addEventListener('loadedmetadata', this.onMetadataLoaded);
|
|
104
|
+
audio.addEventListener('ended', this.onPlaybackEnded);
|
|
105
|
+
|
|
106
|
+
if (audio.readyState >= HAVE_METADATA) {
|
|
107
|
+
this.onMetadataLoaded();
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
ngOnDestroy(): void {
|
|
112
|
+
const audio = this.audioRef?.nativeElement;
|
|
113
|
+
if (!audio) {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (this.onMetadataLoaded) {
|
|
117
|
+
audio.removeEventListener('loadedmetadata', this.onMetadataLoaded);
|
|
118
|
+
}
|
|
119
|
+
if (this.onPlaybackEnded) {
|
|
120
|
+
audio.removeEventListener('ended', this.onPlaybackEnded);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private markAllWordsPast(): void {
|
|
125
|
+
this.words.forEach((w) => {
|
|
126
|
+
w.state = 'past';
|
|
127
|
+
});
|
|
128
|
+
this.activeIndex = -1;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
buildFakeTiming(): void {
|
|
132
|
+
const rawWords = (this.message?.text || '')
|
|
133
|
+
.trim()
|
|
134
|
+
.split(/\s+/)
|
|
135
|
+
.filter((w) => w.length > 0);
|
|
136
|
+
if (rawWords.length === 0) {
|
|
137
|
+
this.words = [];
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const step = this.duration / rawWords.length;
|
|
141
|
+
|
|
142
|
+
this.words = rawWords.map((w, i) => ({
|
|
143
|
+
text: w,
|
|
144
|
+
start: i * step,
|
|
145
|
+
end: (i + 1) * step,
|
|
146
|
+
state: 'future' as const,
|
|
147
|
+
}));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
syncStatesFromCurrentTime(): void {
|
|
151
|
+
if (this.skipSyncAnimation) {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
const audio = this.audioRef?.nativeElement;
|
|
155
|
+
if (!audio || this.words.length === 0) {
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
this.currentTime = audio.currentTime;
|
|
159
|
+
let newActiveIndex = -1;
|
|
160
|
+
|
|
161
|
+
this.words.forEach((w, i) => {
|
|
162
|
+
if (this.currentTime >= w.end) {
|
|
163
|
+
w.state = 'past';
|
|
164
|
+
} else if (this.currentTime >= w.start && this.currentTime < w.end) {
|
|
165
|
+
w.state = 'active';
|
|
166
|
+
newActiveIndex = i;
|
|
167
|
+
} else {
|
|
168
|
+
w.state = 'future';
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
if (newActiveIndex !== this.activeIndex) {
|
|
173
|
+
this.activeIndex = newActiveIndex;
|
|
174
|
+
this.scrollToActive();
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
onTimeUpdate(): void {
|
|
179
|
+
this.syncStatesFromCurrentTime();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
scrollToActive(): void {
|
|
183
|
+
const container = this.transcriptBox?.nativeElement;
|
|
184
|
+
const active = container?.querySelector('.active') as HTMLElement;
|
|
185
|
+
|
|
186
|
+
if (active) {
|
|
187
|
+
active.scrollIntoView({
|
|
188
|
+
behavior: 'smooth',
|
|
189
|
+
block: 'center',
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
trackByIndex(index: number): number {
|
|
195
|
+
return index;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
@@ -64,6 +64,11 @@
|
|
|
64
64
|
[stylesMap]="stylesMap">
|
|
65
65
|
</chat-audio>
|
|
66
66
|
|
|
67
|
+
<chat-audio-sync *ngIf="isAudioTTS(message)"
|
|
68
|
+
[message]="message"
|
|
69
|
+
[color]="fontColor">
|
|
70
|
+
</chat-audio-sync>
|
|
71
|
+
|
|
67
72
|
|
|
68
73
|
<!-- <chat-frame *ngIf="message.metadata && message.metadata.type && message.metadata.type.includes('video')"
|
|
69
74
|
[metadata]="message.metadata"
|
|
@@ -75,7 +80,7 @@
|
|
|
75
80
|
<!-- <div *ngIf="message.type == 'text'"> -->
|
|
76
81
|
|
|
77
82
|
<!-- tooltip="{{message.timestamp | dateAgo}} ({{message.timestamp | date:'shortDate'}} {{message.timestamp | date:'HH:mm:ss'}})" placement="bottom" -->
|
|
78
|
-
<div *ngIf="message?.text && !isAudio(message)" >
|
|
83
|
+
<div *ngIf="message?.text && (!isAudio(message) && !isAudioTTS(message))" >
|
|
79
84
|
|
|
80
85
|
<!-- [htmlEnabled]="(message?.type==='html')? true : false" -->
|
|
81
86
|
<chat-text *ngIf="message?.type !=='html'"
|
|
@@ -5,7 +5,7 @@ import { LoggerService } from 'src/chat21-core/providers/abstract/logger.service
|
|
|
5
5
|
import { LoggerInstance } from 'src/chat21-core/providers/logger/loggerInstance';
|
|
6
6
|
import { MAX_WIDTH_IMAGES, MESSAGE_TYPE_MINE, MESSAGE_TYPE_OTHERS, MIN_WIDTH_IMAGES } from 'src/chat21-core/utils/constants';
|
|
7
7
|
import { convertColorToRGBA } from 'src/chat21-core/utils/utils';
|
|
8
|
-
import { isAudio, isFile, isFrame, isImage, messageType } from 'src/chat21-core/utils/utils-message';
|
|
8
|
+
import { isAudio, isAudioTTS, isFile, isFrame, isImage, messageType } from 'src/chat21-core/utils/utils-message';
|
|
9
9
|
import { getColorBck } from 'src/chat21-core/utils/utils-user';
|
|
10
10
|
|
|
11
11
|
@Component({
|
|
@@ -26,6 +26,7 @@ export class BubbleMessageComponent implements OnInit {
|
|
|
26
26
|
isFile = isFile;
|
|
27
27
|
isFrame = isFrame;
|
|
28
28
|
isAudio = isAudio;
|
|
29
|
+
isAudioTTS=isAudioTTS;
|
|
29
30
|
convertColorToRGBA = convertColorToRGBA
|
|
30
31
|
|
|
31
32
|
// ========== begin:: check message type functions ======= //
|
|
@@ -1125,11 +1125,22 @@ export class GlobalSettingsService {
|
|
|
1125
1125
|
if (TEMP !== undefined) {
|
|
1126
1126
|
globals.showAudioRecorderFooterButton = (TEMP === true) ? true : false;
|
|
1127
1127
|
}
|
|
1128
|
+
TEMP = tiledeskSettings['showAudioStreamFooterButton'];
|
|
1129
|
+
// this.logger.debug('[GLOBAL-SET] setVariablesFromSettings > showAudioStreamFooterButton:: ', TEMP]);
|
|
1130
|
+
if (TEMP !== undefined) {
|
|
1131
|
+
globals.showAudioStreamFooterButton = (TEMP === true) ? true : false;
|
|
1132
|
+
}
|
|
1128
1133
|
TEMP = tiledeskSettings['size'];
|
|
1129
1134
|
// this.logger.debug('[GLOBAL-SET] setVariablesFromSettings > size:: ', TEMP]);
|
|
1130
1135
|
if (TEMP !== undefined) {
|
|
1131
1136
|
globals.size = TEMP;
|
|
1132
1137
|
}
|
|
1138
|
+
|
|
1139
|
+
TEMP = tiledeskSettings['closeChatInConversation'];
|
|
1140
|
+
// this.logger.debug('[GLOBAL-SET] setVariablesFromSettings > closeChatInConversation:: ', TEMP]);
|
|
1141
|
+
if (TEMP !== undefined) {
|
|
1142
|
+
globals.closeChatInConversation = (TEMP === true) ? true : false;
|
|
1143
|
+
}
|
|
1133
1144
|
}
|
|
1134
1145
|
|
|
1135
1146
|
/**
|
|
@@ -1867,6 +1878,11 @@ export class GlobalSettingsService {
|
|
|
1867
1878
|
globals.showAttachmentFooterButton = stringToBoolean(TEMP);
|
|
1868
1879
|
}
|
|
1869
1880
|
|
|
1881
|
+
TEMP = getParameterByName(windowContext, 'tiledesk_showAudioStreamFooterButton');
|
|
1882
|
+
if (TEMP) {
|
|
1883
|
+
globals.showAudioStreamFooterButton = stringToBoolean(TEMP);
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1870
1886
|
TEMP = getParameterByName(windowContext, 'tiledesk_showEmojiFooterButton');
|
|
1871
1887
|
if (TEMP) {
|
|
1872
1888
|
globals.showEmojiFooterButton = stringToBoolean(TEMP);
|
|
@@ -1876,6 +1892,11 @@ export class GlobalSettingsService {
|
|
|
1876
1892
|
if (TEMP) {
|
|
1877
1893
|
globals.size = TEMP;
|
|
1878
1894
|
}
|
|
1895
|
+
|
|
1896
|
+
TEMP = getParameterByName(windowContext, 'tiledesk_closeChatInConversation');
|
|
1897
|
+
if (TEMP) {
|
|
1898
|
+
globals.closeChatInConversation = stringToBoolean(TEMP);
|
|
1899
|
+
}
|
|
1879
1900
|
|
|
1880
1901
|
}
|
|
1881
1902
|
|
|
@@ -302,6 +302,7 @@ export class TranslatorService {
|
|
|
302
302
|
'CLOSED',
|
|
303
303
|
'LABEL_PREVIEW',
|
|
304
304
|
'MAX_ATTACHMENT',
|
|
305
|
+
'MAX_ATTACHMENT_ERROR',
|
|
305
306
|
'EMOJI'
|
|
306
307
|
];
|
|
307
308
|
|
|
@@ -358,6 +359,7 @@ export class TranslatorService {
|
|
|
358
359
|
globals.LABEL_PREVIEW = res['LABEL_PREVIEW']
|
|
359
360
|
globals.LABEL_ERROR_FIELD_REQUIRED= res['LABEL_ERROR_FIELD_REQUIRED']
|
|
360
361
|
globals.MAX_ATTACHMENT = res['MAX_ATTACHMENT']
|
|
362
|
+
globals.MAX_ATTACHMENT_ERROR = res['MAX_ATTACHMENT_ERROR']
|
|
361
363
|
globals.EMOJI = res['EMOJI']
|
|
362
364
|
|
|
363
365
|
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configurazione opzionale per i servizi voce OpenAI (da `environment` o runtime).
|
|
3
|
+
*/
|
|
4
|
+
export interface OpenAiVoiceEnvironmentConfig {
|
|
5
|
+
/** Obbligatoria per chiamate API reali; se assente, STT/TTS non inviano richieste. */
|
|
6
|
+
apiKey?: string;
|
|
7
|
+
baseUrl?: string;
|
|
8
|
+
transcriptionModel?: string;
|
|
9
|
+
ttsModel?: string;
|
|
10
|
+
/** Voce predefinita TTS (es. `alloy`). */
|
|
11
|
+
ttsVoice?: string;
|
|
12
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { HttpClient, HttpErrorResponse, HttpHeaders } from '@angular/common/http';
|
|
2
|
+
import { Injectable } from '@angular/core';
|
|
3
|
+
import { firstValueFrom } from 'rxjs';
|
|
4
|
+
import { environment } from 'src/environments/environment';
|
|
5
|
+
|
|
6
|
+
import type { OpenAiVoiceEnvironmentConfig } from './openai-voice.config';
|
|
7
|
+
import {
|
|
8
|
+
SpeechToTextProvider,
|
|
9
|
+
TextToSpeechProvider,
|
|
10
|
+
type SpeechToTextRequest,
|
|
11
|
+
type SpeechToTextResult,
|
|
12
|
+
type TextToSpeechRequest,
|
|
13
|
+
type TextToSpeechResult,
|
|
14
|
+
} from './speech-provider.abstract';
|
|
15
|
+
import { AppConfigService } from '../../app-config.service';
|
|
16
|
+
|
|
17
|
+
const DEFAULT_BASE = 'https://api.openai.com/v1';
|
|
18
|
+
const DEFAULT_TRANSCRIPTION_MODEL = 'whisper-1';
|
|
19
|
+
const DEFAULT_TTS_MODEL = 'tts-1';
|
|
20
|
+
const DEFAULT_VOICE = 'alloy';
|
|
21
|
+
const DEFAULT_FORMAT = 'mp3';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Provider OpenAI unico: STT (Whisper) + TTS, entrambi via {@link HttpClient}.
|
|
25
|
+
*/
|
|
26
|
+
@Injectable({ providedIn: 'root' })
|
|
27
|
+
export class OpenAiVoiceProviderService extends SpeechToTextProvider implements TextToSpeechProvider {
|
|
28
|
+
constructor(
|
|
29
|
+
private readonly httpClient: HttpClient,
|
|
30
|
+
private readonly appConfig: AppConfigService
|
|
31
|
+
) {
|
|
32
|
+
super();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async transcribe(request: SpeechToTextRequest): Promise<SpeechToTextResult> {
|
|
36
|
+
const cfg = this.getConfig();
|
|
37
|
+
const apiKey = cfg.apiKey?.trim();
|
|
38
|
+
if (!apiKey) {
|
|
39
|
+
return { text: '' };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const base = (cfg.baseUrl ?? DEFAULT_BASE).replace(/\/$/, '');
|
|
43
|
+
const model = cfg.transcriptionModel ?? DEFAULT_TRANSCRIPTION_MODEL;
|
|
44
|
+
const url = `${base}/audio/transcriptions`;
|
|
45
|
+
|
|
46
|
+
const ext = this.extensionForMime(request.mimeType);
|
|
47
|
+
const file = new File([request.audio], `segment.${ext}`, { type: request.mimeType });
|
|
48
|
+
|
|
49
|
+
const form = new FormData();
|
|
50
|
+
form.append('file', file);
|
|
51
|
+
form.append('model', model);
|
|
52
|
+
if (request.language) {
|
|
53
|
+
form.append('language', request.language);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const headers = new HttpHeaders({
|
|
57
|
+
Authorization: `Bearer ${apiKey}`,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
const data = await firstValueFrom(
|
|
62
|
+
this.httpClient.post<{ text?: string }>(url, form, { headers }),
|
|
63
|
+
);
|
|
64
|
+
return { text: (data.text ?? '').trim() };
|
|
65
|
+
} catch (e) {
|
|
66
|
+
if (e instanceof HttpErrorResponse && e.error instanceof Blob) {
|
|
67
|
+
const errText = await e.error.text();
|
|
68
|
+
throw new Error(`OpenAI transcription ${e.status}: ${errText || e.statusText}`);
|
|
69
|
+
}
|
|
70
|
+
throw this.mapOpenAiHttpError(e);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async synthesize(request: TextToSpeechRequest): Promise<TextToSpeechResult> {
|
|
75
|
+
const cfg = this.getConfig();
|
|
76
|
+
const apiKey = cfg.apiKey?.trim();
|
|
77
|
+
if (!apiKey) {
|
|
78
|
+
throw new Error('OpenAI API key not configured (environment.openAiVoice.apiKey)');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const base = (cfg.baseUrl ?? DEFAULT_BASE).replace(/\/$/, '');
|
|
82
|
+
const model = cfg.ttsModel ?? DEFAULT_TTS_MODEL;
|
|
83
|
+
const voice = request.voice ?? cfg.ttsVoice ?? DEFAULT_VOICE;
|
|
84
|
+
const responseFormat =
|
|
85
|
+
(request.responseFormat as 'mp3' | 'opus' | 'aac' | 'flac' | undefined) ?? DEFAULT_FORMAT;
|
|
86
|
+
const url = `${base}/audio/speech`;
|
|
87
|
+
|
|
88
|
+
const body = {
|
|
89
|
+
model,
|
|
90
|
+
voice,
|
|
91
|
+
input: request.text,
|
|
92
|
+
response_format: responseFormat,
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const headers = new HttpHeaders({
|
|
96
|
+
Authorization: `Bearer ${apiKey}`,
|
|
97
|
+
'Content-Type': 'application/json',
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
const blob = await firstValueFrom(
|
|
102
|
+
this.httpClient.post(url, body, {
|
|
103
|
+
headers,
|
|
104
|
+
responseType: 'blob',
|
|
105
|
+
}),
|
|
106
|
+
);
|
|
107
|
+
return { audio: blob, mimeType: this.mimeForFormat(responseFormat) };
|
|
108
|
+
} catch (e) {
|
|
109
|
+
if (e instanceof HttpErrorResponse && e.error instanceof Blob) {
|
|
110
|
+
const errText = await e.error.text();
|
|
111
|
+
throw new Error(`OpenAI TTS ${e.status}: ${errText || e.statusText}`);
|
|
112
|
+
}
|
|
113
|
+
if (e instanceof HttpErrorResponse) {
|
|
114
|
+
throw new Error(`OpenAI TTS ${e.status}: ${e.message || e.statusText}`);
|
|
115
|
+
}
|
|
116
|
+
throw e;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private getConfig(): OpenAiVoiceEnvironmentConfig {
|
|
121
|
+
return this.appConfig.getConfig().openAiKey ?? {};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private mapOpenAiHttpError(e: unknown): Error {
|
|
125
|
+
if (!(e instanceof HttpErrorResponse)) {
|
|
126
|
+
return e instanceof Error ? e : new Error(String(e));
|
|
127
|
+
}
|
|
128
|
+
const label = 'OpenAI transcription';
|
|
129
|
+
if (e.error instanceof Blob) {
|
|
130
|
+
return new Error(`${label} ${e.status}: ${e.statusText}`);
|
|
131
|
+
}
|
|
132
|
+
if (typeof e.error === 'object' && e.error !== null && 'error' in e.error) {
|
|
133
|
+
const err = (e.error as { error?: { message?: string } }).error;
|
|
134
|
+
return new Error(`${label} ${e.status}: ${err?.message ?? JSON.stringify(e.error)}`);
|
|
135
|
+
}
|
|
136
|
+
if (typeof e.error === 'string') {
|
|
137
|
+
return new Error(`${label} ${e.status}: ${e.error}`);
|
|
138
|
+
}
|
|
139
|
+
return new Error(`${label} ${e.status}: ${e.message || e.statusText}`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private extensionForMime(mime: string): string {
|
|
143
|
+
if (mime.includes('webm')) {
|
|
144
|
+
return 'webm';
|
|
145
|
+
}
|
|
146
|
+
if (mime.includes('mp4') || mime.includes('m4a')) {
|
|
147
|
+
return 'm4a';
|
|
148
|
+
}
|
|
149
|
+
if (mime.includes('wav')) {
|
|
150
|
+
return 'wav';
|
|
151
|
+
}
|
|
152
|
+
if (mime.includes('mpeg') || mime.includes('mp3')) {
|
|
153
|
+
return 'mp3';
|
|
154
|
+
}
|
|
155
|
+
return 'webm';
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private mimeForFormat(fmt: string): string {
|
|
159
|
+
switch (fmt) {
|
|
160
|
+
case 'opus':
|
|
161
|
+
return 'audio/opus';
|
|
162
|
+
case 'aac':
|
|
163
|
+
return 'audio/aac';
|
|
164
|
+
case 'flac':
|
|
165
|
+
return 'audio/flac';
|
|
166
|
+
case 'mp3':
|
|
167
|
+
default:
|
|
168
|
+
return 'audio/mpeg';
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contratti astratti per Speech-to-Text e Text-to-Speech.
|
|
3
|
+
* Implementazione OpenAI unificata: `OpenAiVoiceProviderService` (`openai-voice.provider.ts`).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Input per la trascrizione di un segmento audio. */
|
|
7
|
+
export interface SpeechToTextRequest {
|
|
8
|
+
audio: Blob;
|
|
9
|
+
mimeType: string;
|
|
10
|
+
/** ISO 639-1 opzionale (es. `it`, `en`). */
|
|
11
|
+
language?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface SpeechToTextResult {
|
|
15
|
+
text: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Input per la sintesi vocale. */
|
|
19
|
+
export interface TextToSpeechRequest {
|
|
20
|
+
text: string;
|
|
21
|
+
/** Voce provider-specific (es. OpenAI: `alloy`, `echo`, …). */
|
|
22
|
+
voice?: string;
|
|
23
|
+
language?: string;
|
|
24
|
+
/** Formato audio desiderato (dipende dal provider). */
|
|
25
|
+
responseFormat?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface TextToSpeechResult {
|
|
29
|
+
audio: Blob;
|
|
30
|
+
mimeType: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export abstract class SpeechToTextProvider {
|
|
34
|
+
abstract transcribe(request: SpeechToTextRequest): Promise<SpeechToTextResult>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export abstract class TextToSpeechProvider {
|
|
38
|
+
abstract synthesize(request: TextToSpeechRequest): Promise<TextToSpeechResult>;
|
|
39
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tipi condivisi per cattura microfono, VAD e registrazione (WebM).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_VOICE_AUDIO_CONSTRAINTS: MediaTrackConstraints = {
|
|
6
|
+
echoCancellation: true,
|
|
7
|
+
noiseSuppression: true,
|
|
8
|
+
autoGainControl: true,
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export const DEFAULT_VOICE_MEDIA_STREAM_CONSTRAINTS: MediaStreamConstraints = {
|
|
12
|
+
audio: DEFAULT_VOICE_AUDIO_CONSTRAINTS,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export interface VoiceRecordedBlob {
|
|
16
|
+
blob: Blob;
|
|
17
|
+
mimeType: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Segmento audio dopo VAD; può includere `transcript` se STT è configurato e abilitato.
|
|
22
|
+
*/
|
|
23
|
+
export interface VoiceSegmentPayload extends VoiceRecordedBlob {
|
|
24
|
+
transcript?: string;
|
|
25
|
+
transcriptionError?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface VoiceSessionStartOptions {
|
|
29
|
+
/** Opzionale se usi solo {@link VoiceService.audioSegment$}. */
|
|
30
|
+
onRecordingComplete?: (result: VoiceSegmentPayload) => void;
|
|
31
|
+
constraints?: MediaStreamConstraints;
|
|
32
|
+
/** Default `true`. Se `false`, non viene chiamato lo STT sul segmento. */
|
|
33
|
+
enableTranscription?: boolean;
|
|
34
|
+
}
|