J.A.R.V.I.S-rust/frontend/src/routes/settings/index.svelte
Bossiara13 6729e53be6
Some checks are pending
Rust CI / cargo test (jarvis-core) (push) Waiting to run
Rust CI / cargo clippy (push) Waiting to run
Rust CI / cargo check (workspace) (push) Waiting to run
fix: TTS hot-swap actually works + GUI max-width + Python builder discoverable
ROOT CAUSE ANALYSIS

User reported: (a) TTS dropdown doesn't apply, (b) GUI feels stretched,
(c) no link to command builder, (d) Python doesn't even start.

Issues (a), (b), (c) addressed here. (d) addressed in python repo.

=== Fix A: TTS hot-swap (was completely broken) ===

Two distinct bugs:
1. `tts::init_backend()` only read JARVIS_TTS env var, NEVER consulted
   `Settings.tts_backend` in the DB. So saving "Silero" in the GUI
   dropdown changed nothing — not even on restart.
2. `BACKEND: OnceCell<...>` couldn't be replaced at runtime, so even
   if init had picked the right backend, the dropdown was useless
   mid-session.

Fixed:
- `tts::BACKEND` is now `Lazy<RwLock<Option<Arc<dyn TtsBackend>>>>`.
- `choose_backend_name()` resolves from DB → env → auto-detect.
- New `swap_to(name)` replaces the live backend atomically.
- IPC: new `IpcAction::SwitchTts { backend }`. jarvis-app reacts to
  it the same way it reacts to SwitchLlm.
- GUI's `set_tts_backend` now persists + hot-swaps GUI's TTS + Svelte
  page also fires `switchDaemonTts()` over WS so the running daemon
  installs the new backend.
- Removed "Применится при следующем запуске" hint — now applies
  immediately. RU + EN locale strings updated.

=== Fix B: GUI max-width ===

main + #header had no max-width, so on a 1920px monitor the labels
sprawled across 1860px of space, which felt stretched. Added
max-width: 1280px + margin: 0 auto + box-sizing: border-box. Smaller
windows are unaffected.

Also added the same cap on .app-container.assist-page so the home
screen arc reactor centres properly.

=== Fix C: Command builder discoverable ===

The Python fork ships a separate yaml-editing tool at
`python/tools/command_builder/` (pywebview GUI). No way to find it
from the main Rust GUI. Fixed:

- New tauri command `open_command_builder` (crates/jarvis-gui/src/
  tauri_commands/builder.rs). Locates the Python fork via
  $JARVIS_PYTHON_DIR → sibling python/ → C:\Jarvis\python, prefers
  the project's .venv, spawns `python -m tools.command_builder`.
- New blue "Конструктор команд (Python)" button on /settings page
  near the Save / Back buttons.

Tests: 140 rust + 104 python all pass. Release builds of jarvis-app
and jarvis-gui both green.
2026-05-24 22:12:04 +03:00

