UI: убрать голосовой ввод и связанные настройки

This commit is contained in:
AidarKC
2026-07-22 19:18:56 +04:00
parent a183a586cb
commit 681437acb1
15 changed files with 14 additions and 561 deletions
-2
View File
@@ -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'}`);
}
});
}
-26
View File
@@ -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);
-25
View File
@@ -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);
+5 -10
View File
@@ -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 -3
View File
@@ -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');
-168
View File
@@ -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;
}
-3
View File
@@ -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;
-9
View File
@@ -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'),