Compare commits

...

2 Commits

20 changed files with 821 additions and 15 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,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 показывает пользователю, как сейчас считается секрет.

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.47
server.version=1.2.39 server.version=1.2.41

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

@ -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';

View File

@ -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';

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

@ -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 };
} }

View File

@ -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']);

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,