UI: голосовой ввод/STT, TTS через OpenAI, настройки инструментов + учёт недопроверенных фич

This commit is contained in:
AidarKC 2026-05-13 02:01:51 +03:00
parent ddeaf82bfd
commit 8de4e95c6a
15 changed files with 716 additions and 5 deletions

View File

@ -48,3 +48,19 @@
- `call_declined` - `call_declined`
- `unknown_error` - `unknown_error`
- В этих записях искать поля `reason`, `failureStage`, `pcConnectionState`, `pcIceConnectionState`, `routeLabel`, `configuredTurnHosts*`, `reachableTurnHosts*`. - В этих записях искать поля `reason`, `failureStage`, `pcConnectionState`, `pcIceConnectionState`, `routeLabel`, `configuredTurnHosts*`, `reachableTurnHosts*`.
## Недопроверенные фичи (обязательно)
- Папка для учёта недопроверенных фич: `Dev_Docs/Pending_Features/`.
- По каждой новой доработке, которая требует ручной проверки, добавлять отдельный markdown-файл в `Dev_Docs/Pending_Features/`.
- Рекомендуемый формат имени файла: `YYYY-MM-DD_HHMM_<short-feature-name>.md`.
- Внутри файла обязательно указывать:
- краткое описание фичи;
- что именно проверять;
- ожидаемый результат;
- статус (например: `pending`, `in_progress`, `done`).
- После подтверждения, что фича проверена и работает корректно, соответствующий файл удалять.
- В `Dev_Docs/Pending_Features/README.md` вести краткий регламент и поддерживать актуальность.
## Коммуникация в начале нового чата
- В начале каждого нового чата (в первом ответе пользователю) дополнительно сообщать, сколько сейчас недопроверенных фич лежит в `Dev_Docs/Pending_Features/` (без учёта `README.md`).
- В том же первом ответе обязательно уточнять у пользователя, проверил ли он эти фичи и можно ли пометить их как завершённые (удалить соответствующие файлы).

View File

@ -0,0 +1,34 @@
# Голосовой ввод и озвучка (STT/TTS)
Статус: `pending`
## Что сделано
- Добавлен голосовой ввод в:
- личный чат;
- форму ответа в канале;
- форму нового сообщения в канале;
- форму ответа в треде.
- Добавлен инструмент «Прочесть вслух» для текста в чате.
- Добавлен экран `Настройки инструментов ввода`:
- STT через OpenAI (base URL, API key, качество, модель);
- TTS через Browser / Piper HTTP / OpenAI TTS.
- Для TTS добавлена кнопка «Проверить озвучку».
- Если инструмент не настроен, показывается предложение перейти в настройки.
- Настройки сохраняются локально и сохраняются между сессиями.
## Как проверять
1. Открыть `Настройки -> Настройки инструментов ввода`.
2. В блоке STT заполнить OpenAI API key (и при необходимости URL/модель).
3. В блоке TTS выбрать OpenAI, заполнить API key, при необходимости модель/голос.
4. Нажать «Проверить озвучку» и убедиться, что звук воспроизводится.
5. Открыть чат/канал/тред, нажать кнопку `🎤`, записать голос, нажать `OK`.
6. Убедиться, что распознанный текст подставился в поле ввода.
7. Отправить сообщение и проверить, что оно дошло.
## Ожидаемый результат
- Голосовой ввод работает во всех указанных формах.
- Озвучка через OpenAI TTS работает с тем же ключом, что и STT (если ключ имеет нужные права).
- При пустых настройках показывается понятный переход в настройки.

View File

@ -0,0 +1,19 @@
# Недопроверенные фичи
Эта папка хранит список доработок, которые уже реализованы, но ещё не подтверждены ручной проверкой.
## Как использовать
1. При каждом коммите с новыми пользовательскими фичами (если нужна ручная проверка) добавить новый файл:
- формат: `YYYY-MM-DD_HHMM_<short-feature-name>.md`
2. В файле указать:
- что сделано;
- как проверять;
- ожидаемый результат;
- текущий статус (`pending` / `in_progress` / `done`).
3. После подтверждения работоспособности — удалить файл фичи из этой папки.
## Важно
- `README.md` не удаляется.
- Количество недопроверенных фич = число файлов `*.md` в этой папке, кроме `README.md`.

