@chat21/chat21-web-widget 5.1.34-rc1 → 5.2.1
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 +13 -23
- package/.github/workflows/docker-image-tag-community-tag-push.yml +12 -22
- package/CHANGELOG.md +22 -118
- package/Dockerfile +4 -4
- package/README.md +1 -1
- package/docs/ACCESSIBILITY-STATEMENT.md +388 -0
- package/docs/TILEDESK_WIDGET_ACCESSIBILITY_ALIGNMENT.md +60 -0
- package/docs/TILEDESK_WIDGET_ACCESSIBILITY_STATEMENT_COMPLETE.md +386 -0
- package/docs/changelog/this-branch.md +0 -36
- package/nginx.conf +2 -22
- package/package.json +1 -1
- package/src/app/app.component.ts +9 -10
- package/src/app/component/conversation-detail/conversation/conversation.component.html +2 -2
- package/src/app/component/conversation-detail/conversation/conversation.component.scss +2 -2
- package/src/app/component/conversation-detail/conversation/conversation.component.ts +16 -34
- package/src/app/component/conversation-detail/conversation-content/conversation-content.component.html +3 -3
- package/src/app/component/conversation-detail/conversation-content/conversation-content.component.scss +2 -2
- package/src/app/component/conversation-detail/conversation-content/conversation-content.component.spec.ts +0 -1
- package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.html +52 -63
- package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.scss +17 -11
- package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.spec.ts +10 -4
- package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.ts +5 -8
- package/src/app/component/form/inputs/form-text/form-text.component.ts +1 -1
- package/src/app/component/last-message/last-message.component.ts +1 -4
- package/src/app/component/message/audio-sync/audio-sync.component.spec.ts +17 -8
- package/src/app/component/message/audio-sync/audio-sync.component.ts +96 -25
- package/src/app/component/message/bubble-message/bubble-message.component.html +12 -9
- package/src/app/component/message/bubble-message/bubble-message.component.spec.ts +38 -45
- package/src/app/component/message/bubble-message/bubble-message.component.ts +49 -45
- package/src/app/component/message/json-sources/json-sources.component.html +6 -5
- package/src/app/component/message/json-sources/json-sources.component.scss +26 -18
- package/src/app/component/message/json-sources/json-sources.component.ts +41 -0
- package/src/app/providers/global-settings.service.ts +0 -42
- package/src/app/providers/json-sources-parser.service.ts +13 -1
- package/src/app/providers/translator.service.ts +1 -4
- package/src/app/providers/tts-audio-playback-coordinator.service.spec.ts +7 -8
- package/src/app/providers/tts-audio-playback-coordinator.service.ts +13 -0
- package/src/app/providers/voice/STT&TTS/openai-voice.provider.ts +67 -82
- package/src/app/providers/voice/voice.service.spec.ts +35 -35
- package/src/app/providers/voice/voice.service.ts +3 -7
- package/src/app/sass/_variables.scss +0 -1
- package/src/app/utils/globals.ts +2 -8
- package/src/assets/i18n/en.json +22 -1
- package/src/assets/i18n/es.json +22 -1
- package/src/assets/i18n/fr.json +22 -1
- package/src/assets/i18n/it.json +22 -1
- package/src/assets/twp/index-dev.html +0 -18
- package/src/chat21-core/providers/firebase/firebase-init-service.ts +5 -5
- package/src/chat21-core/providers/tiledesk/tiledesk-requests.service.ts +1 -1
- package/src/chat21-core/utils/utils-message.ts +4 -4
- package/src/chat21-core/utils/utils.ts +2 -5
- package/src/widget-config-template.json +0 -1
- package/src/widget-config.json +28 -30
- package/.github/workflows/build.yml +0 -22
- package/src/assets/twp/tiledesk_widget_files/widget-css-override-example.css +0 -14
|
@@ -10,6 +10,12 @@ export type JsonSourceItem = {
|
|
|
10
10
|
image?: string;
|
|
11
11
|
};
|
|
12
12
|
|
|
13
|
+
export type JsonSourcesDisplayFields = {
|
|
14
|
+
title?: boolean;
|
|
15
|
+
description?: boolean;
|
|
16
|
+
image?: boolean;
|
|
17
|
+
};
|
|
18
|
+
|
|
13
19
|
@Component({
|
|
14
20
|
selector: 'chat-json-sources',
|
|
15
21
|
templateUrl: './json-sources.component.html',
|
|
@@ -19,11 +25,30 @@ export class JsonSourcesComponent {
|
|
|
19
25
|
@Input() items: JsonSourceItem[] = [];
|
|
20
26
|
@Input() themeColor?: string;
|
|
21
27
|
@Input() limit = 3;
|
|
28
|
+
// Optional: per-field visibility. Missing/undefined fields default to visible
|
|
29
|
+
// (only an explicit `false` hides the field).
|
|
30
|
+
@Input() displayFields?: JsonSourcesDisplayFields;
|
|
31
|
+
// Optional: background color override for the sources panel.
|
|
32
|
+
@Input() backgroundColor?: string;
|
|
22
33
|
|
|
23
34
|
@Output() onElementRendered = new EventEmitter<{ element: string; status: boolean }>();
|
|
24
35
|
|
|
25
36
|
showAll = false;
|
|
26
37
|
|
|
38
|
+
isFieldVisible(field: keyof JsonSourcesDisplayFields): boolean {
|
|
39
|
+
return this.displayFields?.[field] !== false;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Title is always rendered: when its content is missing or the field is
|
|
43
|
+
// hidden via displayFields, we fall back to the item URL so the row is never
|
|
44
|
+
// left without a label.
|
|
45
|
+
getTitleText(item: JsonSourceItem): string {
|
|
46
|
+
const titleVisible = this.isFieldVisible('title');
|
|
47
|
+
const title = (item?.title || '').trim();
|
|
48
|
+
if (titleVisible && title) return title;
|
|
49
|
+
return (item?.link || '').trim();
|
|
50
|
+
}
|
|
51
|
+
|
|
27
52
|
trackByLink = (_: number, item: JsonSourceItem) => item?.link || item?.title || _;
|
|
28
53
|
|
|
29
54
|
ngAfterViewInit() {
|
|
@@ -51,6 +76,22 @@ export class JsonSourcesComponent {
|
|
|
51
76
|
return hostname || '';
|
|
52
77
|
}
|
|
53
78
|
|
|
79
|
+
// Route large source images through wsrv.nl which downsamples them server-side
|
|
80
|
+
// to a thumbnail-sized version. Rendering at ~3x the CSS size keeps the result
|
|
81
|
+
// sharp on retina displays. Falls back to the original URL on any error.
|
|
82
|
+
getThumbUrl(item: JsonSourceItem): string {
|
|
83
|
+
const raw = (item?.image || '').trim();
|
|
84
|
+
if (!raw) return '';
|
|
85
|
+
if (!/^https?:\/\//i.test(raw)) return raw;
|
|
86
|
+
try {
|
|
87
|
+
const stripped = raw.replace(/^https?:\/\//i, '');
|
|
88
|
+
const encoded = encodeURIComponent(stripped);
|
|
89
|
+
return `https://wsrv.nl/?url=${encoded}&w=120&h=120&fit=cover&output=webp&n=-1`;
|
|
90
|
+
} catch {
|
|
91
|
+
return raw;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
54
95
|
private safeHostname(url: string): string {
|
|
55
96
|
try {
|
|
56
97
|
return new URL(url).hostname.replace(/^www\./, '');
|
|
@@ -66,8 +66,6 @@ export class GlobalSettingsService {
|
|
|
66
66
|
this.globals.logLevel = this.appConfigService.getConfig().logLevel
|
|
67
67
|
/**SET PERSISTENCE parameter */
|
|
68
68
|
this.globals.persistence = this.appConfigService.getConfig().authPersistence
|
|
69
|
-
/**SET CLOSE CHAT IN CONVERSATION parameter */
|
|
70
|
-
this.globals.closeChatInConversation = this.appConfigService.getConfig().closeChatInConversation;
|
|
71
69
|
|
|
72
70
|
// ------------------------------- //
|
|
73
71
|
/** LOAD PARAMETERS FROM SERVER
|
|
@@ -340,8 +338,6 @@ export class GlobalSettingsService {
|
|
|
340
338
|
this.setCssIframe();
|
|
341
339
|
/** set main style */
|
|
342
340
|
this.setStyle();
|
|
343
|
-
/** external CSS override: last stylesheet in document head (max cascade priority vs bundle) */
|
|
344
|
-
this.applyCustomCssOverrideFromGlobals();
|
|
345
341
|
this.obsSettingsService.next(true);
|
|
346
342
|
}
|
|
347
343
|
|
|
@@ -422,28 +418,6 @@ export class GlobalSettingsService {
|
|
|
422
418
|
|
|
423
419
|
document.documentElement.style.setProperty('--font-family', family);
|
|
424
420
|
}
|
|
425
|
-
|
|
426
|
-
/**
|
|
427
|
-
* Loads `globals.cssSource` (set only from tiledeskSettings) as the last stylesheet in head
|
|
428
|
-
* so rules with the same specificity override local / bundled CSS.
|
|
429
|
-
*/
|
|
430
|
-
private applyCustomCssOverrideFromGlobals(): void {
|
|
431
|
-
const id = 'tiledesk-widget-css-override';
|
|
432
|
-
document.getElementById(id)?.remove();
|
|
433
|
-
|
|
434
|
-
const href = (this.globals.cssSource || '').trim();
|
|
435
|
-
console.log('href', href);
|
|
436
|
-
if (!href) {
|
|
437
|
-
return;
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
const link = document.createElement('link');
|
|
441
|
-
link.id = id;
|
|
442
|
-
link.rel = 'stylesheet';
|
|
443
|
-
link.href = href;
|
|
444
|
-
link.setAttribute('data-tiledesk-css-override', 'true');
|
|
445
|
-
document.head.appendChild(link);
|
|
446
|
-
}
|
|
447
421
|
/**
|
|
448
422
|
* A: setVariablesFromService
|
|
449
423
|
*/
|
|
@@ -667,11 +641,6 @@ export class GlobalSettingsService {
|
|
|
667
641
|
let TEMP: any;
|
|
668
642
|
const tiledeskSettings = windowContext['tiledeskSettings'];
|
|
669
643
|
// this.logger.debug('[GLOBAL-SET] setVariablesFromSettings > tiledeskSettings: ', tiledeskSettings);
|
|
670
|
-
/** css override URL: solo tiledeskSettings, mai da URL / query params */
|
|
671
|
-
TEMP = tiledeskSettings['cssSource'];
|
|
672
|
-
if (TEMP !== undefined) {
|
|
673
|
-
globals.cssSource = TEMP;
|
|
674
|
-
}
|
|
675
644
|
TEMP = tiledeskSettings['tenant'];
|
|
676
645
|
// this.logger.debug('[GLOBAL-SET] setVariablesFromSettings > tenant:: ', TEMP);
|
|
677
646
|
if (TEMP !== undefined) {
|
|
@@ -1176,12 +1145,6 @@ export class GlobalSettingsService {
|
|
|
1176
1145
|
if (TEMP !== undefined) {
|
|
1177
1146
|
globals.size = TEMP;
|
|
1178
1147
|
}
|
|
1179
|
-
|
|
1180
|
-
TEMP = tiledeskSettings['closeChatInConversation'];
|
|
1181
|
-
// this.logger.debug('[GLOBAL-SET] setVariablesFromSettings > closeChatInConversation:: ', TEMP]);
|
|
1182
|
-
if (TEMP !== undefined) {
|
|
1183
|
-
globals.closeChatInConversation = (TEMP === true) ? true : false;
|
|
1184
|
-
}
|
|
1185
1148
|
}
|
|
1186
1149
|
|
|
1187
1150
|
/**
|
|
@@ -1951,11 +1914,6 @@ export class GlobalSettingsService {
|
|
|
1951
1914
|
if (TEMP) {
|
|
1952
1915
|
globals.size = TEMP;
|
|
1953
1916
|
}
|
|
1954
|
-
|
|
1955
|
-
TEMP = getParameterByName(windowContext, 'tiledesk_closeChatInConversation');
|
|
1956
|
-
if (TEMP) {
|
|
1957
|
-
globals.closeChatInConversation = stringToBoolean(TEMP);
|
|
1958
|
-
}
|
|
1959
1917
|
|
|
1960
1918
|
}
|
|
1961
1919
|
|
|
@@ -5,9 +5,17 @@ import { extractUrlsFromText } from 'src/app/utils/url-utils';
|
|
|
5
5
|
import { JsonSourceItem } from 'src/app/component/message/json-sources/json-sources.component';
|
|
6
6
|
import { mergeJsonSourcesMissingFields } from 'src/app/utils/json-sources-utils';
|
|
7
7
|
|
|
8
|
+
export type UrlPreviewDisplayFields = {
|
|
9
|
+
title?: boolean;
|
|
10
|
+
description?: boolean;
|
|
11
|
+
image?: boolean;
|
|
12
|
+
};
|
|
13
|
+
|
|
8
14
|
export type UrlPreviewMessage = {
|
|
9
15
|
type?: string; // "url_preview"
|
|
10
16
|
text?: string;
|
|
17
|
+
displayFields?: UrlPreviewDisplayFields;
|
|
18
|
+
previewBackgroundColor?: string;
|
|
11
19
|
};
|
|
12
20
|
|
|
13
21
|
/**
|
|
@@ -79,7 +87,11 @@ export class JsonSourcesParserService {
|
|
|
79
87
|
return this.enrichSources(base);
|
|
80
88
|
}
|
|
81
89
|
|
|
82
|
-
|
|
90
|
+
/**
|
|
91
|
+
* Public: lets callers (UI components) read the raw `url_preview` payload to
|
|
92
|
+
* extract presentation options like `displayFields` or `previewBackgroundColor`.
|
|
93
|
+
*/
|
|
94
|
+
getUrlPreviewPayload(messageLike?: any): UrlPreviewMessage | null {
|
|
83
95
|
if (!messageLike) return null;
|
|
84
96
|
const candidates: any[] = [
|
|
85
97
|
messageLike,
|
|
@@ -318,9 +318,7 @@ export class TranslatorService {
|
|
|
318
318
|
'LABEL_PREVIEW',
|
|
319
319
|
'MAX_ATTACHMENT',
|
|
320
320
|
'EMOJI',
|
|
321
|
-
'BUTTON_OPEN_CHAT'
|
|
322
|
-
'MAX_ATTACHMENT_ERROR',
|
|
323
|
-
'EMOJI'
|
|
321
|
+
'BUTTON_OPEN_CHAT'
|
|
324
322
|
];
|
|
325
323
|
|
|
326
324
|
|
|
@@ -376,7 +374,6 @@ export class TranslatorService {
|
|
|
376
374
|
globals.LABEL_PREVIEW = res['LABEL_PREVIEW']
|
|
377
375
|
globals.LABEL_ERROR_FIELD_REQUIRED= res['LABEL_ERROR_FIELD_REQUIRED']
|
|
378
376
|
globals.MAX_ATTACHMENT = res['MAX_ATTACHMENT']
|
|
379
|
-
globals.MAX_ATTACHMENT_ERROR = res['MAX_ATTACHMENT_ERROR']
|
|
380
377
|
globals.EMOJI = res['EMOJI']
|
|
381
378
|
globals.BUTTON_OPEN_CHAT = res['BUTTON_OPEN_CHAT']
|
|
382
379
|
|
|
@@ -1,12 +1,10 @@
|
|
|
1
|
-
import { TestBed } from '@angular/core/testing';
|
|
2
1
|
import { TtsAudioPlaybackCoordinator } from './tts-audio-playback-coordinator.service';
|
|
3
2
|
|
|
4
3
|
describe('TtsAudioPlaybackCoordinator', () => {
|
|
5
4
|
let coordinator: TtsAudioPlaybackCoordinator;
|
|
6
5
|
|
|
7
6
|
beforeEach(() => {
|
|
8
|
-
|
|
9
|
-
coordinator = TestBed.inject(TtsAudioPlaybackCoordinator);
|
|
7
|
+
coordinator = new TtsAudioPlaybackCoordinator();
|
|
10
8
|
});
|
|
11
9
|
|
|
12
10
|
// ── Basic lifecycle ───────────────────────────────────────────────────────
|
|
@@ -28,15 +26,16 @@ describe('TtsAudioPlaybackCoordinator', () => {
|
|
|
28
26
|
});
|
|
29
27
|
|
|
30
28
|
it('stopAll clears the queue, sets playing=false, and emits stopAllPlayback$', () => {
|
|
31
|
-
const
|
|
29
|
+
const stopAllFired: void[] = [];
|
|
30
|
+
coordinator.stopAllPlayback$.subscribe(() => stopAllFired.push(undefined));
|
|
32
31
|
|
|
33
32
|
coordinator.requestStart('msg-1', () => {});
|
|
34
33
|
coordinator.stopAll();
|
|
35
34
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
expect(
|
|
35
|
+
let playing = true;
|
|
36
|
+
coordinator.isTTSPlaying$.subscribe((v) => (playing = v));
|
|
37
|
+
expect(playing).toBe(false);
|
|
38
|
+
expect(stopAllFired.length).toBe(1);
|
|
40
39
|
});
|
|
41
40
|
|
|
42
41
|
// ── Preemption tests (SPEC-002) ───────────────────────────────────────────
|
|
@@ -11,6 +11,10 @@ export class TtsAudioPlaybackCoordinator {
|
|
|
11
11
|
private currentOwnerId: string | null = null;
|
|
12
12
|
private readonly queue: Array<{ ownerId: string; start: () => void }> = [];
|
|
13
13
|
|
|
14
|
+
private readonly cancelAllSource = new Subject<void>();
|
|
15
|
+
/** Emesso quando la riproduzione TTS va interrotta globalmente (es. l’utente parla al microfono). */
|
|
16
|
+
readonly cancelAll$: Observable<void> = this.cancelAllSource.asObservable();
|
|
17
|
+
|
|
14
18
|
/** Emits true while any TTS is playing or queued; false when the queue is fully drained. */
|
|
15
19
|
private readonly _isTTSPlaying$ = new BehaviorSubject<boolean>(false);
|
|
16
20
|
readonly isTTSPlaying$ = this._isTTSPlaying$.asObservable();
|
|
@@ -96,6 +100,15 @@ export class TtsAudioPlaybackCoordinator {
|
|
|
96
100
|
this.releaseIfCurrent(ownerId);
|
|
97
101
|
}
|
|
98
102
|
|
|
103
|
+
/**
|
|
104
|
+
* Interrompe TUTTA la riproduzione TTS (corrente + coda) e notifica i componenti.
|
|
105
|
+
* I componenti devono fermare l’audio e mostrare il testo per intero.
|
|
106
|
+
*/
|
|
107
|
+
cancelAll(): void {
|
|
108
|
+
this.stopAll();
|
|
109
|
+
this.cancelAllSource.next();
|
|
110
|
+
}
|
|
111
|
+
|
|
99
112
|
/**
|
|
100
113
|
* Stops all TTS playback immediately and clears the queue.
|
|
101
114
|
* Broadcasts on stopAllPlayback$ so every AudioSyncComponent can abort its stream and reveal all text.
|
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
import { HttpClient, HttpErrorResponse, HttpHeaders } from '@angular/common/http';
|
|
2
2
|
import { Injectable } from '@angular/core';
|
|
3
3
|
import { firstValueFrom } from 'rxjs';
|
|
4
|
-
import { environment } from 'src/environments/environment';
|
|
5
4
|
|
|
6
|
-
import type { OpenAiVoiceEnvironmentConfig } from './openai-voice.config';
|
|
7
5
|
import {
|
|
8
6
|
SpeechToTextProvider,
|
|
9
7
|
TextToSpeechProvider,
|
|
@@ -14,121 +12,121 @@ import {
|
|
|
14
12
|
} from './speech-provider.abstract';
|
|
15
13
|
import { AppConfigService } from '../../app-config.service';
|
|
16
14
|
|
|
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
15
|
/**
|
|
24
|
-
*
|
|
16
|
+
* Routes STT and TTS calls through the tiledesk-speech-proxy.
|
|
17
|
+
*
|
|
18
|
+
* STT: POST <proxyBase>/api/stt — multipart/form-data, field "audio"
|
|
19
|
+
* TTS: POST <proxyBase>/api/tts — JSON body { text, ... }
|
|
25
20
|
*/
|
|
26
21
|
@Injectable({ providedIn: 'root' })
|
|
27
22
|
export class OpenAiVoiceProviderService extends SpeechToTextProvider implements TextToSpeechProvider {
|
|
28
23
|
constructor(
|
|
29
24
|
private readonly httpClient: HttpClient,
|
|
30
|
-
private readonly appConfig: AppConfigService
|
|
25
|
+
private readonly appConfig: AppConfigService,
|
|
31
26
|
) {
|
|
32
27
|
super();
|
|
33
28
|
}
|
|
34
29
|
|
|
35
30
|
async transcribe(request: SpeechToTextRequest): Promise<SpeechToTextResult> {
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
if (!apiKey) {
|
|
31
|
+
const proxyBase = this.proxyBase();
|
|
32
|
+
if (!proxyBase) {
|
|
39
33
|
return { text: '' };
|
|
40
34
|
}
|
|
41
35
|
|
|
42
|
-
const
|
|
43
|
-
const model = cfg.transcriptionModel ?? DEFAULT_TRANSCRIPTION_MODEL;
|
|
44
|
-
const url = `${base}/audio/transcriptions`;
|
|
45
|
-
|
|
36
|
+
const url = `${proxyBase}/api/stt`;
|
|
46
37
|
const ext = this.extensionForMime(request.mimeType);
|
|
47
38
|
const file = new File([request.audio], `segment.${ext}`, { type: request.mimeType });
|
|
48
39
|
|
|
49
40
|
const form = new FormData();
|
|
50
|
-
form.append('
|
|
51
|
-
form.append('model', model);
|
|
41
|
+
form.append('audio', file);
|
|
52
42
|
if (request.language) {
|
|
53
43
|
form.append('language', request.language);
|
|
54
44
|
}
|
|
45
|
+
const projectId = String(this.appConfig.g?.projectid ?? '').trim();
|
|
46
|
+
if (projectId) form.append('projectId', projectId);
|
|
47
|
+
const requestId = this.parseRequestId(this.appConfig.g?.recipientId ?? '');
|
|
48
|
+
if (requestId) {
|
|
49
|
+
form.append('requestId', requestId);
|
|
50
|
+
}
|
|
55
51
|
|
|
56
|
-
const headers = new HttpHeaders({
|
|
57
|
-
Authorization: `Bearer ${apiKey}`,
|
|
58
|
-
});
|
|
52
|
+
const headers = new HttpHeaders({ Authorization: this.authHeader() });
|
|
59
53
|
|
|
60
54
|
try {
|
|
61
55
|
const data = await firstValueFrom(
|
|
62
|
-
this.httpClient.post<{
|
|
56
|
+
this.httpClient.post<{ transcript?: string }>(url, form, { headers }),
|
|
63
57
|
);
|
|
64
|
-
return { text: (data.
|
|
58
|
+
return { text: (data.transcript ?? '').trim() };
|
|
65
59
|
} catch (e) {
|
|
66
60
|
if (e instanceof HttpErrorResponse && e.error instanceof Blob) {
|
|
67
61
|
const errText = await e.error.text();
|
|
68
|
-
throw new Error(`
|
|
62
|
+
throw new Error(`Speech proxy STT ${e.status}: ${errText || e.statusText}`);
|
|
69
63
|
}
|
|
70
|
-
throw this.
|
|
64
|
+
throw this.mapHttpError('Speech proxy STT', e);
|
|
71
65
|
}
|
|
72
66
|
}
|
|
73
67
|
|
|
74
68
|
async synthesize(request: TextToSpeechRequest): Promise<TextToSpeechResult> {
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
throw new Error('OpenAI API key not configured (environment.openAiVoice.apiKey)');
|
|
69
|
+
const proxyBase = this.proxyBase();
|
|
70
|
+
if (!proxyBase) {
|
|
71
|
+
throw new Error('voiceProxyApiBaseUrl not configured');
|
|
79
72
|
}
|
|
80
73
|
|
|
81
|
-
const
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
voice,
|
|
91
|
-
input: request.text,
|
|
92
|
-
response_format: responseFormat,
|
|
93
|
-
};
|
|
74
|
+
const url = `${proxyBase}/api/tts`;
|
|
75
|
+
const body: Record<string, unknown> = { text: request.text };
|
|
76
|
+
if (request.language) body['language'] = request.language;
|
|
77
|
+
if (request.voice) body['voiceId'] = request.voice;
|
|
78
|
+
if (request.responseFormat) body['outputFormat'] = request.responseFormat;
|
|
79
|
+
const projectId = String(this.appConfig.g?.projectid ?? '').trim();
|
|
80
|
+
if (projectId) body['projectId'] = projectId;
|
|
81
|
+
const requestId = this.parseRequestId(this.appConfig.g?.recipientId ?? '');
|
|
82
|
+
if (requestId) body['requestId'] = requestId;
|
|
94
83
|
|
|
95
84
|
const headers = new HttpHeaders({
|
|
96
|
-
Authorization:
|
|
85
|
+
Authorization: this.authHeader(),
|
|
97
86
|
'Content-Type': 'application/json',
|
|
98
87
|
});
|
|
99
88
|
|
|
100
89
|
try {
|
|
101
90
|
const blob = await firstValueFrom(
|
|
102
|
-
this.httpClient.post(url, body, {
|
|
103
|
-
headers,
|
|
104
|
-
responseType: 'blob',
|
|
105
|
-
}),
|
|
91
|
+
this.httpClient.post(url, body, { headers, responseType: 'blob' }),
|
|
106
92
|
);
|
|
107
|
-
return { audio: blob, mimeType: this.mimeForFormat(responseFormat) };
|
|
93
|
+
return { audio: blob, mimeType: this.mimeForFormat(request.responseFormat ?? 'mp3') };
|
|
108
94
|
} catch (e) {
|
|
109
95
|
if (e instanceof HttpErrorResponse && e.error instanceof Blob) {
|
|
110
96
|
const errText = await e.error.text();
|
|
111
|
-
throw new Error(`
|
|
97
|
+
throw new Error(`Speech proxy TTS ${e.status}: ${errText || e.statusText}`);
|
|
112
98
|
}
|
|
113
|
-
|
|
114
|
-
throw new Error(`OpenAI TTS ${e.status}: ${e.message || e.statusText}`);
|
|
115
|
-
}
|
|
116
|
-
throw e;
|
|
99
|
+
throw this.mapHttpError('Speech proxy TTS', e);
|
|
117
100
|
}
|
|
118
101
|
}
|
|
119
102
|
|
|
120
|
-
private
|
|
121
|
-
|
|
103
|
+
private proxyBase(): string | null {
|
|
104
|
+
const base = String(this.appConfig.getConfig()?.voiceProxyApiBaseUrl ?? '').trim();
|
|
105
|
+
return base ? base.replace(/\/$/, '') : null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Returns `JWT <rawToken>`, stripping any existing prefix first. */
|
|
109
|
+
private authHeader(): string {
|
|
110
|
+
const raw = (
|
|
111
|
+
String(this.appConfig.g?.tiledeskToken ?? this.appConfig.g?.jwt ?? '').trim()
|
|
112
|
+
).replace(/^(JWT|Bearer)\s+/i, '').trim();
|
|
113
|
+
return raw ? `JWT ${raw}` : '';
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Extracts the Tiledesk requestId from a Chat21 recipient string.
|
|
118
|
+
* Format: `support-group-<projectId>-<requestId>`
|
|
119
|
+
*/
|
|
120
|
+
private parseRequestId(recipient: string): string | null {
|
|
121
|
+
const parts = recipient.split('-');
|
|
122
|
+
if (parts.length < 4) return null;
|
|
123
|
+
return parts.slice(3).join('-') || null;
|
|
122
124
|
}
|
|
123
125
|
|
|
124
|
-
private
|
|
126
|
+
private mapHttpError(label: string, e: unknown): Error {
|
|
125
127
|
if (!(e instanceof HttpErrorResponse)) {
|
|
126
128
|
return e instanceof Error ? e : new Error(String(e));
|
|
127
129
|
}
|
|
128
|
-
const label = 'OpenAI transcription';
|
|
129
|
-
if (e.error instanceof Blob) {
|
|
130
|
-
return new Error(`${label} ${e.status}: ${e.statusText}`);
|
|
131
|
-
}
|
|
132
130
|
if (typeof e.error === 'object' && e.error !== null && 'error' in e.error) {
|
|
133
131
|
const err = (e.error as { error?: { message?: string } }).error;
|
|
134
132
|
return new Error(`${label} ${e.status}: ${err?.message ?? JSON.stringify(e.error)}`);
|
|
@@ -140,32 +138,19 @@ export class OpenAiVoiceProviderService extends SpeechToTextProvider implements
|
|
|
140
138
|
}
|
|
141
139
|
|
|
142
140
|
private extensionForMime(mime: string): string {
|
|
143
|
-
if (mime.includes('webm'))
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
if (mime.includes('
|
|
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
|
-
}
|
|
141
|
+
if (mime.includes('webm')) return 'webm';
|
|
142
|
+
if (mime.includes('mp4') || mime.includes('m4a')) return 'm4a';
|
|
143
|
+
if (mime.includes('wav')) return 'wav';
|
|
144
|
+
if (mime.includes('mpeg') || mime.includes('mp3')) return 'mp3';
|
|
155
145
|
return 'webm';
|
|
156
146
|
}
|
|
157
147
|
|
|
158
148
|
private mimeForFormat(fmt: string): string {
|
|
159
149
|
switch (fmt) {
|
|
160
|
-
case 'opus':
|
|
161
|
-
|
|
162
|
-
case '
|
|
163
|
-
|
|
164
|
-
case 'flac':
|
|
165
|
-
return 'audio/flac';
|
|
166
|
-
case 'mp3':
|
|
167
|
-
default:
|
|
168
|
-
return 'audio/mpeg';
|
|
150
|
+
case 'opus': return 'audio/opus';
|
|
151
|
+
case 'aac': return 'audio/aac';
|
|
152
|
+
case 'flac': return 'audio/flac';
|
|
153
|
+
default: return 'audio/mpeg';
|
|
169
154
|
}
|
|
170
155
|
}
|
|
171
156
|
}
|