SHA256
UI: убрать голосовой ввод и связанные настройки
This commit is contained in:
+2
-2
@@ -1,2 +1,2 @@
|
||||
client.version=1.2.344
|
||||
server.version=1.2.316
|
||||
client.version=1.2.345
|
||||
server.version=1.2.317
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="6" y="6" width="52" height="52" rx="16" fill="#121A24" stroke="#E7EDF7" stroke-opacity="0.18" stroke-width="2"/>
|
||||
<circle cx="32" cy="26" r="8.5" stroke="#F2F5FB" stroke-width="2.6"/>
|
||||
<path d="M19 45.5C20.9 37.8 26.1 34 32 34C37.9 34 43.1 37.8 45 45.5" stroke="#F2F5FB" stroke-width="2.6" stroke-linecap="round"/>
|
||||
<circle cx="32" cy="24.5" r="10.5" stroke="#F2F5FB" stroke-width="3"/>
|
||||
<path d="M15.5 48C17.8 38.9 24.2 34.5 32 34.5C39.8 34.5 46.2 38.9 48.5 48" stroke="#F2F5FB" stroke-width="3" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 431 B After Width: | Height: | Size: 312 B |
@@ -1,5 +1,4 @@
|
||||
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="6" y="6" width="52" height="52" rx="16" fill="#121A24" stroke="#E7EDF7" stroke-opacity="0.18" stroke-width="2"/>
|
||||
<circle cx="32" cy="32" r="10.2" stroke="#F2F5FB" stroke-width="2.8"/>
|
||||
<path d="M32 16.8L34.8 20.4L39.3 19.2L40.2 23.8L44.8 24.7L43.6 29.2L47.2 32L43.6 34.8L44.8 39.3L40.2 40.2L39.3 44.8L34.8 43.6L32 47.2L29.2 43.6L24.7 44.8L23.8 40.2L19.2 39.3L20.4 34.8L16.8 32L20.4 29.2L19.2 24.7L23.8 23.8L24.7 19.2L29.2 20.4L32 16.8Z" stroke="#F2F5FB" stroke-width="2.4" stroke-linejoin="round"/>
|
||||
<circle cx="32" cy="32" r="3.5" fill="#F2F5FB"/>
|
||||
|
||||
|
Before Width: | Height: | Size: 667 B After Width: | Height: | Size: 543 B |
@@ -1,6 +1,6 @@
|
||||
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="6" y="6" width="52" height="52" rx="16" fill="#121A24" stroke="#E7EDF7" stroke-opacity="0.18" stroke-width="2"/>
|
||||
<path d="M18 25.5C18 22.4624 20.4624 20 23.5 20H40.5C43.5376 20 46 22.4624 46 25.5V38.5C46 41.5376 43.5376 44 40.5 44H23.5C20.4624 44 18 41.5376 18 38.5V25.5Z" stroke="#F2F5FB" stroke-width="2.6"/>
|
||||
<path d="M38 31.5H48V39C48 41.2091 46.2091 43 44 43H38V31.5Z" fill="#121A24" stroke="#F2F5FB" stroke-width="2.6"/>
|
||||
<circle cx="42.5" cy="37.2" r="1.8" fill="#F2F5FB"/>
|
||||
<path d="M13.5 24.5C13.5 20.9101 16.4101 18 20 18H41.5C45.0899 18 48 20.9101 48 24.5V26H23.5C21.0147 26 19 28.0147 19 30.5C19 32.9853 21.0147 35 23.5 35H48V39.5C48 43.0899 45.0899 46 41.5 46H20C16.4101 46 13.5 43.0899 13.5 39.5V24.5Z" stroke="#F2F5FB" stroke-width="3" stroke-linejoin="round"/>
|
||||
<path d="M19 30.5C19 28.0147 21.0147 26 23.5 26H50.5V35H23.5C21.0147 35 19 32.9853 19 30.5Z" stroke="#F2F5FB" stroke-width="3" stroke-linejoin="round"/>
|
||||
<circle cx="42.5" cy="30.5" r="2.4" fill="#F2F5FB"/>
|
||||
<path d="M18.5 22H38" stroke="#F2F5FB" stroke-width="3" stroke-linecap="round" opacity="0.9"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 599 B After Width: | Height: | Size: 707 B |
@@ -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'),
|
||||
|
||||
@@ -5029,22 +5029,6 @@ html, body { overflow-x: hidden; }
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.voice-level-wrap {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.voice-level-fill {
|
||||
width: 2%;
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
background: rgba(212, 175, 55, 0.95);
|
||||
transition: width 80ms linear;
|
||||
}
|
||||
|
||||
.dm-screen .input,
|
||||
.dm-input {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
@@ -5140,16 +5124,6 @@ html, body { overflow-x: hidden; }
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.speech-actions-top {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.speech-send-now-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dm-screen .dm-list > .card.meta-muted {
|
||||
width: calc(100% - 40px);
|
||||
margin: 0 20px;
|
||||
|
||||
Reference in New Issue
Block a user