View File

@ -1,2 +1,2 @@
client.version=1.2.45 client.version=1.2.46
server.version=1.2.39 server.version=1.2.40

View File

@ -48,6 +48,7 @@ import * as walletView from './pages/wallet-view.js';
import * as settingsView from './pages/settings-view.js'; import * as settingsView from './pages/settings-view.js';
import * as developerSettingsView from './pages/developer-settings-view.js'; import * as developerSettingsView from './pages/developer-settings-view.js';
import * as serverSettingsView from './pages/server-settings-view.js'; import * as serverSettingsView from './pages/server-settings-view.js';
import * as toolsSettingsView from './pages/tools-settings-view.js';
import * as deviceView from './pages/device-view.js'; import * as deviceView from './pages/device-view.js';
import * as connectDeviceView from './pages/connect-device-view.js'; import * as connectDeviceView from './pages/connect-device-view.js';
import * as deviceQrView from './pages/device-qr-view.js'; import * as deviceQrView from './pages/device-qr-view.js';
@ -85,6 +86,7 @@ const routes = {
'settings-view': settingsView, 'settings-view': settingsView,
'developer-settings-view': developerSettingsView, 'developer-settings-view': developerSettingsView,
'server-settings-view': serverSettingsView, 'server-settings-view': serverSettingsView,
'tools-settings-view': toolsSettingsView,
'device-view': deviceView, 'device-view': deviceView,
'connect-device-view': connectDeviceView, 'connect-device-view': connectDeviceView,
'device-qr-view': deviceQrView, 'device-qr-view': deviceQrView,

View File

@ -0,0 +1,100 @@
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 }) {
if (!isSpeechToTextConfigured(state.entrySettings)) {
showSttMissingConfigDialog(navigate);
return;
}
const root = document.getElementById('modal-root');
root.innerHTML = `
<div class="modal" id="speech-input-modal">
<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="form-actions-grid">
<button class="secondary-btn" type="button" id="speech-cancel">Отмена</button>
<button class="primary-btn" type="button" id="speech-ok">OK</button>
</div>
</div>
</div>
`;
const statusEl = root.querySelector('#speech-input-status');
const timeEl = root.querySelector('#speech-input-time');
const levelEl = root.querySelector('#speech-level-fill');
const errorEl = root.querySelector('#speech-input-error');
const cancelBtn = root.querySelector('#speech-cancel');
const okBtn = root.querySelector('#speech-ok');
const recorder = createMicrophoneRecorder();
let closed = false;
let busy = false;
const close = () => {
if (closed) return;
closed = true;
root.innerHTML = '';
};
const setBusy = (flag) => {
busy = !!flag;
cancelBtn.disabled = busy;
okBtn.disabled = busy;
okBtn.textContent = busy ? 'Распознаю...' : 'OK';
};
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);
errorEl.textContent = '';
statusEl.textContent = 'Распознаю речь...';
try {
const audioBlob = await recorder.stop();
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'}`;
}
});
}

View File

