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)
|
||||
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-failed = Failed to execute command
|
||||
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-failed = Не удалось выполнить команду
|
||||
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
|
||||
|
||||
[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-plugin-shell = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
<script lang="ts">
|
||||
import { onMount, onDestroy } from "svelte"
|
||||
import { invoke } from "@tauri-apps/api/core"
|
||||
|
||||
import { appInfo, translations, translate } from "@/stores"
|
||||
|
||||
$: t = (key: string) => translate($translations, key)
|
||||
|
|
@ -14,9 +17,72 @@
|
|||
upstreamLink = info.upstreamLink
|
||||
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>
|
||||
|
||||
<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>
|
||||
© {currentYear} J.A.R.V.I.S.
|
||||
<span class="edition" title="Rust + Tauri build">Rust</span>
|
||||
|
|
@ -62,6 +128,35 @@
|
|||
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 {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
· 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}>
|
||||
<Space h="sm" />
|
||||
<NativeSelect
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue