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
+16 -1
View File
@@ -10,6 +10,7 @@ import {
showToast,
softHaptic,
} from '../services/channels-ux.js';
import { openSpeechInputModal } from '../components/speech-input-modal.js';
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');
root.innerHTML = `
<div class="modal" id="thread-reply-modal">
<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>
@@ -262,6 +266,15 @@ function openReplyModal({ onSubmit }) {
};
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;
@@ -391,6 +404,7 @@ function renderNodeCard(node, heading, handlers, localNumber, routeKey, options
animatePress(event.currentTarget);
revealCounters();
openReplyModal({
navigate: handlers.navigate,
onSubmit: async (textValue) => handlers.onReply(target, textValue),
});
});
@@ -520,6 +534,7 @@ export function render({ navigate, route }) {
};
const handlers = {
navigate,
onToggleLike: async (target, action) => {
const actionKey = makeReactionActionKey(target);
if (!actionKey) throw new Error('Некорректная ссылка на сообщение для реакции.');
+29 -2
View File
@@ -15,6 +15,7 @@ import {
showToast,
softHaptic,
} from '../services/channels-ux.js';
import { openSpeechInputModal } from '../components/speech-input-modal.js';
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');
root.innerHTML = `
<div class="modal" id="reply-modal">
<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>
@@ -293,6 +297,15 @@ function openReplyModal({ onSubmit }) {
};
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;
@@ -317,7 +330,7 @@ function openReplyModal({ onSubmit }) {
if (textEl) textEl.focus();
}
function openAddMessageModal({ channelName, onSubmit }) {
function openAddMessageModal({ channelName, onSubmit, navigate }) {
const root = document.getElementById('modal-root');
root.innerHTML = `
<div class="modal" id="channel-message-modal">
@@ -325,6 +338,9 @@ function openAddMessageModal({ channelName, onSubmit }) {
<h3 class="modal-title">Новое сообщение в канале</h3>
<p class="meta-muted">${channelName}</p>
<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="form-actions-grid">
<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-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;
@@ -624,6 +649,7 @@ function renderPostCard(post, {
animatePress(event.currentTarget);
revealCounters();
openReplyModal({
navigate,
onSubmit: async (text) => onReply(post.messageRef, text),
});
});
@@ -732,6 +758,7 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
animatePress(event.currentTarget);
openAddMessageModal({
channelName: channelData.channel.name,
navigate,
onSubmit: async (bodyText) => handlers.onAddPost(bodyText),
});
});
+35
View File
@@ -2,6 +2,7 @@ import { renderHeader } from '../components/header.js';
import { directMessages } from '../mock-data.js';
import {
addAppLogEntry,
addChatMessage,
addSystemChatMessage,
addOutgoingPendingMessage,
getChatMessages,
@@ -12,6 +13,8 @@ import {
state,
} from '../state.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: 'Чат' };
@@ -200,9 +203,41 @@ export function render({ navigate, route }) {
form.className = 'chat-input dm-chat-input';
form.innerHTML = `
<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>
`;
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) => {
event.preventDefault();
const input = form.elements.message;
+2
View File
@@ -42,12 +42,14 @@ export function render({ navigate }) {
card.innerHTML = `
<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-tools">Настройки инструментов ввода</button>
<button class="text-btn" type="button" id="settings-language">Язык / Language</button>
<button class="text-btn" type="button" id="settings-signout">Завершить текущий сеанс</button>
`;
card.querySelector('#settings-device').addEventListener('click', () => navigate('device-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
@@ -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;
}