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:
parent
b243e67870
commit
d63399f4c9
6 changed files with 313 additions and 4 deletions
12
.gitignore
vendored
12
.gitignore
vendored
|
|
@ -56,3 +56,15 @@ tree.txt
|
||||||
|
|
||||||
# Tauri-generated platform schemas (regenerated on every build)
|
# Tauri-generated platform schemas (regenerated on every build)
|
||||||
crates/jarvis-gui/gen/schemas/*-schema.json
|
crates/jarvis-gui/gen/schemas/*-schema.json
|
||||||
|
|
||||||
|
# Piper TTS binaries (installed via tools/piper/install.ps1, not committed)
|
||||||
|
tools/piper/piper.exe
|
||||||
|
tools/piper/*.dll
|
||||||
|
tools/piper/espeak-ng-data/
|
||||||
|
tools/piper/voices/
|
||||||
|
tools/piper/libtashkeel_model.ort
|
||||||
|
tools/piper/pkgconfig/
|
||||||
|
|
||||||
|
# Silero model cache (downloaded by torch.hub on first synth)
|
||||||
|
tools/silero/.silero_cache/
|
||||||
|
tools/silero/*.pt
|
||||||
|
|
|
||||||
|
|
@ -142,3 +142,19 @@ settings-gliner-models-hint = No GLiNER models found.
|
||||||
search-error-not-running = Assistant is not running
|
search-error-not-running = Assistant is not running
|
||||||
search-error-failed = Failed to execute command
|
search-error-failed = Failed to execute command
|
||||||
settings-no-voices = No voices found
|
settings-no-voices = No voices found
|
||||||
|
|
||||||
|
# AI Backends tab
|
||||||
|
settings-ai-backends = AI Backends
|
||||||
|
settings-ai-active = Active backend
|
||||||
|
settings-ai-auto = Auto
|
||||||
|
settings-ai-applying = Applying...
|
||||||
|
settings-ai-error = Switch failed
|
||||||
|
settings-ai-tips = Tips
|
||||||
|
settings-llm-backend = LLM backend
|
||||||
|
settings-llm-backend-desc = Where to run LLM requests. Hot-swap, no restart needed.
|
||||||
|
settings-tts-backend = TTS backend
|
||||||
|
settings-tts-backend-desc = Takes effect on next jarvis-app launch.
|
||||||
|
settings-llm-context = Conversation context
|
||||||
|
settings-llm-context-desc = LLM remembers previous turns. Reset if answers get weird.
|
||||||
|
settings-llm-reset = Reset context
|
||||||
|
settings-profile = Profile
|
||||||
|
|
@ -142,3 +142,19 @@ settings-gliner-models-hint = Модели GLiNER не найдены.
|
||||||
search-error-not-running = Ассистент не запущен
|
search-error-not-running = Ассистент не запущен
|
||||||
search-error-failed = Не удалось выполнить команду
|
search-error-failed = Не удалось выполнить команду
|
||||||
settings-no-voices = Голоса не найдены
|
settings-no-voices = Голоса не найдены
|
||||||
|
|
||||||
|
# AI Backends tab
|
||||||
|
settings-ai-backends = ИИ-движки
|
||||||
|
settings-ai-active = Активный движок
|
||||||
|
settings-ai-auto = Авто
|
||||||
|
settings-ai-applying = Применяю...
|
||||||
|
settings-ai-error = Ошибка переключения
|
||||||
|
settings-ai-tips = Подсказки
|
||||||
|
settings-llm-backend = LLM движок
|
||||||
|
settings-llm-backend-desc = Где исполнять LLM-запросы. Hot-swap, без рестарта.
|
||||||
|
settings-tts-backend = TTS движок
|
||||||
|
settings-tts-backend-desc = Применится при следующем запуске jarvis-app.
|
||||||
|
settings-llm-context = Контекст разговора
|
||||||
|
settings-llm-context-desc = LLM помнит предыдущие реплики. Сбросить, если ответы становятся странными.
|
||||||
|
settings-llm-reset = Сбросить контекст
|
||||||
|
settings-profile = Профиль
|
||||||
|
|
@ -7,7 +7,7 @@ repository.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
jarvis-core = { path = "../jarvis-core", default-features = false }
|
jarvis-core = { path = "../jarvis-core", default-features = false, features = ["llm"] }
|
||||||
tauri = { version = "2", features = [] } # v1: "shell-open", "dialog-message", "path-all"
|
tauri = { version = "2", features = [] } # v1: "shell-open", "dialog-message", "path-all"
|
||||||
tauri-plugin-shell = "2"
|
tauri-plugin-shell = "2"
|
||||||
tauri-plugin-dialog = "2"
|
tauri-plugin-dialog = "2"
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { onMount, onDestroy } from "svelte"
|
||||||
|
import { invoke } from "@tauri-apps/api/core"
|
||||||
|
|
||||||
import { appInfo, translations, translate } from "@/stores"
|
import { appInfo, translations, translate } from "@/stores"
|
||||||
|
|
||||||
$: t = (key: string) => translate($translations, key)
|
$: t = (key: string) => translate($translations, key)
|
||||||
|
|
@ -14,9 +17,72 @@
|
||||||
upstreamLink = info.upstreamLink
|
upstreamLink = info.upstreamLink
|
||||||
issuesLink = info.issuesLink
|
issuesLink = info.issuesLink
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Active backends — refreshed every 5 seconds. Silently fails if the
|
||||||
|
// Tauri command isn't registered (e.g. running against an old jarvis-app).
|
||||||
|
interface ActiveBackends {
|
||||||
|
tts: string
|
||||||
|
llm: string
|
||||||
|
llm_model: string | null
|
||||||
|
profile: string
|
||||||
|
}
|
||||||
|
|
||||||
|
let backends: ActiveBackends | null = null
|
||||||
|
let pollId: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
async function refreshBackends() {
|
||||||
|
try {
|
||||||
|
backends = await invoke<ActiveBackends>("get_active_backends")
|
||||||
|
} catch (err) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
refreshBackends()
|
||||||
|
pollId = setInterval(refreshBackends, 5000)
|
||||||
|
})
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
if (pollId !== null) clearInterval(pollId)
|
||||||
|
})
|
||||||
|
|
||||||
|
function chipTitle(key: "tts" | "llm"): string {
|
||||||
|
if (!backends) return ""
|
||||||
|
if (key === "tts") return `TTS backend: ${backends.tts}`
|
||||||
|
const model = backends.llm_model ? ` (${backends.llm_model})` : ""
|
||||||
|
return `LLM backend: ${backends.llm}${model}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function chipLabel(value: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
sapi: "SAPI",
|
||||||
|
piper: "Piper",
|
||||||
|
silero: "Silero",
|
||||||
|
groq: "Groq",
|
||||||
|
ollama: "Ollama",
|
||||||
|
none: "—",
|
||||||
|
}
|
||||||
|
return map[value] ?? value
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<footer id="footer">
|
<footer id="footer">
|
||||||
|
{#if backends}
|
||||||
|
<p class="backends" title="Активные движки. Меняются в /settings и голосом.">
|
||||||
|
<span class="chip chip-tts" title={chipTitle("tts")}>
|
||||||
|
<small>TTS</small> {chipLabel(backends.tts)}
|
||||||
|
</span>
|
||||||
|
<span class="chip chip-llm" title={chipTitle("llm")}>
|
||||||
|
<small>LLM</small> {chipLabel(backends.llm)}
|
||||||
|
</span>
|
||||||
|
{#if backends.profile && backends.profile !== "default"}
|
||||||
|
<span class="chip chip-profile" title="Active profile">
|
||||||
|
<small>Profile</small> {backends.profile}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
<p>
|
<p>
|
||||||
© {currentYear} J.A.R.V.I.S.
|
© {currentYear} J.A.R.V.I.S.
|
||||||
<span class="edition" title="Rust + Tauri build">Rust</span>
|
<span class="edition" title="Rust + Tauri build">Rust</span>
|
||||||
|
|
@ -62,6 +128,35 @@
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.backends {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
display: inline-block;
|
||||||
|
margin: 0 3px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
color: #cfd2d5;
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
|
||||||
|
small {
|
||||||
|
color: #6c6e71;
|
||||||
|
margin-right: 3px;
|
||||||
|
font-size: 9px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.chip-tts { border-color: rgba(82, 254, 254, 0.25); }
|
||||||
|
&.chip-llm { border-color: rgba(138, 200, 50, 0.25); }
|
||||||
|
&.chip-profile { border-color: rgba(255, 168, 60, 0.25); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
p {
|
p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,8 @@
|
||||||
Code,
|
Code,
|
||||||
Gear,
|
Gear,
|
||||||
QuestionMarkCircled,
|
QuestionMarkCircled,
|
||||||
CrossCircled
|
CrossCircled,
|
||||||
|
ChatBubble,
|
||||||
} from "radix-icons-svelte"
|
} from "radix-icons-svelte"
|
||||||
|
|
||||||
$: t = (key: string) => translate($translations, key)
|
$: t = (key: string) => translate($translations, key)
|
||||||
|
|
@ -85,6 +86,66 @@
|
||||||
let apiKeyPicovoice = ""
|
let apiKeyPicovoice = ""
|
||||||
let apiKeyOpenai = ""
|
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
|
// subscribe to stores
|
||||||
assistantVoice.subscribe(value => {
|
assistantVoice.subscribe(value => {
|
||||||
voiceVal = value
|
voiceVal = value
|
||||||
|
|
@ -215,6 +276,19 @@
|
||||||
gainNormalizerEnabled = gainNormalizer === "true"
|
gainNormalizerEnabled = gainNormalizer === "true"
|
||||||
apiKeyPicovoice = pico
|
apiKeyPicovoice = pico
|
||||||
apiKeyOpenai = openai
|
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) {
|
} catch (err) {
|
||||||
console.error("failed to load settings:", err)
|
console.error("failed to load settings:", err)
|
||||||
}
|
}
|
||||||
|
|
@ -307,6 +381,102 @@
|
||||||
/>
|
/>
|
||||||
</Tabs.Tab>
|
</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}
|
||||||
|
· TTS: <strong>{activeBackends.tts}</strong>
|
||||||
|
· {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}>
|
<Tabs.Tab label={t('settings-neural-networks')} icon={Cube}>
|
||||||
<Space h="sm" />
|
<Space h="sm" />
|
||||||
<NativeSelect
|
<NativeSelect
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue