feat(gui): footer backend chips + Settings 'AI Backends' tab + i18n

Surfaces the LLM/TTS hot-swap UX in the GUI so the user doesn't need env vars
or voice commands. Tauri commands for this landed in b243e67; this commit
wires them to Svelte.

Footer (frontend/src/components/Footer.svelte)
  - Poll get_active_backends() every 5 s, show TTS / LLM / Profile chips.
  - Chips have border accents by kind (cyan = TTS, lime = LLM, orange = Profile).
  - Friendly title tooltips with full backend + model names.
  - Silent if jarvis-gui can't reach the command (e.g. cold start) — no UI flicker.

Settings — new "AI Backends" tab (frontend/src/routes/settings/index.svelte)
  - Status banner showing active LLM (with model name) + TTS + Profile.
  - LLM selector: Auto / Groq / Ollama → calls set_llm_backend on change
    (hot-swap, persisted to DB).
  - TTS selector: Auto / SAPI / Piper / Silero → calls set_tts_backend
    (persisted, effective on next jarvis-app restart).
  - "Reset context" button → llm_reset_context Tauri command.
  - Error toast for swap failures (e.g. Groq selected but no GROQ_TOKEN).
  - Tips alert: how to install Ollama / Piper / voice commands.
  - Loads prefs via db_read('llm_backend' / 'tts_backend') on mount.

i18n strings (ru/en/ua, .ftl files)
  - 14 new keys: settings-ai-backends, settings-ai-active, settings-ai-auto,
    settings-ai-applying, settings-ai-error, settings-ai-tips,
    settings-llm-backend{,-desc}, settings-tts-backend{,-desc},
    settings-llm-context{,-desc}, settings-llm-reset, settings-profile.

Build glue (crates/jarvis-gui/Cargo.toml)
  - jarvis-core dep now includes the `llm` feature so backends.rs can resolve
    `jarvis_core::llm::*` (was failing to compile after the global LLM module).

.gitignore
  - tools/piper/*.dll, piper.exe, voices/, espeak-ng-data/ — locally
    installed binaries from tools/piper/install.ps1, not committed.

Build: cargo build --release -p jarvis-gui green (4m, includes npm/Vite).
Test path: rebuild + launch jarvis-gui, /settings → "AI Backends" tab.
This commit is contained in:
Bossiara13 2026-05-15 17:06:46 +03:00
parent b243e67870
commit d63399f4c9
6 changed files with 313 additions and 4 deletions

View file

@ -30,7 +30,8 @@
Code,
Gear,
QuestionMarkCircled,
CrossCircled
CrossCircled,
ChatBubble,
} from "radix-icons-svelte"
$: t = (key: string) => translate($translations, key)
@ -85,6 +86,66 @@
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: "" })
} catch (err) {
backendSwapError = String(err)
}
await refreshActiveBackends()
return
}
backendSwapBusy = true
try {
await invoke<string>("set_llm_backend", { name: target })
await refreshActiveBackends()
} catch (err) {
backendSwapError = String(err)
} finally {
backendSwapBusy = false
}
}
async function applyTtsBackend(target: string) {
try {
await invoke<string>("set_tts_backend", { name: target })
} 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
@ -215,6 +276,19 @@
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)
}
@ -307,6 +381,102 @@
/>
</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') || 'Применится при следующем запуске jarvis-app.'}
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