SHA256
UI: убрать голосовой ввод и связанные настройки
This commit is contained in:
@@ -58,7 +58,6 @@ import * as walletView from './pages/wallet-view.js?v=202606281930';
|
||||
import * as settingsView from './pages/settings-view.js';
|
||||
import * as developerSettingsView from './pages/developer-settings-view.js';
|
||||
import * as serverSettingsView from './pages/server-settings-view.js?v=202606161240';
|
||||
import * as toolsSettingsView from './pages/tools-settings-view.js';
|
||||
import * as remoteAddBlockSessionView from './pages/remote-addblock-session-view.js?v=202606281300';
|
||||
import * as deviceView from './pages/device-view.js?v=202606131435';
|
||||
import * as connectDeviceView from './pages/connect-device-view.js?v=202606142055';
|
||||
@@ -119,7 +118,6 @@ const routes = {
|
||||
'settings-view': settingsView,
|
||||
'developer-settings-view': developerSettingsView,
|
||||
'server-settings-view': serverSettingsView,
|
||||
'tools-settings-view': toolsSettingsView,
|
||||
'remote-addblock-session-view': remoteAddBlockSessionView,
|
||||
'device-view': deviceView,
|
||||
'connect-device-view': connectDeviceView,
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
import {
|
||||
createMicrophoneRecorder,
|
||||
isSpeechToTextConfigured,
|
||||
transcribeAudioBySettings,
|
||||
} from '../services/speech-tools-service.js';
|
||||
import { state } from '../state.js';
|
||||
|
||||
function formatDuration(ms) {
|
||||
const totalSec = Math.max(0, Math.floor(Number(ms || 0) / 1000));
|
||||
const mm = String(Math.floor(totalSec / 60)).padStart(2, '0');
|
||||
const ss = String(totalSec % 60).padStart(2, '0');
|
||||
return `${mm}:${ss}`;
|
||||
}
|
||||
|
||||
function showSttMissingConfigDialog(navigate) {
|
||||
const goSettings = window.confirm(
|
||||
'Распознавание речи не настроено. Перейти в настройки инструментов?'
|
||||
);
|
||||
if (goSettings) navigate('tools-settings-view');
|
||||
}
|
||||
|
||||
export async function openSpeechInputModal({ navigate, onTextReady, onSendText, onSendQueued }) {
|
||||
if (!isSpeechToTextConfigured(state.entrySettings)) {
|
||||
showSttMissingConfigDialog(navigate);
|
||||
return;
|
||||
}
|
||||
|
||||
const root = document.getElementById('modal-root');
|
||||
const host = document.createElement('div');
|
||||
host.innerHTML = `
|
||||
<div class="modal" id="speech-input-modal-layer">
|
||||
<div class="modal-card stack">
|
||||
<h3 class="modal-title">Голосовой ввод</h3>
|
||||
<p class="meta-muted" id="speech-input-status">Идёт запись...</p>
|
||||
<div class="voice-level-wrap"><div class="voice-level-fill" id="speech-level-fill"></div></div>
|
||||
<p class="meta-muted" id="speech-input-time">00:00</p>
|
||||
<p class="inline-error" id="speech-input-error"></p>
|
||||
<div class="speech-actions-top">
|
||||
<button class="secondary-btn" type="button" id="speech-cancel">Отмена</button>
|
||||
<button class="primary-btn" type="button" id="speech-ok">OK</button>
|
||||
</div>
|
||||
<button class="primary-btn speech-send-now-btn" type="button" id="speech-send-now">Распознать и сразу отправить сообщение</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
root.append(host);
|
||||
|
||||
const statusEl = host.querySelector('#speech-input-status');
|
||||
const timeEl = host.querySelector('#speech-input-time');
|
||||
const levelEl = host.querySelector('#speech-level-fill');
|
||||
const errorEl = host.querySelector('#speech-input-error');
|
||||
const cancelBtn = host.querySelector('#speech-cancel');
|
||||
const sendNowBtn = host.querySelector('#speech-send-now');
|
||||
const okBtn = host.querySelector('#speech-ok');
|
||||
const recorder = createMicrophoneRecorder();
|
||||
let closed = false;
|
||||
let busy = false;
|
||||
|
||||
const close = () => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
host.remove();
|
||||
};
|
||||
|
||||
const setBusy = (flag) => {
|
||||
busy = !!flag;
|
||||
cancelBtn.disabled = busy;
|
||||
sendNowBtn.disabled = busy;
|
||||
okBtn.disabled = busy;
|
||||
okBtn.textContent = busy ? 'Распознаю...' : 'OK';
|
||||
sendNowBtn.textContent = busy ? 'Распознаю...' : 'Распознать и сразу отправить сообщение';
|
||||
};
|
||||
|
||||
try {
|
||||
await recorder.start(({ elapsedMs, level }) => {
|
||||
if (timeEl) timeEl.textContent = formatDuration(elapsedMs);
|
||||
if (levelEl) levelEl.style.width = `${Math.max(2, Math.round((Number(level) || 0) * 100))}%`;
|
||||
});
|
||||
} catch (error) {
|
||||
close();
|
||||
window.alert(`Не удалось получить доступ к микрофону: ${error?.message || 'unknown'}`);
|
||||
return;
|
||||
}
|
||||
|
||||
cancelBtn.addEventListener('click', () => {
|
||||
recorder.cancel();
|
||||
close();
|
||||
});
|
||||
|
||||
okBtn.addEventListener('click', async () => {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const audioBlob = await recorder.stop();
|
||||
host.innerHTML = `
|
||||
<div class="modal" id="speech-input-modal-layer">
|
||||
<div class="modal-card stack">
|
||||
<h3 class="modal-title">Голосовой ввод</h3>
|
||||
<p class="meta-muted">Идёт распознавание текста...</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
const text = await transcribeAudioBySettings(audioBlob, state.entrySettings);
|
||||
if (typeof onTextReady === 'function') onTextReady(text);
|
||||
close();
|
||||
} catch (error) {
|
||||
setBusy(false);
|
||||
statusEl.textContent = 'Идёт запись...';
|
||||
errorEl.textContent = `Ошибка распознавания: ${error?.message || 'unknown'}`;
|
||||
}
|
||||
});
|
||||
|
||||
sendNowBtn.addEventListener('click', async () => {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const audioBlob = await recorder.stop();
|
||||
close();
|
||||
if (typeof onSendQueued === 'function') onSendQueued();
|
||||
const text = await transcribeAudioBySettings(audioBlob, state.entrySettings);
|
||||
if (typeof onSendText === 'function') {
|
||||
await onSendText(text);
|
||||
} else if (typeof onTextReady === 'function') {
|
||||
onTextReady(text);
|
||||
}
|
||||
} catch (error) {
|
||||
setBusy(false);
|
||||
close();
|
||||
window.alert(`Ошибка распознавания: ${error?.message || 'unknown'}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
showToast,
|
||||
softHaptic,
|
||||
} from '../services/channels-ux.js';
|
||||
import { openSpeechInputModal } from '../components/speech-input-modal.js';
|
||||
import { navigateBack } from '../router.js';
|
||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||
import { loadProfileSnapshot } from '../services/user-profile-params.js';
|
||||
@@ -339,9 +338,6 @@ function openReplyModal({ onSubmit, navigate }) {
|
||||
<div class="modal-card stack">
|
||||
<h3 class="modal-title">Ответ</h3>
|
||||
<textarea id="thread-reply-text" class="input" rows="5" maxlength="2000" placeholder="Текст ответа"></textarea>
|
||||
<div class="row wrap-row">
|
||||
<button class="ghost-btn" id="thread-reply-voice" type="button">🎤 Голосом</button>
|
||||
</div>
|
||||
<div class="meta-muted inline-error" id="thread-reply-error"></div>
|
||||
<div class="form-actions-grid">
|
||||
<button class="secondary-btn" id="thread-reply-cancel" type="button">Отмена</button>
|
||||
@@ -368,15 +364,6 @@ function openReplyModal({ onSubmit, navigate }) {
|
||||
};
|
||||
|
||||
root.querySelector('#thread-reply-cancel')?.addEventListener('click', close);
|
||||
root.querySelector('#thread-reply-voice')?.addEventListener('click', async () => {
|
||||
await openSpeechInputModal({
|
||||
navigate,
|
||||
onTextReady: (text) => {
|
||||
const prev = String(textEl?.value || '').trim();
|
||||
if (textEl) textEl.value = prev ? `${prev} ${text}` : text;
|
||||
},
|
||||
});
|
||||
});
|
||||
root.querySelector('#thread-reply-submit')?.addEventListener('click', async () => {
|
||||
if (inFlight) return;
|
||||
|
||||
@@ -423,9 +410,6 @@ function openRepostModal({ navigate, channels = [], onSubmit }) {
|
||||
<select id="thread-repost-channel-select" class="input">${options}</select>
|
||||
<label class="meta-muted" for="thread-repost-comment">Комментарий</label>
|
||||
<textarea id="thread-repost-comment" class="input" rows="5" maxlength="2000" placeholder="Комментарий к репосту"></textarea>
|
||||
<div class="row wrap-row">
|
||||
<button class="ghost-btn" id="thread-repost-voice" type="button">🎤 Голосом</button>
|
||||
</div>
|
||||
<div class="meta-muted inline-error" id="thread-repost-error"></div>
|
||||
<div class="form-actions-grid">
|
||||
<button class="secondary-btn" id="thread-repost-cancel" type="button">Отмена</button>
|
||||
@@ -456,16 +440,6 @@ function openRepostModal({ navigate, channels = [], onSubmit }) {
|
||||
};
|
||||
|
||||
root.querySelector('#thread-repost-cancel')?.addEventListener('click', close);
|
||||
root.querySelector('#thread-repost-voice')?.addEventListener('click', async () => {
|
||||
await openSpeechInputModal({
|
||||
navigate,
|
||||
onTextReady: (text) => {
|
||||
const prev = String(textEl?.value || '').trim();
|
||||
if (textEl) textEl.value = prev ? `${prev} ${text}` : text;
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
submitEl?.addEventListener('click', async () => {
|
||||
if (inFlight) return;
|
||||
const idx = Number(selectEl?.value ?? -1);
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
showToast,
|
||||
softHaptic,
|
||||
} from '../services/channels-ux.js';
|
||||
import { openSpeechInputModal } from '../components/speech-input-modal.js';
|
||||
import { navigateBack } from '../router.js';
|
||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||
import { loadProfileSnapshot } from '../services/user-profile-params.js';
|
||||
@@ -314,9 +313,6 @@ function openReplyModal({ onSubmit, navigate }) {
|
||||
<div class="modal-card stack">
|
||||
<h3 class="modal-title">Ответ</h3>
|
||||
<textarea id="reply-text" class="input" rows="5" maxlength="2000" placeholder="Текст ответа"></textarea>
|
||||
<div class="row wrap-row">
|
||||
<button class="ghost-btn" id="reply-voice" type="button">🎤 Голосом</button>
|
||||
</div>
|
||||
<div class="meta-muted inline-error" id="reply-error"></div>
|
||||
<div class="form-actions-grid">
|
||||
<button class="secondary-btn" id="reply-cancel" type="button">Отмена</button>
|
||||
@@ -343,15 +339,6 @@ function openReplyModal({ onSubmit, navigate }) {
|
||||
};
|
||||
|
||||
root.querySelector('#reply-cancel')?.addEventListener('click', close);
|
||||
root.querySelector('#reply-voice')?.addEventListener('click', async () => {
|
||||
await openSpeechInputModal({
|
||||
navigate,
|
||||
onTextReady: (text) => {
|
||||
const prev = String(textEl?.value || '').trim();
|
||||
if (textEl) textEl.value = prev ? `${prev} ${text}` : text;
|
||||
},
|
||||
});
|
||||
});
|
||||
submitEl?.addEventListener('click', async () => {
|
||||
if (inFlight) return;
|
||||
|
||||
@@ -399,9 +386,6 @@ function openRepostModal({ navigate, channels = [], onSubmit }) {
|
||||
<select id="repost-channel-select" class="input">${options}</select>
|
||||
<label class="meta-muted" for="repost-comment">Комментарий</label>
|
||||
<textarea id="repost-comment" class="input" rows="5" maxlength="2000" placeholder="Комментарий к репосту"></textarea>
|
||||
<div class="row wrap-row">
|
||||
<button class="ghost-btn" id="repost-voice" type="button">🎤 Голосом</button>
|
||||
</div>
|
||||
<div class="meta-muted inline-error" id="repost-error"></div>
|
||||
<div class="form-actions-grid">
|
||||
<button class="secondary-btn" id="repost-cancel" type="button">Отмена</button>
|
||||
@@ -429,15 +413,6 @@ function openRepostModal({ navigate, channels = [], onSubmit }) {
|
||||
|
||||
const close = () => { root.innerHTML = ''; };
|
||||
root.querySelector('#repost-cancel')?.addEventListener('click', close);
|
||||
root.querySelector('#repost-voice')?.addEventListener('click', async () => {
|
||||
await openSpeechInputModal({
|
||||
navigate,
|
||||
onTextReady: (text) => {
|
||||
const prev = String(textEl?.value || '').trim();
|
||||
if (textEl) textEl.value = prev ? `${prev} ${text}` : text;
|
||||
},
|
||||
});
|
||||
});
|
||||
submitEl?.addEventListener('click', async () => {
|
||||
if (inFlight) return;
|
||||
const idx = Number(selectEl?.value ?? -1);
|
||||
|
||||
@@ -313,27 +313,22 @@ function openChatActionsMenu({
|
||||
});
|
||||
}
|
||||
|
||||
function showTtsMissingConfigDialog(navigate) {
|
||||
function showTtsMissingConfigDialog() {
|
||||
const root = document.getElementById('modal-root');
|
||||
if (!root) return;
|
||||
root.innerHTML = `
|
||||
<div class="modal" id="chat-tts-missing-modal">
|
||||
<div class="modal-card stack dm-dialog-card">
|
||||
<h3 class="modal-title">Озвучка не настроена</h3>
|
||||
<p class="meta-muted">Перейти в настройки инструментов?</p>
|
||||
<p class="meta-muted">Функция голосовых инструментов сейчас отключена.</p>
|
||||
<div class="form-actions-grid">
|
||||
<button class="secondary-btn" type="button" id="chat-tts-no">Нет</button>
|
||||
<button class="primary-btn" type="button" id="chat-tts-yes">Да</button>
|
||||
<button class="primary-btn" type="button" id="chat-tts-ok">OK</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
const close = () => { root.innerHTML = ''; };
|
||||
root.querySelector('#chat-tts-no')?.addEventListener('click', close);
|
||||
root.querySelector('#chat-tts-yes')?.addEventListener('click', () => {
|
||||
close();
|
||||
navigate('tools-settings-view');
|
||||
});
|
||||
root.querySelector('#chat-tts-ok')?.addEventListener('click', close);
|
||||
}
|
||||
|
||||
function autoResizeComposer(textarea) {
|
||||
@@ -787,7 +782,7 @@ export function render({ navigate, route }) {
|
||||
|
||||
const handleReadAloud = async (msg) => {
|
||||
if (!isTextToSpeechConfigured(state.entrySettings)) {
|
||||
showTtsMissingConfigDialog(navigate);
|
||||
showTtsMissingConfigDialog();
|
||||
return;
|
||||
}
|
||||
const parsedText = parseDmTechBlocks(String(msg?.text || ''));
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { addAppLogEntry, authService, closeCurrentSessionAndSignOut, state } from '../state.js';
|
||||
import { addAppLogEntry, authService, closeCurrentSessionAndSignOut } from '../state.js';
|
||||
|
||||
export const pageMeta = { id: 'settings-view', title: 'Настройки' };
|
||||
|
||||
@@ -43,7 +43,6 @@ export function render({ navigate }) {
|
||||
<button class="text-btn" type="button" id="settings-device">Устройства</button>
|
||||
<button class="text-btn" type="button" id="settings-remote-addblock">Remote AddBlock через homeserver</button>
|
||||
<button class="text-btn" type="button" id="settings-servers">Настройки серверов</button>
|
||||
<button class="text-btn" type="button" id="settings-tools">Настройки инструментов ввода</button>
|
||||
<button class="text-btn" type="button" id="settings-language">Язык / Language</button>
|
||||
<button class="text-btn" type="button" id="settings-signout">Завершить текущий сеанс</button>
|
||||
`;
|
||||
@@ -51,7 +50,6 @@ export function render({ navigate }) {
|
||||
card.querySelector('#settings-device').addEventListener('click', () => navigate('device-view'));
|
||||
card.querySelector('#settings-remote-addblock').addEventListener('click', () => navigate('remote-addblock-session-view'));
|
||||
card.querySelector('#settings-servers').addEventListener('click', () => navigate('server-settings-view'));
|
||||
card.querySelector('#settings-tools').addEventListener('click', () => navigate('tools-settings-view'));
|
||||
card.querySelector('#settings-language').addEventListener('click', () => navigate('language-view'));
|
||||
|
||||
const signOutBtn = card.querySelector('#settings-signout');
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { saveEntrySettings, state } from '../state.js';
|
||||
import { speakTextBySettings } from '../services/speech-tools-service.js';
|
||||
|
||||
export const pageMeta = { id: 'tools-settings-view', title: 'Настройки инструментов' };
|
||||
|
||||
function optionsMarkup(options, selected) {
|
||||
return options.map((opt) => (
|
||||
`<option value="${opt.value}" ${selected === opt.value ? 'selected' : ''}>${opt.label}</option>`
|
||||
)).join('');
|
||||
}
|
||||
|
||||
export function render({ navigate }) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
|
||||
const stt = state.entrySettings.tools?.speechToText || {};
|
||||
const tts = state.entrySettings.tools?.textToSpeech || {};
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card stack';
|
||||
card.innerHTML = `
|
||||
<h3 class="modal-title">Распознавание речи</h3>
|
||||
<label class="field-label">Провайдер</label>
|
||||
<select class="input" id="stt-provider">
|
||||
${optionsMarkup([{ value: 'openai', label: 'OpenAI (Whisper/Transcribe)' }], String(stt.provider || 'openai'))}
|
||||
</select>
|
||||
<label class="field-label">Уровень</label>
|
||||
<select class="input" id="stt-quality">
|
||||
${optionsMarkup([
|
||||
{ value: 'easy', label: 'Easy (дешевле)' },
|
||||
{ value: 'medium', label: 'Medium' },
|
||||
{ value: 'hard', label: 'Hard (лучше)' },
|
||||
], String(stt.quality || 'medium'))}
|
||||
</select>
|
||||
<label class="field-label">Адрес OpenAI API</label>
|
||||
<input class="input" id="stt-base-url" type="text" value="${String(stt.baseUrl || 'https://api.openai.com/v1')}" placeholder="https://api.openai.com/v1" />
|
||||
<label class="field-label">Кастомная модель (опционально)</label>
|
||||
<input class="input" id="stt-model" type="text" value="${String(stt.model || '')}" placeholder="например: gpt-4o-mini-transcribe" />
|
||||
<label class="field-label">API key</label>
|
||||
<input class="input" id="stt-api-key" type="password" value="${String(stt.apiKey || '')}" placeholder="sk-..." />
|
||||
`;
|
||||
|
||||
const card2 = document.createElement('div');
|
||||
card2.className = 'card stack';
|
||||
card2.innerHTML = `
|
||||
<h3 class="modal-title">Прочесть вслух (TTS)</h3>
|
||||
<label class="field-label">Провайдер</label>
|
||||
<select class="input" id="tts-provider">
|
||||
${optionsMarkup([
|
||||
{ value: 'browser', label: 'Браузер (SpeechSynthesis)' },
|
||||
{ value: 'piper-http', label: 'Piper (локальный HTTP)' },
|
||||
{ value: 'openai', label: 'OpenAI TTS API' },
|
||||
], String(tts.provider || 'browser'))}
|
||||
</select>
|
||||
<label class="field-label">Уровень</label>
|
||||
<select class="input" id="tts-quality">
|
||||
${optionsMarkup([
|
||||
{ value: 'easy', label: 'Easy' },
|
||||
{ value: 'medium', label: 'Medium' },
|
||||
{ value: 'hard', label: 'Hard' },
|
||||
], String(tts.quality || 'medium'))}
|
||||
</select>
|
||||
<label class="field-label">Голос</label>
|
||||
<input class="input" id="tts-voice" type="text" value="${String(tts.voice || '')}" placeholder="например: andrey / alloy" />
|
||||
<label class="field-label">Piper HTTP адрес</label>
|
||||
<input class="input" id="tts-piper-url" type="text" value="${String(tts.piperBaseUrl || 'http://127.0.0.1:5000')}" placeholder="http://127.0.0.1:5000" />
|
||||
<label class="field-label">OpenAI/внешний API адрес</label>
|
||||
<input class="input" id="tts-external-url" type="text" value="${String(tts.externalBaseUrl || '')}" placeholder="https://api.openai.com/v1" />
|
||||
<label class="field-label">API key (для внешнего API)</label>
|
||||
<input class="input" id="tts-api-key" type="password" value="${String(tts.apiKey || '')}" placeholder="sk-..." />
|
||||
<label class="field-label">Модель (для внешнего API)</label>
|
||||
<input class="input" id="tts-model" type="text" value="${String(tts.model || '')}" placeholder="например: gpt-4o-mini-tts" />
|
||||
<label class="field-label">Тестовая фраза</label>
|
||||
<input class="input" id="tts-test-text" type="text" value="Привет! Проверка озвучки работает." />
|
||||
<div class="row wrap-row">
|
||||
<button class="ghost-btn" type="button" id="tts-test-btn">Проверить озвучку</button>
|
||||
<button class="ghost-btn" type="button" id="piper-autofill">Загрузить и настроить (шаблон)</button>
|
||||
<button class="ghost-btn" type="button" id="piper-links">Ссылки на Piper/голоса</button>
|
||||
</div>
|
||||
<p class="meta-muted">Для офлайн-озвучки через Piper используйте локальный HTTP-обёртчик. Кнопка «шаблон» подставляет базовые значения.</p>
|
||||
`;
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'auth-footer-actions';
|
||||
actions.innerHTML = `
|
||||
<button class="ghost-btn" type="button" id="tools-cancel">Отмена</button>
|
||||
<button class="primary-btn" type="button" id="tools-save">Сохранить</button>
|
||||
`;
|
||||
|
||||
actions.querySelector('#tools-cancel')?.addEventListener('click', () => navigate('settings-view'));
|
||||
actions.querySelector('#tools-save')?.addEventListener('click', async () => {
|
||||
const next = {
|
||||
...state.entrySettings,
|
||||
tools: {
|
||||
speechToText: {
|
||||
provider: card.querySelector('#stt-provider')?.value || 'openai',
|
||||
quality: card.querySelector('#stt-quality')?.value || 'medium',
|
||||
baseUrl: String(card.querySelector('#stt-base-url')?.value || '').trim(),
|
||||
apiKey: String(card.querySelector('#stt-api-key')?.value || '').trim(),
|
||||
model: String(card.querySelector('#stt-model')?.value || '').trim(),
|
||||
},
|
||||
textToSpeech: {
|
||||
provider: card2.querySelector('#tts-provider')?.value || 'browser',
|
||||
quality: card2.querySelector('#tts-quality')?.value || 'medium',
|
||||
voice: String(card2.querySelector('#tts-voice')?.value || '').trim(),
|
||||
piperBaseUrl: String(card2.querySelector('#tts-piper-url')?.value || '').trim(),
|
||||
externalBaseUrl: String(card2.querySelector('#tts-external-url')?.value || '').trim(),
|
||||
apiKey: String(card2.querySelector('#tts-api-key')?.value || '').trim(),
|
||||
model: String(card2.querySelector('#tts-model')?.value || '').trim(),
|
||||
},
|
||||
},
|
||||
};
|
||||
await saveEntrySettings(next);
|
||||
navigate('settings-view');
|
||||
});
|
||||
|
||||
card2.querySelector('#piper-autofill')?.addEventListener('click', () => {
|
||||
card2.querySelector('#tts-provider').value = 'piper-http';
|
||||
card2.querySelector('#tts-piper-url').value = 'http://127.0.0.1:5000';
|
||||
card2.querySelector('#tts-quality').value = 'medium';
|
||||
if (!String(card2.querySelector('#tts-voice').value || '').trim()) {
|
||||
card2.querySelector('#tts-voice').value = 'ru_RU-irina-medium';
|
||||
}
|
||||
});
|
||||
|
||||
card2.querySelector('#piper-links')?.addEventListener('click', () => {
|
||||
window.open('https://github.com/rhasspy/piper', '_blank', 'noopener,noreferrer');
|
||||
window.open('https://huggingface.co/rhasspy/piper-voices/tree/main', '_blank', 'noopener,noreferrer');
|
||||
});
|
||||
|
||||
card2.querySelector('#tts-test-btn')?.addEventListener('click', async () => {
|
||||
const ttsProvider = card2.querySelector('#tts-provider')?.value || 'openai';
|
||||
const text = String(card2.querySelector('#tts-test-text')?.value || '').trim();
|
||||
const runtimeSettings = {
|
||||
...state.entrySettings,
|
||||
tools: {
|
||||
...state.entrySettings.tools,
|
||||
textToSpeech: {
|
||||
provider: ttsProvider,
|
||||
quality: card2.querySelector('#tts-quality')?.value || 'medium',
|
||||
voice: String(card2.querySelector('#tts-voice')?.value || '').trim(),
|
||||
piperBaseUrl: String(card2.querySelector('#tts-piper-url')?.value || '').trim(),
|
||||
externalBaseUrl: String(card2.querySelector('#tts-external-url')?.value || '').trim(),
|
||||
apiKey: String(card2.querySelector('#tts-api-key')?.value || '').trim(),
|
||||
model: String(card2.querySelector('#tts-model')?.value || '').trim(),
|
||||
},
|
||||
},
|
||||
};
|
||||
try {
|
||||
await speakTextBySettings(text || 'Проверка озвучки', runtimeSettings);
|
||||
} catch (error) {
|
||||
window.alert(`Ошибка озвучки: ${error?.message || 'unknown'}`);
|
||||
}
|
||||
});
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
title: 'Настройки инструментов',
|
||||
leftAction: { label: '←', onClick: () => navigate('settings-view') },
|
||||
}),
|
||||
card,
|
||||
card2,
|
||||
actions,
|
||||
);
|
||||
|
||||
return screen;
|
||||
}
|
||||
@@ -30,7 +30,6 @@ const PRETTY_PATHS = new Map([
|
||||
['notifications-view', 'notifications'],
|
||||
['settings-view', 'settings'],
|
||||
['server-settings-view', 'settings/servers'],
|
||||
['tools-settings-view', 'settings/tools'],
|
||||
['developer-settings-view', 'settings/developer'],
|
||||
['trusted-device-login-settings-view', 'settings/device-login'],
|
||||
['language-view', 'settings/language'],
|
||||
@@ -260,7 +259,6 @@ export function getRoute() {
|
||||
if (pageId === 'settings') {
|
||||
const sub = decodePart(segments[1] || '').toLowerCase();
|
||||
if (sub === 'servers') return { pageId: 'server-settings-view', params: {} };
|
||||
if (sub === 'tools') return { pageId: 'tools-settings-view', params: {} };
|
||||
if (sub === 'developer') return { pageId: 'developer-settings-view', params: {} };
|
||||
if (sub === 'device-login') return { pageId: 'trusted-device-login-settings-view', params: {} };
|
||||
if (sub === 'language') return { pageId: 'language-view', params: {} };
|
||||
@@ -372,7 +370,6 @@ export function resolveToolbarActive(pageId) {
|
||||
pageId === 'settings-view' ||
|
||||
pageId === 'developer-settings-view' ||
|
||||
pageId === 'server-settings-view' ||
|
||||
pageId === 'tools-settings-view' ||
|
||||
pageId === 'remote-addblock-session-view' ||
|
||||
pageId === 'device-view' ||
|
||||
pageId === 'connect-device-view' ||
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
const OPENAI_MODELS_BY_QUALITY = {
|
||||
easy: 'whisper-1',
|
||||
medium: 'gpt-4o-mini-transcribe',
|
||||
hard: 'gpt-4o-transcribe',
|
||||
};
|
||||
|
||||
const PIPER_LENGTH_SCALE_BY_QUALITY = {
|
||||
easy: '1.15',
|
||||
medium: '1.0',
|
||||
@@ -16,18 +10,6 @@ function normalizeOpenAiBaseUrl(url) {
|
||||
return value.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function resolveSttModel(config) {
|
||||
const quality = String(config?.quality || 'medium').toLowerCase();
|
||||
const customModel = String(config?.model || '').trim();
|
||||
return customModel || OPENAI_MODELS_BY_QUALITY[quality] || OPENAI_MODELS_BY_QUALITY.medium;
|
||||
}
|
||||
|
||||
export function isSpeechToTextConfigured(entrySettings) {
|
||||
const cfg = entrySettings?.tools?.speechToText || {};
|
||||
if (String(cfg.provider || 'openai') !== 'openai') return false;
|
||||
return !!String(cfg.apiKey || '').trim();
|
||||
}
|
||||
|
||||
export function isTextToSpeechConfigured(entrySettings) {
|
||||
const cfg = entrySettings?.tools?.textToSpeech || {};
|
||||
const provider = String(cfg.provider || 'browser');
|
||||
@@ -37,135 +19,6 @@ export function isTextToSpeechConfigured(entrySettings) {
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function transcribeAudioBySettings(audioBlob, entrySettings) {
|
||||
const cfg = entrySettings?.tools?.speechToText || {};
|
||||
const provider = String(cfg.provider || 'openai');
|
||||
if (provider !== 'openai') {
|
||||
throw new Error('Поддерживается только провайдер OpenAI для распознавания.');
|
||||
}
|
||||
|
||||
const apiKey = String(cfg.apiKey || '').trim();
|
||||
if (!apiKey) throw new Error('Не заполнен OpenAI API key.');
|
||||
|
||||
const model = resolveSttModel(cfg);
|
||||
const baseUrl = normalizeOpenAiBaseUrl(cfg.baseUrl);
|
||||
|
||||
const form = new FormData();
|
||||
form.append('model', model);
|
||||
form.append('language', 'ru');
|
||||
form.append('response_format', 'json');
|
||||
form.append('file', audioBlob, 'voice-input.webm');
|
||||
|
||||
const response = await fetch(`${baseUrl}/audio/transcriptions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: form,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text().catch(() => '');
|
||||
throw new Error(`Ошибка STT API (${response.status}): ${body || 'unknown error'}`);
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
const text = String(payload?.text || '').trim();
|
||||
if (!text) throw new Error('Пустой ответ распознавания.');
|
||||
return text;
|
||||
}
|
||||
|
||||
export function createMicrophoneRecorder() {
|
||||
const Ctx = window.AudioContext || window.webkitAudioContext;
|
||||
let stream = null;
|
||||
let recorder = null;
|
||||
let startedAtMs = 0;
|
||||
let chunks = [];
|
||||
let timerId = 0;
|
||||
let level = 0;
|
||||
let analyser = null;
|
||||
let rafId = 0;
|
||||
|
||||
async function start(onTick) {
|
||||
stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
|
||||
recorder = new MediaRecorder(stream, { mimeType: 'audio/webm' });
|
||||
startedAtMs = Date.now();
|
||||
chunks = [];
|
||||
|
||||
recorder.ondataavailable = (event) => {
|
||||
if (event?.data?.size > 0) chunks.push(event.data);
|
||||
};
|
||||
recorder.start(250);
|
||||
|
||||
timerId = window.setInterval(() => {
|
||||
if (typeof onTick === 'function') {
|
||||
onTick({
|
||||
elapsedMs: Date.now() - startedAtMs,
|
||||
level,
|
||||
});
|
||||
}
|
||||
}, 120);
|
||||
|
||||
if (Ctx) {
|
||||
const audioCtx = new Ctx();
|
||||
const source = audioCtx.createMediaStreamSource(stream);
|
||||
analyser = audioCtx.createAnalyser();
|
||||
analyser.fftSize = 256;
|
||||
source.connect(analyser);
|
||||
const data = new Uint8Array(analyser.frequencyBinCount);
|
||||
const read = () => {
|
||||
if (!analyser) return;
|
||||
analyser.getByteFrequencyData(data);
|
||||
let sum = 0;
|
||||
for (let i = 0; i < data.length; i += 1) sum += data[i];
|
||||
level = data.length > 0 ? Math.max(0, Math.min(1, (sum / data.length) / 255)) : 0;
|
||||
rafId = window.requestAnimationFrame(read);
|
||||
};
|
||||
read();
|
||||
}
|
||||
}
|
||||
|
||||
async function stop() {
|
||||
if (!recorder) return null;
|
||||
const blob = await new Promise((resolve) => {
|
||||
recorder.onstop = () => resolve(new Blob(chunks, { type: 'audio/webm' }));
|
||||
recorder.stop();
|
||||
});
|
||||
cleanup();
|
||||
return blob;
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
try {
|
||||
recorder?.stop();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
cleanup();
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
if (timerId) window.clearInterval(timerId);
|
||||
if (rafId) window.cancelAnimationFrame(rafId);
|
||||
timerId = 0;
|
||||
rafId = 0;
|
||||
analyser = null;
|
||||
if (stream) {
|
||||
stream.getTracks().forEach((track) => {
|
||||
try {
|
||||
track.stop();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
}
|
||||
stream = null;
|
||||
recorder = null;
|
||||
}
|
||||
|
||||
return { start, stop, cancel };
|
||||
}
|
||||
|
||||
export async function speakTextBySettings(text, entrySettings) {
|
||||
const value = String(text || '').trim();
|
||||
if (!value) return;
|
||||
|
||||
@@ -95,7 +95,6 @@ const DEFAULT_SHINE_SERVER_LOGIN_VALUE = DEFAULT_SHINE_SERVER_LOGIN;
|
||||
const DEFAULT_SHINE_SERVER_HTTP_VALUE = DEFAULT_SHINE_SERVER_HTTP;
|
||||
const DEFAULT_ARWEAVE_SERVER = 'https://arweave.net';
|
||||
const DEFAULT_CALL_PREFLIGHT_TIMEOUT_MS = 6000;
|
||||
const DEFAULT_OPENAI_BASE_URL = 'https://api.openai.com/v1';
|
||||
|
||||
function normalizeStoredSolanaServer(value) {
|
||||
const raw = String(value || '').trim();
|
||||
@@ -109,16 +108,8 @@ export function normalizeDmChatId(value) {
|
||||
|
||||
function normalizeToolsSettings(rawTools) {
|
||||
const source = rawTools && typeof rawTools === 'object' ? rawTools : {};
|
||||
const stt = source.speechToText && typeof source.speechToText === 'object' ? source.speechToText : {};
|
||||
const tts = source.textToSpeech && typeof source.textToSpeech === 'object' ? source.textToSpeech : {};
|
||||
return {
|
||||
speechToText: {
|
||||
provider: String(stt.provider || 'openai'),
|
||||
baseUrl: String(stt.baseUrl || DEFAULT_OPENAI_BASE_URL),
|
||||
apiKey: String(stt.apiKey || ''),
|
||||
quality: String(stt.quality || 'medium'),
|
||||
model: String(stt.model || ''),
|
||||
},
|
||||
textToSpeech: {
|
||||
provider: String(tts.provider || 'openai'),
|
||||
quality: String(tts.quality || 'medium'),
|
||||
|
||||
Reference in New Issue
Block a user