feat(voice): add admin-configurable elevenlabs tts
This commit is contained in:
+178
@@ -11,6 +11,7 @@ const state = {
|
||||
providerLabel: 'Keycloak',
|
||||
},
|
||||
voiceNameConfig: null,
|
||||
voiceTtsConfig: null,
|
||||
};
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
@@ -650,6 +651,172 @@ function resetVoiceNameConfigForm() {
|
||||
log('Форма voice name collection сброшена к текущим настройкам');
|
||||
}
|
||||
|
||||
function voiceTtsProviderConfig(config, provider) {
|
||||
return config?.[provider] || { ru: {}, kz: {} };
|
||||
}
|
||||
|
||||
function voiceTtsOptionsFor(provider, language) {
|
||||
return state.voiceTtsConfig?.voice_options?.[provider]?.[language] || [];
|
||||
}
|
||||
|
||||
function fillVoiceTtsPresetSelect(selectId, options, selectedValue) {
|
||||
const select = $(selectId);
|
||||
if (!select) {
|
||||
return;
|
||||
}
|
||||
const rows = ['<option value="">Свой вариант</option>'].concat(
|
||||
(options || []).map((item) => `<option value="${item.value}">${item.label}</option>`),
|
||||
);
|
||||
select.innerHTML = rows.join('');
|
||||
const hasSelectedValue = (options || []).some((item) => item.value === selectedValue);
|
||||
select.value = hasSelectedValue ? selectedValue : '';
|
||||
}
|
||||
|
||||
function syncVoiceTtsPresetOptions(payload) {
|
||||
const config = payload?.config || payload || state.voiceTtsConfig?.config;
|
||||
if (!config) {
|
||||
return;
|
||||
}
|
||||
fillVoiceTtsPresetSelect('voiceTtsYandexRuVoicePreset', voiceTtsOptionsFor('yandex', 'ru'), config.yandex?.ru?.voice || '');
|
||||
fillVoiceTtsPresetSelect('voiceTtsYandexKzVoicePreset', voiceTtsOptionsFor('yandex', 'kz'), config.yandex?.kz?.voice || '');
|
||||
fillVoiceTtsPresetSelect('voiceTtsElevenRuVoicePreset', voiceTtsOptionsFor('elevenlabs', 'ru'), config.elevenlabs?.ru?.voice || '');
|
||||
fillVoiceTtsPresetSelect('voiceTtsElevenKzVoicePreset', voiceTtsOptionsFor('elevenlabs', 'kz'), config.elevenlabs?.kz?.voice || '');
|
||||
fillVoiceTtsPresetSelect('voiceTtsOpenAiRuVoicePreset', voiceTtsOptionsFor('openai', 'ru'), config.openai?.ru?.voice || '');
|
||||
fillVoiceTtsPresetSelect('voiceTtsOpenAiKzVoicePreset', voiceTtsOptionsFor('openai', 'kz'), config.openai?.kz?.voice || '');
|
||||
}
|
||||
|
||||
function serializeVoiceTtsConfigForm() {
|
||||
return {
|
||||
provider: $('voiceTtsProvider').value || 'yandex',
|
||||
yandex: {
|
||||
ru: { voice: $('voiceTtsYandexRuVoice').value.trim() || null },
|
||||
kz: { voice: $('voiceTtsYandexKzVoice').value.trim() || null },
|
||||
},
|
||||
elevenlabs: {
|
||||
ru: {
|
||||
voice: $('voiceTtsElevenRuVoice').value.trim() || null,
|
||||
model_id: $('voiceTtsElevenRuModel').value.trim() || null,
|
||||
language_code: $('voiceTtsElevenRuLanguageCode').value.trim() || null,
|
||||
},
|
||||
kz: {
|
||||
voice: $('voiceTtsElevenKzVoice').value.trim() || null,
|
||||
model_id: $('voiceTtsElevenKzModel').value.trim() || null,
|
||||
language_code: $('voiceTtsElevenKzLanguageCode').value.trim() || null,
|
||||
},
|
||||
},
|
||||
openai: {
|
||||
ru: {
|
||||
voice: $('voiceTtsOpenAiRuVoice').value.trim() || null,
|
||||
model_id: $('voiceTtsOpenAiRuModel').value.trim() || null,
|
||||
language_code: $('voiceTtsOpenAiRuLanguageCode').value.trim() || null,
|
||||
},
|
||||
kz: {
|
||||
voice: $('voiceTtsOpenAiKzVoice').value.trim() || null,
|
||||
model_id: $('voiceTtsOpenAiKzModel').value.trim() || null,
|
||||
language_code: $('voiceTtsOpenAiKzLanguageCode').value.trim() || null,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function syncVoiceTtsConfigFields(payload) {
|
||||
const config = payload?.config || payload;
|
||||
if (!config) {
|
||||
return;
|
||||
}
|
||||
$('voiceTtsProvider').value = config.provider || 'yandex';
|
||||
$('voiceTtsYandexRuVoice').value = config.yandex?.ru?.voice || '';
|
||||
$('voiceTtsYandexKzVoice').value = config.yandex?.kz?.voice || '';
|
||||
$('voiceTtsElevenRuVoice').value = config.elevenlabs?.ru?.voice || '';
|
||||
$('voiceTtsElevenRuModel').value = config.elevenlabs?.ru?.model_id || '';
|
||||
$('voiceTtsElevenRuLanguageCode').value = config.elevenlabs?.ru?.language_code || '';
|
||||
$('voiceTtsElevenKzVoice').value = config.elevenlabs?.kz?.voice || '';
|
||||
$('voiceTtsElevenKzModel').value = config.elevenlabs?.kz?.model_id || '';
|
||||
$('voiceTtsElevenKzLanguageCode').value = config.elevenlabs?.kz?.language_code || '';
|
||||
$('voiceTtsOpenAiRuVoice').value = config.openai?.ru?.voice || '';
|
||||
$('voiceTtsOpenAiRuModel').value = config.openai?.ru?.model_id || '';
|
||||
$('voiceTtsOpenAiRuLanguageCode').value = config.openai?.ru?.language_code || '';
|
||||
$('voiceTtsOpenAiKzVoice').value = config.openai?.kz?.voice || '';
|
||||
$('voiceTtsOpenAiKzModel').value = config.openai?.kz?.model_id || '';
|
||||
$('voiceTtsOpenAiKzLanguageCode').value = config.openai?.kz?.language_code || '';
|
||||
syncVoiceTtsPresetOptions(payload);
|
||||
}
|
||||
|
||||
function renderVoiceTtsConfigSummary(payload) {
|
||||
const config = payload?.config || payload;
|
||||
const source = payload?.source || 'defaults';
|
||||
const updatedAt = payload?.updated_at || 'не сохранялось';
|
||||
const activeProvider = config?.provider || 'yandex';
|
||||
const activeSettings = voiceTtsProviderConfig(config, activeProvider);
|
||||
$('voiceTtsSummary').textContent = [
|
||||
`Активный провайдер: ${activeProvider}`,
|
||||
`RU голос: ${activeSettings?.ru?.voice || 'не задан'}`,
|
||||
`RU модель: ${activeSettings?.ru?.model_id || 'по умолчанию'}`,
|
||||
`RU language_code: ${activeSettings?.ru?.language_code || 'по умолчанию'}`,
|
||||
`KZ голос: ${activeSettings?.kz?.voice || 'не задан'}`,
|
||||
`KZ модель: ${activeSettings?.kz?.model_id || 'по умолчанию'}`,
|
||||
`KZ language_code: ${activeSettings?.kz?.language_code || 'по умолчанию'}`,
|
||||
`Источник конфигурации: ${source}`,
|
||||
`Последнее обновление: ${updatedAt}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
async function loadVoiceTtsConfig() {
|
||||
try {
|
||||
const data = await api('ai', 'ai/voice/config/tts');
|
||||
state.voiceTtsConfig = data;
|
||||
syncVoiceTtsConfigFields(data);
|
||||
renderVoiceTtsConfigSummary(data);
|
||||
$('voiceTtsConfigOutput').textContent = JSON.stringify(data, null, 2);
|
||||
log('Настройки voice TTS загружены', { source: data.source, updated_at: data.updated_at });
|
||||
} catch (err) {
|
||||
$('voiceTtsConfigOutput').textContent = err.message;
|
||||
log('Не удалось загрузить настройки voice TTS', { error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
async function saveVoiceTtsConfig() {
|
||||
try {
|
||||
const payload = serializeVoiceTtsConfigForm();
|
||||
const data = await api('ai', 'ai/voice/config/tts', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
state.voiceTtsConfig = data;
|
||||
syncVoiceTtsConfigFields(data);
|
||||
renderVoiceTtsConfigSummary(data);
|
||||
$('voiceTtsConfigOutput').textContent = JSON.stringify(data, null, 2);
|
||||
log('Настройки voice TTS сохранены', { provider: data?.config?.provider, updated_at: data.updated_at });
|
||||
} catch (err) {
|
||||
$('voiceTtsConfigOutput').textContent = err.message;
|
||||
log('Не удалось сохранить настройки voice TTS', { error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
function resetVoiceTtsConfigForm() {
|
||||
const snapshot = state.voiceTtsConfig;
|
||||
if (!snapshot) {
|
||||
$('voiceTtsConfigOutput').textContent = 'Сначала загрузите текущие настройки voice TTS.';
|
||||
return;
|
||||
}
|
||||
syncVoiceTtsConfigFields(snapshot);
|
||||
renderVoiceTtsConfigSummary(snapshot);
|
||||
$('voiceTtsConfigOutput').textContent = JSON.stringify(snapshot, null, 2);
|
||||
log('Форма voice TTS сброшена к текущим настройкам');
|
||||
}
|
||||
|
||||
function bindVoiceTtsPreset(selectId, inputId) {
|
||||
const select = $(selectId);
|
||||
if (!select) {
|
||||
return;
|
||||
}
|
||||
select.addEventListener('change', () => {
|
||||
if (select.value) {
|
||||
$(inputId).value = select.value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function parseIvrFlowJson() {
|
||||
ensureDefaultIvrFlowJson();
|
||||
return JSON.parse($('ivrFlowJson').value || '{}');
|
||||
@@ -1177,6 +1344,7 @@ function wire() {
|
||||
loadQueues(),
|
||||
loadIvrFlows(),
|
||||
loadVoiceNameConfig(),
|
||||
loadVoiceTtsConfig(),
|
||||
loadAsteriskStatus(),
|
||||
loadAsteriskEvents(),
|
||||
]);
|
||||
@@ -1204,6 +1372,15 @@ function wire() {
|
||||
$('loadVoiceNameConfigBtn').addEventListener('click', loadVoiceNameConfig);
|
||||
$('saveVoiceNameConfigBtn').addEventListener('click', saveVoiceNameConfig);
|
||||
$('resetVoiceNameConfigBtn').addEventListener('click', resetVoiceNameConfigForm);
|
||||
$('loadVoiceTtsConfigBtn').addEventListener('click', loadVoiceTtsConfig);
|
||||
$('saveVoiceTtsConfigBtn').addEventListener('click', saveVoiceTtsConfig);
|
||||
$('resetVoiceTtsConfigBtn').addEventListener('click', resetVoiceTtsConfigForm);
|
||||
bindVoiceTtsPreset('voiceTtsYandexRuVoicePreset', 'voiceTtsYandexRuVoice');
|
||||
bindVoiceTtsPreset('voiceTtsYandexKzVoicePreset', 'voiceTtsYandexKzVoice');
|
||||
bindVoiceTtsPreset('voiceTtsElevenRuVoicePreset', 'voiceTtsElevenRuVoice');
|
||||
bindVoiceTtsPreset('voiceTtsElevenKzVoicePreset', 'voiceTtsElevenKzVoice');
|
||||
bindVoiceTtsPreset('voiceTtsOpenAiRuVoicePreset', 'voiceTtsOpenAiRuVoice');
|
||||
bindVoiceTtsPreset('voiceTtsOpenAiKzVoicePreset', 'voiceTtsOpenAiKzVoice');
|
||||
$('loadAsteriskStatusBtn').addEventListener('click', loadAsteriskStatus);
|
||||
$('loadAsteriskEventsBtn').addEventListener('click', loadAsteriskEvents);
|
||||
$('loadAsteriskEventDetailBtn').addEventListener('click', loadAsteriskEventDetail);
|
||||
@@ -1231,6 +1408,7 @@ async function init() {
|
||||
loadQueues(),
|
||||
loadIvrFlows(),
|
||||
loadVoiceNameConfig(),
|
||||
loadVoiceTtsConfig(),
|
||||
loadAsteriskStatus(),
|
||||
loadAsteriskEvents(),
|
||||
]);
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
<a class="nav-link" href="#routes"><span class="nav-bullet"></span>Маршрутизация</a>
|
||||
<a class="nav-link" href="#ivr"><span class="nav-bullet"></span>IVR</a>
|
||||
<a class="nav-link" href="#voiceNameCollection"><span class="nav-bullet"></span>Voice AI</a>
|
||||
<a class="nav-link" href="#voiceTtsConfig"><span class="nav-bullet"></span>Voice TTS</a>
|
||||
<a class="nav-link" href="#asterisk"><span class="nav-bullet"></span>Asterisk</a>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -326,6 +327,143 @@
|
||||
<pre id="voiceNameConfigOutput" class="output">Raw payload voice name collection появится здесь.</pre>
|
||||
</article>
|
||||
|
||||
<article class="panel reveal" id="voiceTtsConfig">
|
||||
<h2>Voice AI: TTS</h2>
|
||||
<p class="hint">Выберите провайдера и голоса для RU и KZ. Для ElevenLabs в поле голоса хранится voice ID, для Brian уже есть готовый preset.</p>
|
||||
<pre id="voiceTtsSummary" class="output">Сводка по voice TTS появится здесь.</pre>
|
||||
|
||||
<div class="inline-form split-two">
|
||||
<label>
|
||||
Активный провайдер
|
||||
<select id="voiceTtsProvider">
|
||||
<option value="yandex">Yandex SpeechKit</option>
|
||||
<option value="elevenlabs">ElevenLabs</option>
|
||||
<option value="openai">OpenAI</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="hint">RU и KZ можно настроить отдельно, а провайдер переключать из админки без правки env.</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label>Yandex</label>
|
||||
<div class="inline-form split-two">
|
||||
<label>
|
||||
RU preset
|
||||
<select id="voiceTtsYandexRuVoicePreset"></select>
|
||||
</label>
|
||||
<label>
|
||||
RU voice
|
||||
<input id="voiceTtsYandexRuVoice" placeholder="jane" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="inline-form split-two">
|
||||
<label>
|
||||
KZ preset
|
||||
<select id="voiceTtsYandexKzVoicePreset"></select>
|
||||
</label>
|
||||
<label>
|
||||
KZ voice
|
||||
<input id="voiceTtsYandexKzVoice" placeholder="amira" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label>ElevenLabs</label>
|
||||
<div class="inline-form split-two">
|
||||
<label>
|
||||
RU preset
|
||||
<select id="voiceTtsElevenRuVoicePreset"></select>
|
||||
</label>
|
||||
<label>
|
||||
RU voice ID
|
||||
<input id="voiceTtsElevenRuVoice" placeholder="nPczCjzI2devNBz1zQrb" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="inline-form split-two">
|
||||
<label>
|
||||
RU model
|
||||
<input id="voiceTtsElevenRuModel" placeholder="eleven_v3" />
|
||||
</label>
|
||||
<label>
|
||||
RU language_code
|
||||
<input id="voiceTtsElevenRuLanguageCode" placeholder="ru" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="inline-form split-two">
|
||||
<label>
|
||||
KZ preset
|
||||
<select id="voiceTtsElevenKzVoicePreset"></select>
|
||||
</label>
|
||||
<label>
|
||||
KZ voice ID
|
||||
<input id="voiceTtsElevenKzVoice" placeholder="nPczCjzI2devNBz1zQrb" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="inline-form split-two">
|
||||
<label>
|
||||
KZ model
|
||||
<input id="voiceTtsElevenKzModel" placeholder="eleven_v3" />
|
||||
</label>
|
||||
<label>
|
||||
KZ language_code
|
||||
<input id="voiceTtsElevenKzLanguageCode" placeholder="kk" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label>OpenAI</label>
|
||||
<div class="inline-form split-two">
|
||||
<label>
|
||||
RU preset
|
||||
<select id="voiceTtsOpenAiRuVoicePreset"></select>
|
||||
</label>
|
||||
<label>
|
||||
RU voice
|
||||
<input id="voiceTtsOpenAiRuVoice" placeholder="alloy" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="inline-form split-two">
|
||||
<label>
|
||||
RU model
|
||||
<input id="voiceTtsOpenAiRuModel" placeholder="gpt-4o-mini-tts" />
|
||||
</label>
|
||||
<label>
|
||||
RU language_code
|
||||
<input id="voiceTtsOpenAiRuLanguageCode" placeholder="ru" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="inline-form split-two">
|
||||
<label>
|
||||
KZ preset
|
||||
<select id="voiceTtsOpenAiKzVoicePreset"></select>
|
||||
</label>
|
||||
<label>
|
||||
KZ voice
|
||||
<input id="voiceTtsOpenAiKzVoice" placeholder="alloy" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="inline-form split-two">
|
||||
<label>
|
||||
KZ model
|
||||
<input id="voiceTtsOpenAiKzModel" placeholder="gpt-4o-mini-tts" />
|
||||
</label>
|
||||
<label>
|
||||
KZ language_code
|
||||
<input id="voiceTtsOpenAiKzLanguageCode" placeholder="kz" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions compact-actions">
|
||||
<button id="loadVoiceTtsConfigBtn" class="btn ghost">Загрузить настройки</button>
|
||||
<button id="saveVoiceTtsConfigBtn" class="btn">Сохранить настройки</button>
|
||||
<button id="resetVoiceTtsConfigBtn" class="btn ghost">Сбросить форму к текущим</button>
|
||||
</div>
|
||||
<pre id="voiceTtsConfigOutput" class="output">Raw payload voice TTS появится здесь.</pre>
|
||||
</article>
|
||||
|
||||
<article class="panel reveal" id="asterisk">
|
||||
<h2>Мост Asterisk</h2>
|
||||
<p class="hint">Диагностика AMI-подключения, пересланных bridge-событий и ручного повтора.</p>
|
||||
|
||||
Reference in New Issue
Block a user