@ -10,6 +10,7 @@ import {
showToast, showToast,
softHaptic, softHaptic,
} from '../services/channels-ux.js'; } from '../services/channels-ux.js';
import { openSpeechInputModal } from '../components/speech-input-modal.js';
export const pageMeta = { id: 'channel-thread-view', title: 'Тред' }; export const pageMeta = { id: 'channel-thread-view', title: 'Тред' };
@ -229,13 +230,16 @@ function resolveNodeText(node) {
); );
} }
function openReplyModal({ onSubmit }) { function openReplyModal({ onSubmit, navigate }) {
const root = document.getElementById('modal-root'); const root = document.getElementById('modal-root');
root.innerHTML = ` root.innerHTML = `
<div class="modal" id="thread-reply-modal"> <div class="modal" id="thread-reply-modal">
<div class="modal-card stack"> <div class="modal-card stack">
<h3 class="modal-title">Ответ</h3> <h3 class="modal-title">Ответ</h3>
<textarea id="thread-reply-text" class="input" rows="5" maxlength="2000" placeholder="Текст ответа"></textarea> <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="meta-muted inline-error" id="thread-reply-error"></div>
<div class="form-actions-grid"> <div class="form-actions-grid">
<button class="secondary-btn" id="thread-reply-cancel" type="button">Отмена</button> <button class="secondary-btn" id="thread-reply-cancel" type="button">Отмена</button>
@ -262,6 +266,15 @@ function openReplyModal({ onSubmit }) {
}; };
root.querySelector('#thread-reply-cancel')?.addEventListener('click', close); 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 () => { root.querySelector('#thread-reply-submit')?.addEventListener('click', async () => {
if (inFlight) return; if (inFlight) return;
@ -391,6 +404,7 @@ function renderNodeCard(node, heading, handlers, localNumber, routeKey, options
animatePress(event.currentTarget); animatePress(event.currentTarget);
revealCounters(); revealCounters();
openReplyModal({ openReplyModal({
navigate: handlers.navigate,
onSubmit: async (textValue) => handlers.onReply(target, textValue), onSubmit: async (textValue) => handlers.onReply(target, textValue),
}); });
}); });
@ -520,6 +534,7 @@ export function render({ navigate, route }) {
}; };
const handlers = { const handlers = {
navigate,
onToggleLike: async (target, action) => { onToggleLike: async (target, action) => {
const actionKey = makeReactionActionKey(target); const actionKey = makeReactionActionKey(target);
if (!actionKey) throw new Error('Некорректная ссылка на сообщение для реакции.'); if (!actionKey) throw new Error('Некорректная ссылка на сообщение для реакции.');

View File

@ -15,6 +15,7 @@ import {
showToast, showToast,
softHaptic, softHaptic,
} from '../services/channels-ux.js'; } from '../services/channels-ux.js';
import { openSpeechInputModal } from '../components/speech-input-modal.js';
export const pageMeta = { id: 'channel-view', title: 'Канал' }; export const pageMeta = { id: 'channel-view', title: 'Канал' };
@ -260,13 +261,16 @@ function openAboutChannelModal(channel) {
}); });
} }
function openReplyModal({ onSubmit }) { function openReplyModal({ onSubmit, navigate }) {
const root = document.getElementById('modal-root'); const root = document.getElementById('modal-root');
root.innerHTML = ` root.innerHTML = `
<div class="modal" id="reply-modal"> <div class="modal" id="reply-modal">
<div class="modal-card stack"> <div class="modal-card stack">
<h3 class="modal-title">Ответ</h3> <h3 class="modal-title">Ответ</h3>
<textarea id="reply-text" class="input" rows="5" maxlength="2000" placeholder="Текст ответа"></textarea> <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="meta-muted inline-error" id="reply-error"></div>
<div class="form-actions-grid"> <div class="form-actions-grid">
<button class="secondary-btn" id="reply-cancel" type="button">Отмена</button> <button class="secondary-btn" id="reply-cancel" type="button">Отмена</button>
@ -293,6 +297,15 @@ function openReplyModal({ onSubmit }) {
}; };
root.querySelector('#reply-cancel')?.addEventListener('click', close); 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 () => { submitEl?.addEventListener('click', async () => {
if (inFlight) return; if (inFlight) return;
@ -317,7 +330,7 @@ function openReplyModal({ onSubmit }) {
if (textEl) textEl.focus(); if (textEl) textEl.focus();
} }
function openAddMessageModal({ channelName, onSubmit }) { function openAddMessageModal({ channelName, onSubmit, navigate }) {
const root = document.getElementById('modal-root'); const root = document.getElementById('modal-root');
root.innerHTML = ` root.innerHTML = `
<div class="modal" id="channel-message-modal"> <div class="modal" id="channel-message-modal">
@ -325,6 +338,9 @@ function openAddMessageModal({ channelName, onSubmit }) {
<h3 class="modal-title">Новое сообщение в канале</h3> <h3 class="modal-title">Новое сообщение в канале</h3>
<p class="meta-muted">${channelName}</p> <p class="meta-muted">${channelName}</p>
<textarea id="channel-message-text" class="input" rows="6" maxlength="2000" placeholder="Текст сообщения"></textarea> <textarea id="channel-message-text" class="input" rows="6" maxlength="2000" placeholder="Текст сообщения"></textarea>
<div class="row wrap-row">
<button class="ghost-btn" id="channel-message-voice" type="button">🎤 Голосом</button>
</div>
<div class="meta-muted inline-error" id="channel-message-error"></div> <div class="meta-muted inline-error" id="channel-message-error"></div>
<div class="form-actions-grid"> <div class="form-actions-grid">
<button class="secondary-btn" id="channel-message-cancel" type="button">Отмена</button> <button class="secondary-btn" id="channel-message-cancel" type="button">Отмена</button>
@ -351,6 +367,15 @@ function openAddMessageModal({ channelName, onSubmit }) {
}; };
root.querySelector('#channel-message-cancel')?.addEventListener('click', close); root.querySelector('#channel-message-cancel')?.addEventListener('click', close);
root.querySelector('#channel-message-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 () => { submitEl?.addEventListener('click', async () => {
if (inFlight) return; if (inFlight) return;
@ -624,6 +649,7 @@ function renderPostCard(post, {
animatePress(event.currentTarget); animatePress(event.currentTarget);
revealCounters(); revealCounters();
openReplyModal({ openReplyModal({
navigate,
onSubmit: async (text) => onReply(post.messageRef, text), onSubmit: async (text) => onReply(post.messageRef, text),
}); });
}); });
@ -732,6 +758,7 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
animatePress(event.currentTarget); animatePress(event.currentTarget);
openAddMessageModal({ openAddMessageModal({
channelName: channelData.channel.name, channelName: channelData.channel.name,
navigate,
onSubmit: async (bodyText) => handlers.onAddPost(bodyText), onSubmit: async (bodyText) => handlers.onAddPost(bodyText),
}); });
}); });

View File

@ -2,6 +2,7 @@ import { renderHeader } from '../components/header.js';
import { directMessages } from '../mock-data.js'; import { directMessages } from '../mock-data.js';
import { import {
addAppLogEntry, addAppLogEntry,
addChatMessage,
addSystemChatMessage, addSystemChatMessage,
addOutgoingPendingMessage, addOutgoingPendingMessage,
getChatMessages, getChatMessages,
@ -12,6 +13,8 @@ import {
state, state,
} from '../state.js'; } from '../state.js';
import { startOutgoingCall } from '../services/call-service.js'; import { startOutgoingCall } from '../services/call-service.js';
import { openSpeechInputModal } from '../components/speech-input-modal.js';
import { isTextToSpeechConfigured, speakTextBySettings } from '../services/speech-tools-service.js';
export const pageMeta = { id: 'chat-view', title: 'Чат' }; export const pageMeta = { id: 'chat-view', title: 'Чат' };
@ -200,9 +203,41 @@ export function render({ navigate, route }) {
form.className = 'chat-input dm-chat-input'; form.className = 'chat-input dm-chat-input';
form.innerHTML = ` form.innerHTML = `
<input class="input dm-input" type="text" name="message" placeholder="Введите сообщение" maxlength="300" /> <input class="input dm-input" type="text" name="message" placeholder="Введите сообщение" maxlength="300" />
<button class="ghost-btn dm-voice-btn" type="button" id="chat-voice-input">🎤</button>
<button class="ghost-btn dm-voice-btn" type="button" id="chat-read-aloud">🔊</button>
<button class="primary-btn dm-send-btn" type="submit">Отправить</button> <button class="primary-btn dm-send-btn" type="submit">Отправить</button>
`; `;
form.querySelector('#chat-voice-input')?.addEventListener('click', async () => {
const input = form.elements.message;
await openSpeechInputModal({
navigate,
onTextReady: (text) => {
const prev = String(input.value || '').trim();
input.value = prev ? `${prev} ${text}` : text;
},
});
});
form.querySelector('#chat-read-aloud')?.addEventListener('click', async () => {
const input = form.elements.message;
const text = String(input.value || '').trim();
if (!text) {
window.alert('Введите текст для озвучки.');
return;
}
if (!isTextToSpeechConfigured(state.entrySettings)) {
const goSettings = window.confirm('Озвучка не настроена. Перейти в настройки инструментов?');
if (goSettings) navigate('tools-settings-view');
return;
}
try {
await speakTextBySettings(text, state.entrySettings);
} catch (error) {
window.alert(`Ошибка озвучки: ${error?.message || 'unknown'}`);
}
});
form.addEventListener('submit', async (event) => { form.addEventListener('submit', async (event) => {
event.preventDefault(); event.preventDefault();
const input = form.elements.message; const input = form.elements.message;

View File

@ -42,12 +42,14 @@ export function render({ navigate }) {
card.innerHTML = ` card.innerHTML = `
<button class="text-btn" type="button" id="settings-device">Устройства</button> <button class="text-btn" type="button" id="settings-device">Устройства</button>
<button class="text-btn" type="button" id="settings-servers">Настройки серверов</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-language">Язык / Language</button>
<button class="text-btn" type="button" id="settings-signout">Завершить текущий сеанс</button> <button class="text-btn" type="button" id="settings-signout">Завершить текущий сеанс</button>
`; `;
card.querySelector('#settings-device').addEventListener('click', () => navigate('device-view')); card.querySelector('#settings-device').addEventListener('click', () => navigate('device-view'));
card.querySelector('#settings-servers').addEventListener('click', () => navigate('server-settings-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')); card.querySelector('#settings-language').addEventListener('click', () => navigate('language-view'));
const signOutBtn = card.querySelector('#settings-signout'); const signOutBtn = card.querySelector('#settings-signout');

View File

@ -0,0 +1,168 @@
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;
}

View File

@ -147,6 +147,7 @@ export function resolveToolbarActive(pageId) {
pageId === 'settings-view' || pageId === 'settings-view' ||
pageId === 'developer-settings-view' || pageId === 'developer-settings-view' ||
pageId === 'server-settings-view' || pageId === 'server-settings-view' ||
pageId === 'tools-settings-view' ||
pageId === 'device-view' || pageId === 'device-view' ||
pageId === 'connect-device-view' || pageId === 'connect-device-view' ||
pageId === 'device-qr-view' || pageId === 'device-qr-view' ||

View File

@ -0,0 +1,242 @@
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',
hard: '0.9',
};
function normalizeOpenAiBaseUrl(url) {
const value = String(url || '').trim();
if (!value) return 'https://api.openai.com/v1';
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');
if (provider === 'browser') return true;
if (provider === 'piper-http') return !!String(cfg.piperBaseUrl || '').trim();
if (provider === 'openai') return !!String(cfg.apiKey || '').trim();
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;
const cfg = entrySettings?.tools?.textToSpeech || {};
const provider = String(cfg.provider || 'browser');
if (provider === 'browser') {
const utt = new SpeechSynthesisUtterance(value);
utt.lang = 'ru-RU';
const selected = String(cfg.voice || '').trim();
if (selected) {
const voice = window.speechSynthesis.getVoices().find((v) => v.name === selected);
if (voice) utt.voice = voice;
}
window.speechSynthesis.speak(utt);
return;
}
if (provider === 'piper-http') {
const baseUrl = String(cfg.piperBaseUrl || '').trim().replace(/\/+$/, '');
if (!baseUrl) throw new Error('Не указан адрес Piper HTTP.');
const quality = String(cfg.quality || 'medium').toLowerCase();
const voice = String(cfg.voice || '').trim();
const lengthScale = PIPER_LENGTH_SCALE_BY_QUALITY[quality] || PIPER_LENGTH_SCALE_BY_QUALITY.medium;
const resp = await fetch(`${baseUrl}/api/tts`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: value,
voice,
quality,
length_scale: lengthScale,
}),
});
if (!resp.ok) throw new Error(`Piper HTTP недоступен (${resp.status}).`);
const blob = await resp.blob();
const audioUrl = URL.createObjectURL(blob);
const audio = new Audio(audioUrl);
await audio.play();
window.setTimeout(() => URL.revokeObjectURL(audioUrl), 30000);
return;
}
if (provider === 'openai') {
const apiKey = String(cfg.apiKey || '').trim();
if (!apiKey) throw new Error('Не заполнен API key для OpenAI TTS.');
const model = String(cfg.model || '').trim() || 'gpt-4o-mini-tts';
const baseUrl = normalizeOpenAiBaseUrl(cfg.externalBaseUrl || cfg.baseUrl || 'https://api.openai.com/v1');
const voice = String(cfg.voice || '').trim() || 'alloy';
const resp = await fetch(`${baseUrl}/audio/speech`, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model,
voice,
input: value,
format: 'mp3',
}),
});
if (!resp.ok) throw new Error(`OpenAI TTS недоступен (${resp.status}).`);
const blob = await resp.blob();
const audioUrl = URL.createObjectURL(blob);
const audio = new Audio(audioUrl);
await audio.play();
window.setTimeout(() => URL.revokeObjectURL(audioUrl), 30000);
return;
}
throw new Error('Неизвестный провайдер озвучки.');
}

View File

@ -81,6 +81,31 @@ const DEFAULT_SOLANA_SERVER = 'https://api.devnet.solana.com';
const DEFAULT_SHINE_SERVER = 'wss://shineup.me/ws'; const DEFAULT_SHINE_SERVER = 'wss://shineup.me/ws';
const DEFAULT_ARWEAVE_SERVER = 'https://arweave.net'; const DEFAULT_ARWEAVE_SERVER = 'https://arweave.net';
const DEFAULT_CALL_PREFLIGHT_TIMEOUT_MS = 6000; const DEFAULT_CALL_PREFLIGHT_TIMEOUT_MS = 6000;
const DEFAULT_OPENAI_BASE_URL = 'https://api.openai.com/v1';
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'),
voice: String(tts.voice || ''),
piperBaseUrl: String(tts.piperBaseUrl || 'http://127.0.0.1:5000'),
externalBaseUrl: String(tts.externalBaseUrl || ''),
apiKey: String(tts.apiKey || ''),
model: String(tts.model || ''),
},
};
}
function loadStoredSession() { function loadStoredSession() {
try { try {
@ -153,6 +178,7 @@ function persistEntrySettings(settings) {
shineServer: String(settings?.statuses?.shineServer || 'idle'), shineServer: String(settings?.statuses?.shineServer || 'idle'),
arweaveServer: String(settings?.statuses?.arweaveServer || 'idle'), arweaveServer: String(settings?.statuses?.arweaveServer || 'idle'),
}, },
tools: normalizeToolsSettings(settings?.tools),
}; };
localStorage.setItem(ENTRY_SETTINGS_STORAGE_KEY, JSON.stringify(payload)); localStorage.setItem(ENTRY_SETTINGS_STORAGE_KEY, JSON.stringify(payload));
} catch { } catch {
@ -216,6 +242,7 @@ function createInitialState({ withStoredSession = true } = {}) {
shineServer: String(storedEntrySettings?.statuses?.shineServer || 'idle'), shineServer: String(storedEntrySettings?.statuses?.shineServer || 'idle'),
arweaveServer: String(storedEntrySettings?.statuses?.arweaveServer || 'idle'), arweaveServer: String(storedEntrySettings?.statuses?.arweaveServer || 'idle'),
}, },
tools: normalizeToolsSettings(storedEntrySettings?.tools),
}, },
registrationDraft: { registrationDraft: {
flowType: '', flowType: '',
@ -630,6 +657,7 @@ export async function saveEntrySettings(nextSettings) {
...state.entrySettings.statuses, ...state.entrySettings.statuses,
...(nextSettings.statuses || {}), ...(nextSettings.statuses || {}),
}, },
tools: normalizeToolsSettings(nextSettings.tools || state.entrySettings.tools),
}; };
persistEntrySettings(state.entrySettings); persistEntrySettings(state.entrySettings);
await authService.reconnect(state.entrySettings.shineServer); await authService.reconnect(state.entrySettings.shineServer);

View File

@ -3381,6 +3381,28 @@ textarea.input {
.dm-chat-input { .dm-chat-input {
gap: 10px; gap: 10px;
grid-template-columns: 1fr auto auto auto;
}
.dm-voice-btn {
min-width: 42px;
padding: 0 10px;
}
.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-screen .input,