Compare commits
2 Commits
ddeaf82bfd
...
b55fd1571e
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
b55fd1571e | ||
|
|
8de4e95c6a |
16
AGENTS.md
16
AGENTS.md
@ -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`).
|
||||||
|
- В том же первом ответе обязательно уточнять у пользователя, проверил ли он эти фичи и можно ли пометить их как завершённые (удалить соответствующие файлы).
|
||||||
|
|||||||
@ -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 (если ключ имеет нужные права).
|
||||||
|
- При пустых настройках показывается понятный переход в настройки.
|
||||||
@ -0,0 +1,36 @@
|
|||||||
|
# Argon2id для входа/регистрации + блок «Расширенные»
|
||||||
|
|
||||||
|
Статус: `pending`
|
||||||
|
|
||||||
|
## Что сделано
|
||||||
|
|
||||||
|
- При непустом пароле derivation ключей переведён на Argon2id.
|
||||||
|
- В derivation участвуют и логин, и пароль.
|
||||||
|
- Для пустого пароля оставлен прежний тестовый режим (старый детерминированный вариант), чтобы сохранить текущий тестовый сценарий.
|
||||||
|
- На экранах входа и регистрации добавлен блок `Расширенные` с кратким описанием схемы и параметров.
|
||||||
|
|
||||||
|
## Параметры Argon2id (текущий профиль)
|
||||||
|
|
||||||
|
- `t = 3`
|
||||||
|
- `m = 262144 KiB` (256 MB)
|
||||||
|
- `p = 1`
|
||||||
|
- `dkLen = 32`
|
||||||
|
|
||||||
|
Формат salt:
|
||||||
|
|
||||||
|
- `saltSource = "shine-auth-v2|login=<lowercaseLogin>|suffix=<keySuffix>"`
|
||||||
|
- `salt = first16bytes( SHA-256(saltSource) )`
|
||||||
|
- `keySuffix` = `root.key` / `bch.key` / `dev.key`
|
||||||
|
|
||||||
|
## Как проверять
|
||||||
|
|
||||||
|
1. На входе/регистрации открыть `Расширенные` и проверить отображение описания.
|
||||||
|
2. Проверить тестовый режим: оставить пароль пустым и убедиться, что вход работает по старому сценарию.
|
||||||
|
3. Проверить новый режим: ввести непустой пароль и выполнить вход/регистрацию.
|
||||||
|
4. Проверить, что одинаковый пароль при разных логинах даёт разные ключи (например, вход под двумя логинами с тем же паролем и проверка несовпадения производных ключей/сессий).
|
||||||
|
|
||||||
|
## Ожидаемый результат
|
||||||
|
|
||||||
|
- Непустой пароль использует Argon2id.
|
||||||
|
- Пустой пароль остаётся тестовым legacy-вариантом.
|
||||||
|
- UI показывает пользователю, как сейчас считается секрет.
|
||||||
19
Dev_Docs/Pending_Features/README.md
Normal file
19
Dev_Docs/Pending_Features/README.md
Normal 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`.
|
||||||
@ -1,2 +1,2 @@
|
|||||||
client.version=1.2.45
|
client.version=1.2.47
|
||||||
server.version=1.2.39
|
server.version=1.2.41
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
100
shine-UI/js/components/speech-input-modal.js
Normal file
100
shine-UI/js/components/speech-input-modal.js
Normal 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'}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -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('Некорректная ссылка на сообщение для реакции.');
|
||||||
|
|||||||
@ -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),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -35,6 +35,16 @@ export function render({ navigate }) {
|
|||||||
hint.className = 'meta-muted';
|
hint.className = 'meta-muted';
|
||||||
hint.textContent = 'Введите логин. Пароль может быть пустым. На следующем шаге сохраните ключи на устройстве.';
|
hint.textContent = 'Введите логин. Пароль может быть пустым. На следующем шаге сохраните ключи на устройстве.';
|
||||||
|
|
||||||
|
const advanced = document.createElement('details');
|
||||||
|
advanced.className = 'card stack';
|
||||||
|
advanced.innerHTML = `
|
||||||
|
<summary>Расширенные</summary>
|
||||||
|
<p class="meta-muted">Схема derivation ключей: при непустом пароле используется Argon2id (средний профиль), затем из результата строится секрет для root/bch/dev ключей.</p>
|
||||||
|
<p class="meta-muted">Если пароль пустой — используется прежний тестовый режим совместимости (старый детерминированный вариант).</p>
|
||||||
|
<p class="meta-muted">Для тесто оставьте пустой пароль.</p>
|
||||||
|
<p class="meta-muted">Профиль Argon2id сейчас фиксированный: t=3, m=262144 KiB (256 MB), p=1, dkLen=32. Выбор уровня будет добавлен позже.</p>
|
||||||
|
`;
|
||||||
|
|
||||||
const status = document.createElement('p');
|
const status = document.createElement('p');
|
||||||
status.className = 'status-line is-unavailable';
|
status.className = 'status-line is-unavailable';
|
||||||
status.style.display = 'none';
|
status.style.display = 'none';
|
||||||
@ -49,7 +59,7 @@ export function render({ navigate }) {
|
|||||||
`;
|
`;
|
||||||
form.children[0].append(loginInput);
|
form.children[0].append(loginInput);
|
||||||
form.children[1].append(passwordInput);
|
form.children[1].append(passwordInput);
|
||||||
form.append(hint, status, testLoginsHint);
|
form.append(hint, advanced, status, testLoginsHint);
|
||||||
|
|
||||||
const actions = document.createElement('div');
|
const actions = document.createElement('div');
|
||||||
actions.className = 'auth-footer-actions';
|
actions.className = 'auth-footer-actions';
|
||||||
|
|||||||
@ -33,6 +33,17 @@ export function render({ navigate }) {
|
|||||||
formError.className = 'status-line is-unavailable';
|
formError.className = 'status-line is-unavailable';
|
||||||
formError.style.display = 'none';
|
formError.style.display = 'none';
|
||||||
|
|
||||||
|
const advanced = document.createElement('details');
|
||||||
|
advanced.className = 'card stack';
|
||||||
|
advanced.innerHTML = `
|
||||||
|
<summary>Расширенные</summary>
|
||||||
|
<p class="meta-muted">Схема derivation ключей: при непустом пароле используется Argon2id (средний профиль), затем из результата строится секрет для root/bch/dev ключей.</p>
|
||||||
|
<p class="meta-muted">В derivation участвуют и логин, и пароль: одинаковый пароль у разных логинов даёт разные ключи.</p>
|
||||||
|
<p class="meta-muted">Если пароль пустой — используется прежний тестовый режим совместимости (старый детерминированный вариант).</p>
|
||||||
|
<p class="meta-muted">Для тесто оставьте пустой пароль.</p>
|
||||||
|
<p class="meta-muted">Профиль Argon2id сейчас фиксированный: t=3, m=262144 KiB (256 MB), p=1, dkLen=32. Выбор уровня будет добавлен позже.</p>
|
||||||
|
`;
|
||||||
|
|
||||||
const checkButton = document.createElement('button');
|
const checkButton = document.createElement('button');
|
||||||
checkButton.className = 'ghost-btn';
|
checkButton.className = 'ghost-btn';
|
||||||
checkButton.type = 'button';
|
checkButton.type = 'button';
|
||||||
@ -73,7 +84,7 @@ export function render({ navigate }) {
|
|||||||
`;
|
`;
|
||||||
form.children[0].append(loginInput);
|
form.children[0].append(loginInput);
|
||||||
form.children[1].append(passwordInput);
|
form.children[1].append(passwordInput);
|
||||||
form.append(checkButton, statusText, formError);
|
form.append(checkButton, statusText, advanced, formError);
|
||||||
|
|
||||||
const actions = document.createElement('div');
|
const actions = document.createElement('div');
|
||||||
actions.className = 'auth-footer-actions';
|
actions.className = 'auth-footer-actions';
|
||||||
|
|||||||
@ -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');
|
||||||
|
|||||||
168
shine-UI/js/pages/tools-settings-view.js
Normal file
168
shine-UI/js/pages/tools-settings-view.js
Normal 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;
|
||||||
|
}
|
||||||
@ -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' ||
|
||||||
|
|||||||
@ -556,11 +556,12 @@ export class AuthService {
|
|||||||
return payload.exists !== true;
|
return payload.exists !== true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async derivePasswordKeyBundle(password) {
|
async derivePasswordKeyBundle(login, password) {
|
||||||
|
const normalizedLogin = String(login ?? '');
|
||||||
const normalizedPassword = String(password ?? '');
|
const normalizedPassword = String(password ?? '');
|
||||||
const rootPair = await deriveEd25519FromPassword(normalizedPassword, 'root.key');
|
const rootPair = await deriveEd25519FromPassword(normalizedPassword, 'root.key', { login: normalizedLogin });
|
||||||
const blockchainPair = await deriveEd25519FromPassword(normalizedPassword, 'bch.key');
|
const blockchainPair = await deriveEd25519FromPassword(normalizedPassword, 'bch.key', { login: normalizedLogin });
|
||||||
const devicePair = await deriveEd25519FromPassword(normalizedPassword, 'dev.key');
|
const devicePair = await deriveEd25519FromPassword(normalizedPassword, 'dev.key', { login: normalizedLogin });
|
||||||
return { rootPair, blockchainPair, devicePair };
|
return { rootPair, blockchainPair, devicePair };
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -617,7 +618,7 @@ export class AuthService {
|
|||||||
const isFree = await this.ensureLoginFree(cleanLogin);
|
const isFree = await this.ensureLoginFree(cleanLogin);
|
||||||
if (!isFree) throw new Error('Этот логин уже занят');
|
if (!isFree) throw new Error('Этот логин уже занят');
|
||||||
|
|
||||||
const keyBundle = await this.derivePasswordKeyBundle(password);
|
const keyBundle = await this.derivePasswordKeyBundle(cleanLogin, password);
|
||||||
|
|
||||||
const addResp = await this.ws.request('AddUser', {
|
const addResp = await this.ws.request('AddUser', {
|
||||||
login: cleanLogin,
|
login: cleanLogin,
|
||||||
@ -640,7 +641,7 @@ export class AuthService {
|
|||||||
const user = await this.getUser(cleanLogin);
|
const user = await this.getUser(cleanLogin);
|
||||||
if (!user.exists) throw new Error('Пользователь не найден');
|
if (!user.exists) throw new Error('Пользователь не найден');
|
||||||
|
|
||||||
const keyBundle = await this.derivePasswordKeyBundle(password);
|
const keyBundle = await this.derivePasswordKeyBundle(cleanLogin, password);
|
||||||
const session = await this.createAuthSession(cleanLogin, keyBundle);
|
const session = await this.createAuthSession(cleanLogin, keyBundle);
|
||||||
return { ...session, keyBundle };
|
return { ...session, keyBundle };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
const WEB_CRYPTO_REQUIRED_MESSAGE = 'Регистрация и подпись блоков требуют WebCrypto (crypto.subtle). Откройте приложение через HTTPS или localhost в современном браузере и повторите попытку.';
|
const WEB_CRYPTO_REQUIRED_MESSAGE = 'Регистрация и подпись блоков требуют WebCrypto (crypto.subtle). Откройте приложение через HTTPS или localhost в современном браузере и повторите попытку.';
|
||||||
|
import { argon2idAsync } from 'https://esm.sh/@noble/hashes@1.8.0/argon2.js';
|
||||||
|
|
||||||
function getCryptoApi() {
|
function getCryptoApi() {
|
||||||
const api = globalThis.crypto;
|
const api = globalThis.crypto;
|
||||||
@ -66,6 +67,33 @@ export async function derivePasswordSeed(password, suffix) {
|
|||||||
return sha256Text(concat);
|
return sha256Text(concat);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeLoginForKdf(login) {
|
||||||
|
return String(login || '').trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function makeArgon2Salt(login, suffix) {
|
||||||
|
const normalizedLogin = normalizeLoginForKdf(login);
|
||||||
|
const normalizedSuffix = String(suffix || '').trim();
|
||||||
|
const saltSource = `shine-auth-v2|login=${normalizedLogin}|suffix=${normalizedSuffix}`;
|
||||||
|
const digest = await sha256Text(saltSource);
|
||||||
|
return digest.slice(0, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function derivePasswordSeedArgon2id({ login, password, suffix }) {
|
||||||
|
const normalizedLogin = normalizeLoginForKdf(login);
|
||||||
|
const normalizedPassword = String(password ?? '');
|
||||||
|
const normalizedSuffix = String(suffix || '').trim();
|
||||||
|
const salt = await makeArgon2Salt(normalizedLogin, normalizedSuffix);
|
||||||
|
const passBytes = utf8Bytes(`${normalizedLogin}\n${normalizedPassword}`);
|
||||||
|
const out = await argon2idAsync(passBytes, salt, {
|
||||||
|
t: 3,
|
||||||
|
m: 262144,
|
||||||
|
p: 1,
|
||||||
|
dkLen: 32,
|
||||||
|
});
|
||||||
|
return new Uint8Array(out);
|
||||||
|
}
|
||||||
|
|
||||||
function ed25519Pkcs8FromSeed(seed32) {
|
function ed25519Pkcs8FromSeed(seed32) {
|
||||||
if (seed32.length !== 32) {
|
if (seed32.length !== 32) {
|
||||||
throw new Error('Для Ed25519 нужен seed длиной 32 байта');
|
throw new Error('Для Ed25519 нужен seed длиной 32 байта');
|
||||||
@ -79,8 +107,17 @@ function ed25519Pkcs8FromSeed(seed32) {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deriveEd25519FromPassword(password, suffix) {
|
export async function deriveEd25519FromPassword(password, suffix, options = {}) {
|
||||||
const seed = await derivePasswordSeed(password, suffix);
|
const normalizedPassword = String(password ?? '');
|
||||||
|
const normalizedLogin = String(options?.login ?? '');
|
||||||
|
const useLegacyEmptyPassword = normalizedPassword.length === 0;
|
||||||
|
const seed = useLegacyEmptyPassword
|
||||||
|
? await derivePasswordSeed(normalizedPassword, suffix)
|
||||||
|
: await derivePasswordSeedArgon2id({
|
||||||
|
login: normalizedLogin,
|
||||||
|
password: normalizedPassword,
|
||||||
|
suffix,
|
||||||
|
});
|
||||||
const pkcs8 = ed25519Pkcs8FromSeed(seed);
|
const pkcs8 = ed25519Pkcs8FromSeed(seed);
|
||||||
const subtle = getSubtleApi();
|
const subtle = getSubtleApi();
|
||||||
const privateKey = await subtle.importKey('pkcs8', pkcs8, { name: 'Ed25519' }, true, ['sign']);
|
const privateKey = await subtle.importKey('pkcs8', pkcs8, { name: 'Ed25519' }, true, ['sign']);
|
||||||
|
|||||||
242
shine-UI/js/services/speech-tools-service.js
Normal file
242
shine-UI/js/services/speech-tools-service.js
Normal 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('Неизвестный провайдер озвучки.');
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user