819 lines
No EOL
26 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script lang="ts">
import { onMount } from "svelte"
import { invoke } from "@tauri-apps/api/core"
import { goto } from "@roxi/routify"
import { setTimeout } from "worker-timers"
import { showInExplorer } from "@/functions"
import {
appInfo, assistantVoice, translations, translate,
switchDaemonLlm, reloadDaemonLlm, switchDaemonTts,
} from "@/stores"
import HDivider from "@/components/elements/HDivider.svelte"
import Footer from "@/components/Footer.svelte"
import {
Notification,
Button,
Text,
Tabs,
Space,
Alert,
Input,
InputWrapper,
NativeSelect,
Switch
} from "@svelteuidev/core"
import {
Check,
Mix,
Cube,
Code,
Gear,
QuestionMarkCircled,
CrossCircled,
ChatBubble,
} from "radix-icons-svelte"
$: t = (key: string) => translate($translations, key)
interface VoiceMeta {
id: string
name: string
author: string
languages: string[]
}
interface VoiceConfig {
voice: VoiceMeta
}
let availableVoices: VoiceMeta[] = []
async function selectVoice(voiceId: string) {
voiceVal = voiceId
// play preview sound
try {
await invoke("preview_voice", { voiceId })
} catch (err) {
console.error("Failed to preview voice:", err)
}
}
// ### STATE
interface MicrophoneOption {
label: string
value: string
}
let availableMicrophones: MicrophoneOption[] = []
let availableVoskModels: { label: string; value: string }[] = []
let availableGlinerModels: { label: string; value: string }[] = []
let settingsSaved = false
let saveButtonDisabled = false
// form values (state vars)
let voiceVal = ""
let selectedMicrophone = ""
let selectedWakeWordEngine = ""
let selectedIntentRecognitionEngine = ""
let selectedSlotExtractionEngine = ""
let selectedGlinerModel = ""
let selectedVoskModel = ""
let selectedNoiseSuppression = ""
let selectedVad = ""
let gainNormalizerEnabled = false
let apiKeyPicovoice = ""
let apiKeyOpenai = ""
// ── AI backends (new tab) ─────────────────────────────────────────────
interface ActiveBackends {
tts: string
llm: string
llm_model: string | null
profile: string
}
let activeBackends: ActiveBackends | null = null
let selectedLlmBackend = "" // "" = auto, "groq", "ollama"
let selectedTtsBackend = "" // "" = auto, "sapi", "piper", "silero"
let backendSwapBusy = false
let backendSwapError = ""
async function refreshActiveBackends() {
try {
activeBackends = await invoke<ActiveBackends>("get_active_backends")
} catch (err) {
console.error("Failed to read active backends:", err)
}
}
async function applyLlmBackend(target: string) {
backendSwapError = ""
if (target === "") {
try {
await invoke("db_write", { key: "llm_backend", val: "" })
reloadDaemonLlm() // tell daemon to re-read DB (now auto)
} catch (err) {
backendSwapError = String(err)
}
await refreshActiveBackends()
return
}
backendSwapBusy = true
try {
// 1. Swap on GUI process (persists to DB).
await invoke<string>("set_llm_backend", { name: target })
// 2. Tell daemon to swap too, so the running listener uses the
// new backend immediately. No-op if daemon not connected.
switchDaemonLlm(target as "groq" | "ollama")
await refreshActiveBackends()
} catch (err) {
backendSwapError = String(err)
} finally {
backendSwapBusy = false
}
}
async function applyTtsBackend(target: string) {
try {
// 1. GUI Tauri command — persists to DB + hot-swaps GUI's TTS.
await invoke<string>("set_tts_backend", { name: target })
// 2. Tell the running daemon (separate process) to switch too.
switchDaemonTts(target || "auto")
} catch (err) {
backendSwapError = String(err)
}
}
async function resetLlmContext() {
try {
await invoke("llm_reset_context")
} catch (err) {
console.error(err)
}
}
// subscribe to stores
assistantVoice.subscribe(value => {
voiceVal = value
})
let issuesLink = ""
let logFilePath = ""
appInfo.subscribe(info => {
issuesLink = info.issuesLink
logFilePath = info.logFilePath
})
// ### FUNCTIONS
async function saveSettings() {
saveButtonDisabled = true
settingsSaved = false
try {
await Promise.all([
invoke("db_write", { key: "assistant_voice", val: voiceVal }),
invoke("db_write", { key: "selected_microphone", val: selectedMicrophone }),
invoke("db_write", { key: "selected_wake_word_engine", val: selectedWakeWordEngine }),
invoke("db_write", { key: "selected_intent_recognition_engine", val: selectedIntentRecognitionEngine }),
invoke("db_write", { key: "selected_slot_extraction_engine", val: selectedSlotExtractionEngine }),
invoke("db_write", { key: "selected_gliner_model", val: selectedGlinerModel }),
invoke("db_write", { key: "selected_vosk_model", val: selectedVoskModel }),
invoke("db_write", { key: "noise_suppression", val: selectedNoiseSuppression }),
invoke("db_write", { key: "vad", val: selectedVad }),
invoke("db_write", { key: "gain_normalizer", val: gainNormalizerEnabled.toString() }),
invoke("db_write", { key: "api_key__picovoice", val: apiKeyPicovoice }),
invoke("db_write", { key: "api_key__openai", val: apiKeyOpenai })
])
// update shared store
assistantVoice.set(voiceVal)
settingsSaved = true
// hide alert after 5 seconds
setTimeout(() => {
settingsSaved = false
}, 5000)
// restart listening with new settings
// stopListening(() => startListening())
} catch (err) {
console.error("failed to save settings:", err)
}
setTimeout(() => {
saveButtonDisabled = false
}, 1000)
}
// ### INIT
onMount(async () => {
// load voices
try {
const voices = await invoke<VoiceConfig[]>("list_voices")
availableVoices = voices.map(v => v.voice)
} catch (err) {
console.error("Failed to load voices:", err)
availableVoices = []
}
try {
// load microphones
const mics = await invoke<string[]>("pv_get_audio_devices")
availableMicrophones = [
{ label: t('settings-mic-default'), value: "-1" }, // system default
...mics.map((name, idx) => ({
label: name,
value: String(idx)
}))
]
// load vosk models
const languageNames: Record<string, string> = {
us: 'English',
ru: 'Русский',
de: 'German',
fr: 'French',
es: 'Spanish',
// ..
};
const voskModels = await invoke<{ name: string; language: string; size: string }[]>("list_vosk_models")
availableVoskModels = voskModels.map(m => ({
label: `${m.name} (${languageNames[m.language] ?? m.language}, ${m.size})`,
value: m.name
}))
// load gliner models
const glinerModels = await invoke<{ display_name: string; value: string }[]>("list_gliner_models")
availableGlinerModels = glinerModels.map(m => ({
label: m.display_name,
value: m.value,
}))
// load settings from db
const [mic, wakeWord, intentReco, slotEngine, glinerModel, voskModel,
noiseSuppression, vad, gainNormalizer,
pico, openai] = await Promise.all([
invoke<string>("db_read", { key: "selected_microphone" }),
invoke<string>("db_read", { key: "selected_wake_word_engine" }),
invoke<string>("db_read", { key: "selected_intent_recognition_engine" }),
invoke<string>("db_read", { key: "selected_slot_extraction_engine" }),
invoke<string>("db_read", { key: "selected_gliner_model" }),
invoke<string>("db_read", { key: "selected_vosk_model" }),
invoke<string>("db_read", { key: "noise_suppression" }),
invoke<string>("db_read", { key: "vad" }),
invoke<string>("db_read", { key: "gain_normalizer" }),
invoke<string>("db_read", { key: "api_key__picovoice" }),
invoke<string>("db_read", { key: "api_key__openai" })
])
selectedMicrophone = mic
selectedWakeWordEngine = wakeWord
selectedIntentRecognitionEngine = intentReco
selectedSlotExtractionEngine = slotEngine
selectedVoskModel = voskModel
selectedGlinerModel = glinerModel
selectedNoiseSuppression = noiseSuppression
selectedVad = vad
gainNormalizerEnabled = gainNormalizer === "true"
apiKeyPicovoice = pico
apiKeyOpenai = openai
// load AI backend prefs
try {
const [llm_pref, tts_pref] = await Promise.all([
invoke<string>("db_read", { key: "llm_backend" }),
invoke<string>("db_read", { key: "tts_backend" }),
])
selectedLlmBackend = llm_pref ?? ""
selectedTtsBackend = tts_pref ?? ""
} catch (err) {
console.error("Failed to load backend prefs:", err)
}
await refreshActiveBackends()
} catch (err) {
console.error("failed to load settings:", err)
}
})
</script>
<Space h="xl" />
<Notification
title={t('settings-beta-title')}
icon={QuestionMarkCircled}
color="blue"
withCloseButton={false}
>
{t('settings-beta-desc')}<br />
{t('settings-beta-feedback')} <a href={issuesLink} target="_blank" rel="noopener noreferrer">{t('settings-beta-bot')}</a>.
<Space h="sm" />
<Button
color="gray"
radius="md"
size="xs"
uppercase
on:click={() => showInExplorer(logFilePath)}
>
{t('settings-open-logs')}
</Button>
</Notification>
<Space h="xl" />
{#if settingsSaved}
<Notification
title={t('notification-saved')}
icon={Check}
color="teal"
on:close={() => { settingsSaved = false }}
/>
<Space h="xl" />
{/if}
<Tabs class="form" color="#8AC832" position="left">
<Tabs.Tab label={t('settings-general')} icon={Gear}>
<Space h="sm" />
<div class="voice-select">
<label>{t('settings-voice')}</label>
<p class="description">{t('settings-voice-desc')}</p>
<div class="voice-options">
{#each availableVoices as voice}
<button
type="button"
class="voice-option"
class:selected={voiceVal === voice.id}
on:click={() => selectVoice(voice.id)}
>
<div class="voice-info">
<span class="voice-name">{voice.name}</span>
{#if voice.author}
<span class="voice-author">by {voice.author}</span>
{/if}
</div>
<div class="voice-languages">
{#each voice.languages as lang}
<img
src="/media/flags/{lang.toUpperCase()}.png"
alt={lang}
width="20"
title={lang}
/>
{/each}
</div>
</button>
{/each}
{#if availableVoices.length === 0}
<p class="no-voices">{t('settings-no-voices')}</p>
{/if}
</div>
</div>
</Tabs.Tab>
<Tabs.Tab label={t('settings-devices')} icon={Mix}>
<Space h="sm" />
<NativeSelect
data={availableMicrophones}
label={t('settings-microphone')}
description={t('settings-microphone-desc')}
variant="filled"
bind:value={selectedMicrophone}
/>
</Tabs.Tab>
<Tabs.Tab label={t('settings-ai-backends') || 'AI Backends'} icon={ChatBubble}>
<Space h="sm" />
{#if activeBackends}
<Alert title={t('settings-ai-active') || 'Активный движок'} color="cyan" variant="outline">
<Text size="sm" color="gray">
LLM: <strong>{activeBackends.llm}</strong>
{#if activeBackends.llm_model}({activeBackends.llm_model}){/if}
&nbsp;·&nbsp; TTS: <strong>{activeBackends.tts}</strong>
&nbsp;·&nbsp; {t('settings-profile') || 'Profile'}: <strong>{activeBackends.profile}</strong>
</Text>
</Alert>
<Space h="md" />
{/if}
<NativeSelect
data={[
{ label: t('settings-ai-auto') || 'Авто (по env / GROQ_TOKEN)', value: "" },
{ label: 'Groq (cloud, fast, free tier)', value: "groq" },
{ label: 'Ollama (local, offline, private)', value: "ollama" },
]}
label={t('settings-llm-backend') || 'LLM движок'}
description={t('settings-llm-backend-desc') || 'Где исполнять LLM-запросы. Hot-swap, без рестарта.'}
variant="filled"
bind:value={selectedLlmBackend}
on:change={() => applyLlmBackend(selectedLlmBackend)}
/>
<Space h="md" />
<NativeSelect
data={[
{ label: t('settings-ai-auto') || 'Авто (по env / Piper detect)', value: "" },
{ label: 'SAPI (Windows, robotic)', value: "sapi" },
{ label: 'Piper (neural, recommended)', value: "piper" },
{ label: 'Silero (PyTorch helper)', value: "silero" },
]}
label={t('settings-tts-backend') || 'TTS движок'}
description={t('settings-tts-backend-desc') || 'Применяется сразу — без перезапуска.'}
variant="filled"
bind:value={selectedTtsBackend}
on:change={() => applyTtsBackend(selectedTtsBackend)}
/>
<Space h="md" />
{#if backendSwapBusy}
<Text size="xs" color="gray">
{t('settings-ai-applying') || 'Применяю...'}
</Text>
{/if}
{#if backendSwapError}
<Notification
title={t('settings-ai-error') || 'Ошибка переключения'}
icon={CrossCircled}
color="red"
withCloseButton={true}
on:close={() => { backendSwapError = "" }}
>
{backendSwapError}
</Notification>
{/if}
<Space h="md" />
<InputWrapper label={t('settings-llm-context') || 'Контекст разговора'}>
<Text size="sm" color="gray">
{t('settings-llm-context-desc') || 'LLM помнит предыдущие реплики. Сбросить, если ответы становятся странными.'}
</Text>
<Space h="xs" />
<Button
color="gray"
radius="md"
size="xs"
uppercase
on:click={resetLlmContext}
>
{t('settings-llm-reset') || 'Сбросить контекст'}
</Button>
</InputWrapper>
<Space h="xl" />
<Alert title={t('settings-ai-tips') || 'Подсказки'} color="gray" variant="outline">
<Text size="sm" color="gray">
<strong>Ollama</strong>: установи с <a href="https://ollama.com" target="_blank">ollama.com</a>,
выполни <code>ollama pull qwen2.5:3b</code>.<br />
<strong>Piper</strong>: запусти <code>pwsh tools/piper/install.ps1</code> чтобы скачать голос.<br />
<strong>Голосом</strong>: «переключись на локальный» / «переключись на облако»,
«какой у тебя мозг» — спрашивает статус.<br />
<strong>Сброс/повтор</strong>: «сбрось контекст» / «повтори последнее».
</Text>
</Alert>
</Tabs.Tab>
<Tabs.Tab label={t('settings-neural-networks')} icon={Cube}>
<Space h="sm" />
<NativeSelect
data={[
{ label: "Rustpotter", value: "Rustpotter" },
{ label: "Vosk", value: "Vosk" },
{ label: "Picovoice Porcupine", value: "Picovoice" }
]}
label={t('settings-wake-word-engine')}
description={t('settings-wake-word-desc')}
variant="filled"
bind:value={selectedWakeWordEngine}
/>
{#if selectedWakeWordEngine === "picovoice"}
<Space h="sm" />
<Alert title={t('settings-attention')} color="#868E96" variant="outline">
<Notification
title={t('settings-picovoice-warning')}
icon={CrossCircled}
color="orange"
withCloseButton={false}
>
{t('settings-picovoice-waiting')}
</Notification>
<Space h="sm" />
<Text size="sm" color="gray">
{t('settings-picovoice-key-desc')}
<a href="https://console.picovoice.ai/" target="_blank">Picovoice Console</a>.
</Text>
<Space h="sm" />
<Input
icon={Code}
placeholder={t('settings-picovoice-key')}
variant="filled"
autocomplete="off"
bind:value={apiKeyPicovoice}
/>
</Alert>
{/if}
<Space h="xl" />
{#key availableVoskModels}
<NativeSelect
data={[
{ label: t('settings-auto-detect'), value: "" },
...availableVoskModels
]}
label={t('settings-vosk-model')}
description={t('settings-vosk-model-desc')}
variant="filled"
bind:value={selectedVoskModel}
/>
{/key}
{#if availableVoskModels.length === 0}
<Space h="sm" />
<Alert title={t('settings-models-not-found')} color="orange" variant="outline">
<Text size="sm" color="gray">
{t('settings-models-hint')}
</Text>
</Alert>
{/if}
<Space h="xl" />
<NativeSelect
data={[
{ label: "Intent Classifier", value: "IntentClassifier" },
{ label: "Embedding Classifier", value: "EmbeddingClassifier" }
]}
label={t('settings-intent-engine')}
description={t('settings-intent-engine-desc')}
variant="filled"
bind:value={selectedIntentRecognitionEngine}
/>
<Space h="xl" />
<NativeSelect
data={[
{ label: t('settings-disabled'), value: "None" },
{ label: "GLiNER (NER)", value: "GLiNER" }
]}
label={t('settings-slot-engine')}
description={t('settings-slot-engine-desc')}
variant="filled"
bind:value={selectedSlotExtractionEngine}
/>
{#if selectedSlotExtractionEngine === "GLiNER"}
<Space h="sm" />
{#key availableGlinerModels}
<NativeSelect
data={[
{ label: t('settings-auto-detect'), value: "" },
...availableGlinerModels
]}
label={t('settings-gliner-model')}
description={t('settings-gliner-model-desc')}
variant="filled"
bind:value={selectedGlinerModel}
/>
{/key}
{#if availableGlinerModels.length === 0}
<Space h="sm" />
<Alert title={t('settings-models-not-found')} color="orange" variant="outline">
<Text size="sm" color="gray">
{t('settings-gliner-models-hint')}
</Text>
</Alert>
{/if}
{/if}
<Space h="xl" />
<NativeSelect
data={[
{ label: t('settings-disabled'), value: "None" },
{ label: "Nnnoiseless", value: "Nnnoiseless" }
]}
label={t('settings-noise-suppression')}
description={t('settings-noise-suppression-desc')}
variant="filled"
bind:value={selectedNoiseSuppression}
/>
<Space h="md" />
<NativeSelect
data={[
{ label: t('settings-disabled'), value: "None" },
{ label: "Energy", value: "Energy" },
{ label: "Nnnoiseless", value: "Nnnoiseless" }
]}
label={t('settings-vad')}
description={t('settings-vad-desc')}
variant="filled"
bind:value={selectedVad}
/>
<Space h="md" />
<InputWrapper label={t('settings-gain-normalizer')}>
<Text size="sm" color="gray">
{t('settings-gain-normalizer-desc')}
</Text>
<Space h="xs" />
<Switch
label={gainNormalizerEnabled ? t('settings-enabled') : t('settings-disabled')}
bind:checked={gainNormalizerEnabled}
/>
</InputWrapper>
<Space h="xl" />
<InputWrapper label={t('settings-openai-key')}>
<Text size="sm" color="gray">
{t('settings-openai-not-supported')}
</Text>
<Space h="sm" />
<Input
icon={Code}
placeholder={t('settings-openai-key')}
variant="filled"
autocomplete="off"
bind:value={apiKeyOpenai}
disabled
/>
</InputWrapper>
</Tabs.Tab>
</Tabs>
<Space h="xl" />
<Button
color="lime"
radius="md"
size="sm"
uppercase
ripple
fullSize
on:click={saveSettings}
disabled={saveButtonDisabled}
>
{t('settings-save')}
</Button>
<Space h="sm" />
<Button
color="blue"
radius="md"
size="sm"
uppercase
fullSize
on:click={async () => {
try {
const msg = await invoke<string>("open_command_builder")
backendSwapError = "Конструктор команд: " + msg
} catch (e) {
backendSwapError = "Не удалось запустить конструктор: " + String(e)
}
}}
>
Конструктор команд (Python)
</Button>
<Space h="sm" />
<Button
color="gray"
radius="md"
size="sm"
uppercase
fullSize
on:click={() => $goto("/")}
>
{t('settings-back')}
</Button>
<HDivider />
<Footer />
<style lang="scss">
.voice-select {
margin-bottom: 1rem;
label {
font-weight: 600;
font-size: 0.9rem;
color: #fff;
display: block;
margin-bottom: 0.25rem;
}
.description {
font-size: 0.75rem;
color: rgba(255,255,255,0.5);
margin: 0 0 0.75rem;
white-space: pre-line;
}
}
$voice-item-height: 70px;
$voice-item-gap: 0.5rem;
$voice-max-visible: 3;
.voice-options {
display: flex;
flex-direction: column;
gap: $voice-item-gap;
max-height: $voice-item-height * $voice-max-visible;
overflow-y: auto;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.05);
border-radius: 3px;
}
&::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
border-radius: 3px;
&:hover {
background: rgba(255, 255, 255, 0.3);
}
}
}
.voice-option {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
background: rgba(30, 40, 45, 0.8);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
text-align: left;
width: 100%;
&:hover {
background: rgba(40, 55, 60, 0.9);
border-color: rgba(255,255,255,0.2);
}
&.selected {
background: rgba(82, 254, 254, 0.1);
border-color: rgba(82, 254, 254, 0.4);
}
}
.voice-info {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.15rem;
}
.voice-name {
font-size: 0.85rem;
color: #fff;
font-weight: 500;
}
.voice-author {
font-size: 0.7rem;
color: rgba(255,255,255,0.4);
}
.voice-languages {
display: flex;
gap: 0.35rem;
img {
opacity: 0.8;
border-radius: 2px;
}
}
.no-voices {
font-size: 0.8rem;
color: rgba(255,255,255,0.4);
font-style: italic;
}
</style>