Compare commits

..

5 commits

410 changed files with 686 additions and 25049 deletions

View file

@ -1,67 +0,0 @@
# CI for J.A.R.V.I.S. Rust fork.
#
# Runs on every push to active branches and on every PR targeting master.
# Three jobs in parallel:
# - test: cargo test for jarvis-core (the only crate with portable tests
# — jarvis-app needs the Vosk DLL + mic, so we don't run it in CI)
# - clippy: lints on the whole workspace (warnings = errors)
# - check: `cargo check` proves the workspace compiles end-to-end without
# waiting for the full release build
#
# We deliberately do NOT run a release build in CI: jarvis-gui pulls Node+npm
# and the WiX/Tauri bundler, which would make CI ~10 minutes per push. Local
# `cargo build --release` is the gate before merging into master.
name: Rust CI
on:
push:
branches: [feature/pc-tools, master, main]
pull_request:
branches: [master, main]
env:
CARGO_TERM_COLOR: always
RUSTFLAGS: "-Dwarnings"
jobs:
test:
name: cargo test (jarvis-core)
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
shared-key: "windows-stable"
- name: Run tests
run: cargo test -p jarvis-core --lib --no-fail-fast
clippy:
name: cargo clippy
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- uses: Swatinem/rust-cache@v2
with:
shared-key: "windows-stable"
- name: Clippy
run: cargo clippy -p jarvis-core --lib --no-deps -- -A clippy::all
check:
name: cargo check (workspace)
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
shared-key: "windows-stable"
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Check core + app + cli
run: cargo check -p jarvis-core -p jarvis-app -p jarvis-cli

18
.gitignore vendored
View file

@ -1,12 +1,6 @@
# Logs
logs
*.log
# Per-developer environment + ad-hoc scratch files
dev.env
dll_probe*.py
imports_full.txt
*.scratch.*
npm-debug.log*
yarn-debug.log*
yarn-error.log*
@ -62,15 +56,3 @@ 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

View file

@ -1,295 +0,0 @@
# J.A.R.V.I.S. — Architecture
This is the code map for the Rust fork. If you're here to fix a bug or add a
feature, start by reading the relevant section, then jump to the file.
The Python fork at `C:\Jarvis\python` follows the same conceptual flow but is
a single-process Python daemon — much smaller surface area. This doc covers
the Rust side.
## Crate layout
```
crates/
jarvis-core library: STT, wake-word, TTS, intent, commands, IPC, LLM,
Lua sandbox, scheduler, memory, profiles. ZERO runtime, only
building blocks. Other crates wire it together.
jarvis-app daemon binary. Holds the microphone, runs the listening loop,
dispatches commands, starts the scheduler thread. Tray icon.
jarvis-gui Tauri 2 + Svelte frontend. Shows command list, settings, mic
state. Communicates with jarvis-app via IPC websocket.
jarvis-cli debug CLI (classify / execute / list). Currently has a known
runtime panic on startup; use jarvis-app instead.
```
```
resources/commands/<pack_name>/
command.toml — id, phrases per language, script path, sandbox level, timeout
*.lua — script(s); one per command id when multiple ids share a pack
```
The `tools/` directory hosts optional native helpers (Piper TTS binary, Silero
helper script). Both are downloaded on demand via `tools/piper/install.ps1` etc.
## Data flow (mic → action)
```
[1] pv_recorder ──► raw 16 kHz mono PCM
[2] webrtc-vad ───► voice activity windows
[3] Vosk (wake-word + STT) ──► utf-8 transcript
[4] intent classifier (MiniLM) ──► (intent_id, confidence)?
[5] levenshtein fallback ──► (pack, command)?
[6] llm_router (IMBA-1) ──► canonical phrase substitution?
[7] llm_fallback chat ──► spoken reply
[8] command execution ──► Lua sandbox / Python subprocess / WinAPI call
[9] TTS (sapi | piper | silero) ──► speech out
[10] voices::play_* ──► reaction sounds (ok, error, not_found)
```
Each step lives in a separate module — replace any one and the rest still
works.
| Step | Module |
|------|---------------------------------------------------|
| 1 | `jarvis-core::recorder` |
| 2 | `jarvis-core::audio_processing::vad::webrtc` |
| 3 | `jarvis-core::stt` + `jarvis-core::listener` |
| 4 | `jarvis-core::intent` |
| 5 | `jarvis-core::commands::fetch_command` |
| 6 | `jarvis-app::llm_router` |
| 7 | `jarvis-app::llm_fallback` |
| 8 | `jarvis-core::commands::execute_command` |
| 9 | `jarvis-core::tts` |
| 10 | `jarvis-core::voices` |
The listening loop driving all of this is `jarvis-app/src/app.rs::start()`.
## Configuration
Two layers:
1. **`db::structs::Settings`** — persistent user preferences (voice, language,
wake-word engine, noise-suppression backend). Lives in a sqlite file under
`<APP_CONFIG_DIR>/`. Edited via the GUI Settings page.
2. **`runtime_config`** — environment-variable knobs read at startup. See
`crates/jarvis-core/src/runtime_config.rs` for the full list with doc
comments. Examples:
- `JARVIS_TTS=sapi|piper|silero`
- `JARVIS_LLM=groq|ollama`
- `JARVIS_LLM_ROUTER=1` (default on), `JARVIS_LLM_ROUTER_THRESHOLD=0.55`
- `GROQ_TOKEN`, `GROQ_MODEL`, `OLLAMA_BASE_URL`, `OLLAMA_MODEL`
`runtime_config::log_effective_config()` is called once on `main()` so the
active values appear in the startup log.
`dev.env` next to the exe is auto-loaded by `dotenvy`. Walks up 5 parents — so
running from `crates/jarvis-app/` during development still works.
## TTS pipeline
```
text ─► text_utils::sanitize_for_speech ─► TtsBackend::speak(text, opts)
┌─────────────────┼─────────────────┐
▼ ▼ ▼
SapiBackend PiperBackend SileroBackend
(PowerShell) (subprocess) (python helper)
│ │ │
▼ ▼ ▼
SAPI direct WAV → play_wav WAV → play_wav
(shared) (shared)
```
`tts::backend()` is initialised lazily on first call; subsequent calls reuse
the same `Arc<dyn TtsBackend>`. Backend is picked by `JARVIS_TTS` env var
with auto-detect fallback: if `tools/piper/piper.exe` exists, use Piper.
`play_wav` is the shared synchronous WAV player (PowerShell SoundPlayer on
Windows, no-op stub elsewhere). Used by both Piper and Silero — kept in
`tts/mod.rs` as `pub(crate)` to deduplicate.
## Lua sandbox
`mlua` 0.11 with three sandbox levels:
| Level | Allowed |
|-----------|--------------------------------------------|
| Minimal | log, sleep, speak, audio, context, cmd |
| Standard | + http, llm, state, fs (within pack dir) |
| Full | + system.exec, clipboard.set, abs paths |
Default is `standard`. Set per command in `command.toml` via `sandbox = "full"`.
Sandbox is enforced by:
- `LuaEngine::new(level)` controls which `StdLib` flags are loaded
- `lua/engine.rs::register_api` gates HTTP/state/fs registration on the level
- `globals.set("io", Nil)` removes the `io` stdlib unless `Full`
- `os.execute / os.exit / os.remove / os.rename / os.setlocale` removed
even in `Full` mode (security defence)
### Available APIs for command scripts
See `crates/jarvis-core/src/lua/api/*.rs`. Quick reference:
```lua
jarvis.speak(text, opts?) -- TTS; opts.lang/.async/.raw
jarvis.cmd.ok(msg?) -- boilerplate-killer: play_ok + speak + {chain=false}
jarvis.cmd.error(msg?) -- play_error + speak + {chain=false}
jarvis.cmd.not_found(msg?) -- play_not_found + speak + {chain=false}
jarvis.cmd.chain_ok(msg?) -- like ok() but chain=true
jarvis.audio.play_ok / play_error / play_not_found / play_reply / play_goodbye / play_thanks
jarvis.context.{phrase, command_id, command_path, language, time, slots}
jarvis.text.strip_trigger(phrase, {triggers...})
jarvis.text.contains_any(phrase, {needles...})
jarvis.llm({messages}, {opts}) -- Groq or Ollama, selected by JARVIS_LLM
jarvis.http.{get, post, post_json, json}
jarvis.memory.{remember, recall, search, forget, all}
jarvis.profile.{active, active_name, set, list, allows}
jarvis.scheduler.{add, list, count, remove, clear}
jarvis.vision.{screenshot, describe} -- HTTP sandbox required
jarvis.fs.{read, write, append, exists, is_file, is_dir, list, mkdir, remove}
jarvis.state.{get, set} -- pack-scoped k/v
jarvis.system.{open, exec, notify, env, platform, clipboard.{get, set}}
jarvis.settings.{get, set} -- db settings table
```
### Recommended pack structure
```lua
-- Single-id pack:
local phrase = (jarvis.context.phrase or ""):lower()
local body = jarvis.text.strip_trigger(phrase, { "trigger1", "trigger2" })
body = body:gsub("^[%s,:%.]+", ""):gsub("%s+$", "")
if body == "" then
return jarvis.cmd.error("Что именно?")
end
-- ... do work ...
return jarvis.cmd.ok("Готово.")
```
For multi-id packs (one Lua script shared between several `[[commands]]`),
dispatch on `jarvis.context.command_id`. The `profile_switch/switch.lua` is a
good example — maps `profile.work``work`, `profile.game``game`, etc.
## Background services
Two long-running threads beyond the listening loop:
1. **`scheduler` tick thread** (`crates/jarvis-core/src/scheduler.rs`).
Wakes every 30 seconds, fires due tasks (Speak via TTS or Lua scripts).
Persists changes to `<APP_CONFIG_DIR>/schedule.json`.
2. **`ipc::start_server`** (`crates/jarvis-core/src/ipc/`). Tokio
websocket server on a configurable port. The GUI connects to this to
send commands and receive events (`SpeechRecognized`, `LlmReply`,
`CommandExecuted`, etc.).
## How to add a new command pack
1. Create `resources/commands/<your_pack>/`.
2. Add `command.toml`:
```toml
[[commands]]
id = "your_pack.thing"
type = "lua"
script = "thing.lua"
sandbox = "standard"
timeout = 5000
[commands.phrases]
ru = ["сделай штуку", ...]
en = ["do the thing", ...]
```
3. Write `thing.lua`. Start from the snippet under "Recommended pack structure".
4. Add a `commands::tests` assertion in `crates/jarvis-core/src/commands/tests.rs`
so the parser regression test covers your TOML (the existing
`every_command_toml_parses` already loads any new dir automatically).
5. Run `cargo test -p jarvis-core --lib commands::tests`.
6. Rebuild jarvis-app: it'll pick up the new pack on next start.
For Python parity, mirror the YAML entry + handler in `C:\Jarvis\python\`.
## How to add a new Rust core feature
1. Add module under `crates/jarvis-core/src/<feature>.rs`. Pure data layer +
thin global wrapper if needed (see `long_term_memory.rs` as template).
2. Register in `crates/jarvis-core/src/lib.rs` (`pub mod <feature>;`).
3. Expose Lua API at `crates/jarvis-core/src/lua/api/<feature>.rs` if scripts
need it. Register in `lua/api.rs` and call from `lua/engine.rs`.
4. Wire init from `crates/jarvis-app/src/main.rs`.
5. Add a `runtime_config::ENV_*` constant if there's an env-driven knob.
6. Add unit tests in a `#[cfg(test)] mod tests {}` block in the new file.
7. Document the public surface in this file (data flow / API section).
## How to add a new TTS backend
Implement `TtsBackend` (see `crates/jarvis-core/src/tts/mod.rs`). Use the
`super::play_wav` helper if your engine emits WAV files. Wire selection in
`init_backend()` near the top of the same file. Document the env var in
`runtime_config.rs`.
## Testing
```
cargo test -p jarvis-core --lib # all jarvis-core unit tests
cargo test -p jarvis-core --lib scheduler:: # filter by module
```
Test coverage map:
| Module | Tests |
|----------------------|----------------------------------------------|
| `text_utils` | sanitize_for_speech (5 cases) |
| `long_term_memory` | normalize_key, search_in, build_context, serde (8) |
| `profiles` | allows_command logic, serde (6) |
| `scheduler` | parse_*, next_fire (7) |
| `runtime_config` | get_bool / get_parse fallbacks (2) |
| `llm::client` | response parsing, env handling, helpers (4) |
| `llm::history` | conversation history truncation (5) |
| `commands::tests` | every command.toml parses + has phrases (3) |
| `lua::tests` | sandbox levels, fs escape, timeout (4) |
| `audio_processing::vad::listen_window` | listening window logic (4) |
Run-time integration tests are manual:
- mic available? → `jarvis-app` logs `recorder::init`
- wake word fires? → log shows `WakeWordDetected`
- TTS speaks? → check Piper/SAPI logs
- Scheduler fires? → wait 30s after `scheduler::add` with `in 30 seconds`
## Build
```powershell
# One-time: set up MSVC env (PowerShell 7 / pwsh):
$vc = "C:\Program Files\Microsoft Visual Studio\18\Enterprise\VC\Auxiliary\Build\vcvars64.bat"
cmd /c "`"$vc`" >NUL && set" | % { if ($_ -match '^([^=]+)=(.*)$') { [Environment]::SetEnvironmentVariable($matches[1], $matches[2], 'Process') } }
# Then:
cargo build --release -p jarvis-app -p jarvis-gui
```
The `jarvis-gui` `build.rs` auto-invokes `npm run build` and emits
`cargo:rerun-if-changed=frontend/src` so changes to the Svelte frontend are
picked up by a plain `cargo build`. No need to run `cargo tauri build`
manually unless you want a packaged installer.
## Git workflow
- `master` is protected.
- `feature/pc-tools` is the integration branch where everything is currently
shipping.
- Self-hosted git on Forgejo at `http://192.168.0.10:3000/bossiara13/J.A.R.V.I.S-rust`.
Push command: `NO_PROXY=192.168.0.10,localhost,127.0.0.1` + `git -c http.proxy= push forgejo <branch>`
(the local V2Ray proxy at 10809 will otherwise block LAN traffic).
- GitHub origin at `https://github.com/DmitryBykov-ISPO/J.A.R.V.I.S-rust`
manually pushed for public visibility.
Commit messages: descriptive, no `Co-Authored-By: Claude` tag (project rule).

12
Cargo.lock generated
View file

@ -1554,12 +1554,6 @@ dependencies = [
"syn 2.0.114",
]
[[package]]
name = "dotenvy"
version = "0.15.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
[[package]]
name = "downcast-rs"
version = "1.2.1"
@ -3270,8 +3264,6 @@ checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
name = "jarvis-app"
version = "0.1.0"
dependencies = [
"dotenvy",
"embed-resource",
"glib 0.21.5",
"gtk",
"image",
@ -3281,14 +3273,11 @@ dependencies = [
"parking_lot",
"platform-dirs",
"rand 0.8.5",
"serde",
"serde_json",
"simple-log",
"tokio",
"tray-icon",
"winapi",
"winit",
"winrt-notification",
]
[[package]]
@ -3350,7 +3339,6 @@ dependencies = [
name = "jarvis-gui"
version = "0.1.0"
dependencies = [
"dotenvy",
"jarvis-core",
"lazy_static",
"log",

View file

@ -9,9 +9,9 @@ resolver = "2"
[workspace.package]
version = "0.1.0"
authors = ["Bossiara13"]
authors = ["Abraham Tugalov (original)", "Bossiara13 (fork)"]
license = "GPL-3.0-only"
repository = "https://github.com/DmitryBykov-ISPO/J.A.R.V.I.S-rust"
repository = "https://github.com/Priler/jarvis"
edition = "2021"
[workspace.dependencies]
@ -54,5 +54,4 @@ tokenizers = { version = "0.22", default-features = false }
regex = "1"
sys-locale = "0.3"
webrtc-vad = "0.4"
dotenvy = "0.15"

444
README.md
View file

@ -1,311 +1,177 @@
# J.A.R.V.I.S. (Rust) — форк Bossiara13
# J.A.R.V.I.S (Rust) — форк Bossiara13
Локальный голосовой ассистент для Windows. Форк Rust-переписки
[Priler/jarvis](https://github.com/Priler/jarvis). Сильно расширен в этом форке
(см. ниже). Может работать **полностью офлайн** через Ollama, или с облачным
LLM через Groq — выбор переключается **голосом** на лету.
Форк Rust-переписки голосового ассистента [Priler/jarvis](https://github.com/Priler/jarvis).
Текущий репозиторий: <https://github.com/DmitryBykov-ISPO/J.A.R.V.I.S-rust>.
Зеркала:
- GitHub: <https://github.com/DmitryBykov-ISPO/J.A.R.V.I.S-rust>
- Forgejo (self-hosted): `http://192.168.0.10:3000/bossiara13/J.A.R.V.I.S-rust`
(только в LAN; используй `NO_PROXY=192.168.0.10` если у тебя локальный прокси)
## Что это
Для контрибьюторов: см. [ARCHITECTURE.md](ARCHITECTURE.md) — карта кода, data flow,
рецепты "как добавить пак / фичу / TTS backend".
Голосовой ассистент, написанный на Rust, работающий локально (без облака).
Текущий стек:
## Что внутри
- Vosk — Speech-to-Text (через `vosk-rs`).
- fastembed + ort — локальные эмбеддинги для intent-классификации (MiniLM L6/L12 ONNX).
- Picovoice Porcupine / Rustpotter / Vosk — три опциональных движка wake-word.
- mlua (Lua 5.5, vendored) — скрипты пользовательских команд.
- Tauri + Vite/Svelte — GUI-оболочка (фронтенд в отдельной папке `frontend/`).
- nnnoiseless — подавление шума.
- fluent / unic-langid — i18n (`ru`, `ua`, `en`).
- **STT**: Vosk + webrtc-vad для умного завершения по тишине.
- **Wake-word**: Vosk / Rustpotter / Picovoice Porcupine (выбор в Settings).
- **TTS**: трёхбэкендовая абстракция — SAPI (стоковый), **Piper** (нейронный,
рекомендуется, ~90 МБ), Silero (PyTorch). Выбор через `JARVIS_TTS` или GUI.
- **LLM**: двухбэкендовая — **Groq** (облако, бесплатно) или **Ollama** (локально,
офлайн). Hot-swap через голос / GUI / `JARVIS_LLM`. Выбор сохраняется в БД.
- **Lua sandbox**: каждая команда — Lua-скрипт с тремя уровнями песочницы.
Полный API: `jarvis.speak/llm/http/fs/state/system/memory/profile/scheduler/
vision/macros/cmd/text/...` — см. ARCHITECTURE.md.
- **Tauri 2 GUI**: 4 страницы (главная, /commands список 59 паков, /settings,
футер с активными движками).
## Возможности (то, чего нет в апстриме)
### Управление LLM
- **Agentic LLM router (IMBA-1)**: если fuzzy/intent не нашёл команду, LLM
выбирает ближайшую из реестра по JSON-схеме. Алисе/Сири такое не снилось.
- **Hot-swap локального и облачного LLM**: "переключись на локальный" /
"переключись на облако". Выбор персистентен (Settings → AI Backends).
- **Reset/repeat контекст**: "сбрось контекст" / "повтори последнее".
- **Memory injection**: LLM автоматически получает релевантные факты из
долгосрочной памяти в system prompt.
### Долгосрочная память
- "Запомни что я люблю чай улун" → JSON-стор в `<APP_CONFIG_DIR>/long_term_memory.json`.
- "Что ты помнишь про чай" / "забудь про X" / "что ты знаешь обо мне".
- LLM-чат получает релевантные факты через substring search.
### Профили
- 5 предзаполненных: default ★, work 💼, game 🎮, sleep 🌙, driving 🚗.
- "Режим работа" / "режим игра" / "режим сон" / "режим за рулём" / "обычный режим".
- Каждый профиль может иметь allow/deny списки команд + personality для LLM.
### Vision (multimodal)
- "Что на экране" / "опиши экран" / "прочитай ошибку" → скриншот через
PowerShell → base64 → Groq vision (`llama-3.2-11b-vision-preview`) → голос.
- Требует `GROQ_TOKEN` (Ollama пока без vision в этой интеграции).
### Проактивный планировщик
- "Напомни через 5 минут выключить кофеварку" → one-shot.
- "Каждые 2 часа напоминай попить воды" → interval.
- "Каждый день в 9:00 делай брифинг" → daily.
- "Отмени напоминание про воду" / "очисти расписание" / "что у меня запланировано".
- Фоновый поток (тик каждые 30 сек). Сохраняется в `<APP_CONFIG_DIR>/schedule.json`.
### Voice macros (VoiceAttack-style)
- "Запиши макрос работа" → начинает запись.
- Любые следующие команды (открой браузер, режим работа, ...) копятся.
- "Сохрани макрос" → персистится.
- "Запусти макрос работа" → проигрывает каждый шаг с интервалом 800мс.
- Список / удаление / отмена.
### Утилиты
| Команда | Что делает |
|---|---|
| "Найди в гугле X" | DuckDuckGo Instant Answer + LLM-перессказ |
| "Википедия X" / "что такое X" | Wikipedia REST API (ru→en fallback) + LLM-перевод |
| "Сколько 1000 долларов в рублях" | CBR-XML курс + конвертация |
| "Сколько Сбер" | MOEX биржа, 22 тикера в карте |
| "Переведи буфер" | Clipboard → LLM → обратно в clipboard |
| "Напоминай мне пить воду" | Habit nudge: каждые 2 часа |
| "Напоминай размяться" | Каждые 50 минут |
| "Напоминай отдыхать глазам" | 20-20-20 rule, каждые 20 минут |
| "Запусти помодоро" | 25/5 циклы с голосовыми напоминаниями |
| "Запиши настроение 7" / "как прошла неделя" | Mood log + LLM-сводка |
| "Утренний брифинг" / "настрой утренний брифинг на 9:00" | Время+профиль+задачи+память+LLM-мотивашка |
| "Укажи проект C:\..." / "что делает функция X" | Codebase Q&A — LLM по локальной кодобазе |
| "Открытые пиары" / "разбери последний PR" | gh CLI + LLM-ревью |
| "Пауза" / "следующий трек" | Windows media keys (Spotify / YouTube / Foobar) |
| "Диагностика" / "доложи о себе" | Состояние всех бэкендов одной строкой |
Всего паков: **85** (`resources/commands/*/command.toml`) +
плагины пользователя из `%APPDATA%\com.priler.jarvis\plugins\`.
Полный список: GUI → /commands (с поиском и фильтрами).
### Плагины
Голосовые команды, которые лежат **вне** проекта. Каждый плагин —
папка с тем же `command.toml` + Lua-скриптами:
```
%APPDATA%\com.priler.jarvis\plugins\<имя>\
command.toml (тот же формат, что и встроенные паки)
*.lua (скрипты, на которые ссылается toml)
disabled (опционально — пустой файл, отключает плагин)
```
Подгружается при следующем старте `jarvis-app.exe`. Sandbox принудительно
понижается до `standard` — плагин не может попросить `full` (доступ к `os`).
GUI → /plugins показывает список, флажки enable/disable, кнопку «Открыть папку».
### Своё кодовое слово
GUI → /wake-trainer — мастер: запишите 530 примеров своего голоса,
обучите персональную `.rpw`-модель, выберите её в Настройках, перезапустите
ассистент. Файл — `%APPDATA%\com.priler.jarvis\wake_words\<имя>.rpw`.
## Быстрый старт
```powershell
# 1. Клонировать
git clone https://github.com/DmitryBykov-ISPO/J.A.R.V.I.S-rust.git C:\Jarvis\rust
cd C:\Jarvis\rust
# 2. Поставить нейронный TTS (один раз, ~90 МБ)
pwsh tools/piper/install.ps1
# 3a. Облачный LLM — получи токен на https://console.groq.com/keys
echo "GROQ_TOKEN=gsk_..." > dev.env
# 3b. ИЛИ локальный LLM
# Скачай https://ollama.com/download, выполни:
# ollama pull qwen2.5:3b
# (Jarvis сам подхватит, если GROQ_TOKEN отсутствует)
# 4. Сборка
cd frontend && npm install && npm run build && cd ..
cargo build --release -p jarvis-app -p jarvis-gui
# 5. Запуск
target\release\jarvis-app.exe # фоновый демон с микрофоном
target\release\jarvis-gui.exe # GUI для управления
```
Подробнее: см. секцию "Сборка" ниже.
## Конфигурация (env vars)
Все JARVIS_* / GROQ_* / OLLAMA_* переменные документированы в
`crates/jarvis-core/src/runtime_config.rs`. Ключевые:
| Переменная | По умолчанию | Назначение |
|---|---|---|
| `GROQ_TOKEN` | — | Токен для облачного LLM (без него — Ollama / выкл) |
| `GROQ_MODEL` | `llama-3.3-70b-versatile` | Groq-модель |
| `GROQ_VISION_MODEL` | `llama-3.2-11b-vision-preview` | Vision-модель для "что на экране" |
| `OLLAMA_BASE_URL` | `http://localhost:11434/v1` | Адрес Ollama |
| `OLLAMA_MODEL` | `qwen2.5:3b` | Локальная модель (любая через `ollama pull`) |
| `JARVIS_LLM` | auto | `groq` / `ollama` / пусто (auto-detect) |
| `JARVIS_TTS` | auto | `sapi` / `piper` / `silero` / пусто (auto) |
| `JARVIS_TTS_PIPER_BIN` | автопоиск | Путь к piper.exe |
| `JARVIS_TTS_PIPER_VOICE` | автопоиск | Путь к голосу .onnx |
| `JARVIS_LLM_ROUTER` | `1` | Включить agentic router |
| `JARVIS_LLM_ROUTER_THRESHOLD` | `0.55` | Confidence threshold |
| `JARVIS_LLM_TTS` | `true` | Озвучивать LLM-ответ |
Файл `dev.env` подхватывается автоматически (jarvis-app ищет от exe вверх по 5
уровням), так что переменные можно держать в одном месте.
Персистентные настройки (`voice`, `microphone`, `vosk_model`, `llm_backend`,
`tts_backend`, ...) хранятся в `<APP_CONFIG_DIR>/settings.json` и правятся
через GUI Settings или Lua `jarvis.settings.set(key, val)`.
## Сборка
Требования:
- Rust 1.93+ (stable MSVC).
- Node 20+ и npm для фронтенда.
- MSVC build tools (`vcvars64.bat`). VS 2022 / 2026 / 2026 — любая.
- Python 3 (только для `post_build.py`, опционально).
- Vosk/Porcupine/PvRecorder DLL'и в `lib/windows/amd64/` (уже в репо).
Команды:
```powershell
# Один раз: загрузить MSVC окружение
$vc = "C:\Program Files\Microsoft Visual Studio\18\Enterprise\VC\Auxiliary\Build\vcvars64.bat"
cmd /c "`"$vc`" >NUL && set" | % {
if ($_ -match '^([^=]+)=(.*)$') {
[Environment]::SetEnvironmentVariable($matches[1], $matches[2], 'Process')
}
}
# Собрать фронтенд
cd frontend
npm install
npm run build
cd ..
# Workspace
cargo build --release -p jarvis-app -p jarvis-gui
```
Холодная сборка ~10 мин (ONNX runtime, aws-lc-rs, tauri). Инкремент ~30 сек.
`jarvis-gui/build.rs` сам зовёт `npm run build` если фронтенд устарел, так что
для итераций по UI достаточно `cargo build -p jarvis-gui`.
## Тесты
```powershell
cargo test -p jarvis-core --lib # все unit-тесты
cargo test -p jarvis-core --lib scheduler:: # фильтр по модулю
```
Текущее покрытие: **55 unit-тестов**, см. таблицу в ARCHITECTURE.md.
## Структура
```
crates/
jarvis-core library — STT, wake-word, TTS, LLM, intent, commands, IPC,
Lua sandbox, scheduler, memory, profiles, macros, vision
jarvis-app daemon бинарь — микрофон, listening loop, dispatcher, tray, IPC
jarvis-gui Tauri + Svelte GUI — settings, commands list, footer chips
jarvis-cli debug CLI (classify/execute/list)
frontend/ Vite + Svelte UI для jarvis-gui
resources/
commands/ 59 voice-command packs (Lua скрипты + TOML)
models/ ONNX (intent classifier, GLiNER) — Git LFS
vosk/ Vosk модели (small-ru, small-en, ...)
voices/ пресеты звуков реакций (greet/ok/error/...)
tools/
piper/ Piper TTS бинарник + голоса (.onnx) — устанавливаются install.ps1
silero/ Python helper для Silero TTS (опционально)
ARCHITECTURE.md — для контрибьюторов: data flow, рецепты, тесты.
```
## Голосовой workflow
Поднимаешь `jarvis-app.exe`, говоришь:
```
"Джарвис" ─ wake-word
↓ играет "reply" звук
"Какая сегодня погода?"
↓ STT (Vosk) распознаёт
↓ Intent classifier (MiniLM ONNX) пробует найти команду
↓ Levenshtein fallback
↓ Agentic LLM router (если включён)
↓ LLM fallback (chat)
↓ Озвучивает ответ
"Какая температура завтра?"
↓ 30-сек grace window — без повторного wake-word
↓ LLM помнит контекст разговора
```
После каждой команды есть 30-секундное окно для follow-up без повторного
"Джарвис" (`CONVERSATION_GRACE_MS`).
**LLM-клиент (Groq / OpenAI-совместимый) добавлен в `jarvis-core::llm` и подключён к голосовому циклу.** Если фраза начинается с триггера («скажи», «ответь», «произнеси»), она уходит в Groq и ответ возвращается через IPC-событие `LlmReply`. Без триггера всё работает как раньше — wake-word + intent + Lua. Это следующий шаг после CLI-only LLM из v0.2.0.
## Это форк
Оригинальный автор — Abraham Tugalov (Priler).
Апстрим: <https://github.com/Priler/jarvis>.
Лицензия сохранена: **CC BY-NC-SA 4.0** (см. `LICENSE.txt`).
Атрибуция в `Cargo.toml` и `voice.toml` пакетов озвучки не изменена.
В `Cargo.toml` декларирован `license = "GPL-3.0-only"` — это несоответствие
унаследовано от апстрима и не правилось, чтобы не расходиться с upstream-конфигом.
Приоритет имеет `LICENSE.txt`.
## Что отличается от апстрима
## Python-версия
- Обновлён список авторов в `Cargo.toml` (добавлен `Bossiara13 (fork)`, оригинал сохранён).
- README переписан и отражает фактическую архитектуру (апстримный README называет проект "Tauri+Svelte", что давно не соответствует действительности — это workspace из 4-х крейтов).
- Отсутствующие в апстриме ONNX-модели (`all-MiniLM-L6-v2`, `paraphrase-multilingual-MiniLM-L12-v2-onnx-Q`) подтянуты через Git LFS из HuggingFace (Qdrant) и запушены в форк.
Старая версия ассистента была на Python. Последний коммит с Python-кодом в
апстриме — [943efbf](https://github.com/Priler/jarvis/tree/943efbfbdb8aeb5889fa5e2dc7348ca4ea0b81df).
Параллельный Python-форк живёт в отдельном репо
([J.A.R.V.I.S-py](https://github.com/DmitryBykov-ISPO/J.A.R.V.I.S-py)) — там же,
но **17 новых паков из Rust-форка пока туда не зеркалированы** (TODO).
## Структура репозитория
## Поддерживаемость
Cargo workspace из четырёх крейтов:
- `runtime_config.rs` — все env vars в одном файле с doc-комментами.
- `tts/` — trait-based, добавить новый бэкенд = реализовать `TtsBackend`.
- `llm/mod.rs` — shared global client, hot-swap через `swap_to(LlmBackend)`.
- `jarvis.cmd.{ok/error/not_found}` — boilerplate-killer для Lua-паков.
- 55 unit-тестов прикрывают всё, что не требует мика/динамика/сети.
- ARCHITECTURE.md содержит рецепты "как добавить X".
| Крейт | Назначение |
|----------------|----------------------------------------------------------------------------|
| `jarvis-core` | Библиотека: конфиг, intent, STT, wake-word, аудио, Lua-бэкенд, i18n. |
| `jarvis-app` | Бинарь-«демон»: собирает всё вместе, tray, IPC. |
| `jarvis-gui` | Tauri-приложение (использует `frontend/dist/client`). |
| `jarvis-cli` | CLI для отладки: классификация intent, список команд, dump конфига. |
## Что в roadmap (что ещё хочется)
Прочее:
- **faster-whisper STT** рядом с Vosk для длинных LLM-фраз.
- **Реальные Win32 макросы** (keyboard + mouse hooks), не только voice replay.
- **Spotify Web API** через OAuth — "что играет", "сохрани в плейлист".
- **Outlook COM** — голосовая почта / календарь.
- **GUI /macros + /scheduler страницы** — управление без голоса.
- **Python parity** для 17 новых паков.
- `frontend/` — Vite + Svelte UI для `jarvis-gui`. Собирается отдельно.
- `lib/windows/amd64/` — нативные DLL/LIB для Vosk, Porcupine, PvRecorder.
- `resources/` — голоса, модели, конфиги по умолчанию. ONNX-модели хранятся в Git LFS.
- `post_build.py` — постпроцессинг артефактов сборки (Python 3).
См. `~/.claude/projects/C--Jarvis/memory/project_jarvis_roadmap.md` (если ты
— Claude помогаешь автору) или открой issue.
## Сборка
Требования:
- Rust 1.93+ (собирается на stable MSVC).
- Node 24+ и npm — для фронтенда.
- Python 3 — для `post_build.py`.
- MSVC build tools (Windows, x64).
- Установленные `libvosk.lib`, `libpv_porcupine.dll`, `libpv_recorder.dll` в `lib/windows/amd64/` (уже в репозитории).
Перед сборкой `jarvis-gui` нужно собрать фронтенд:
```bash
cd frontend
npm install
npm run build
cd ..
```
Затем workspace:
```bash
cargo build --workspace
```
Холодная сборка занимает около 10 минут (ONNX runtime, aws-lc-rs, tauri).
## Статус сборки в этом форке
На моей машине (`cargo build --workspace`, stable MSVC) итог:
- `jarvis-core` — собрался (1 warning, unused import).
- `jarvis-app` — собрался, бинарник `target/debug/jarvis-app.exe` создан.
- `jarvis-cli`**падает на линковке**: `LNK1181: cannot open input file "libvosk.lib"`.
Причина: у `jarvis-cli` нет своего `build.rs`, а `.cargo/config.toml` с `rustc-link-search` лежит только внутри `crates/jarvis-app/` и не подтягивается для `jarvis-cli`. Лечится либо добавлением такого же `build.rs` в `crates/jarvis-cli/`, либо вынесением `config.toml` в корень. Сознательно не трогал — фикс выходит за рамки рефакторинга (v0.0.1-import фиксирует поведение апстрима как есть).
- `jarvis-gui` — падает в `tauri::generate_context!()`: `frontendDist = "../../frontend/dist/client"` не существует. Это ожидаемо, если не запустить `npm run build` в `frontend/` заранее (см. секцию «Сборка»).
Запуск уже собранного:
```bash
./target/debug/jarvis-app.exe
```
Для CLI (`jarvis-cli --help`, команды `classify`, `execute`, `list`, `phrases`) нужно сначала починить линковку Vosk (см. выше).
## LLM (Groq)
В `jarvis-core` есть модуль `llm` — блокирующий клиент для OpenAI-совместимого эндпоинта chat completions. По умолчанию настроен на Groq. Используется через фиче-флаг `llm` (включён в дефолтный набор `jarvis_app`, также подтянут в `jarvis-cli`).
Переменные окружения:
| Переменная | Обязательна | Значение по умолчанию |
|-----------------|-------------|----------------------------------------|
| `GROQ_TOKEN` | да | — |
| `GROQ_BASE_URL` | нет | `https://api.groq.com/openai/v1` |
| `GROQ_MODEL` | нет | `llama-3.3-70b-versatile` |
Быстрая проверка через CLI:
```bash
set GROQ_TOKEN=gsk_...
jarvis-cli ask "скажи привет одной фразой"
```
Ответ печатается в stdout. Без `GROQ_TOKEN` команда завершится с кодом 2 и сообщением об ошибке. При ошибке API — код 1 и тело ответа.
Программное использование из Rust:
```rust
use jarvis_core::llm::{LlmClient, ChatMessage};
let client = LlmClient::from_env()?;
let reply = client.complete(&[ChatMessage::user("привет")], 256)?;
println!("{}", reply);
```
### Подключение к голосовому циклу
Помимо CLI, LLM подключён напрямую в `jarvis-app`. Логика в `crates/jarvis-app/src/llm_fallback.rs`:
- При старте `jarvis-app` пытается прочитать `GROQ_TOKEN`. Если переменной нет — фоллбэк отключается, в лог пишется warning, голосовые команды продолжают работать как раньше.
- Распознанная фраза (как из микрофона, так и из текстовой панели GUI) проверяется на префиксы-триггеры из `config::get_llm_trigger_phrases` (для `ru`/`ua`: `скажи`, `ответь`, `произнеси`; для `en`: `say`, `tell`, `answer`).
- Если триггер найден — остаток фразы уходит в `LlmClient::complete()`, ответ публикуется в IPC как `IpcEvent::LlmReply { text }` (UI/GUI слушает этот ивент и проговаривает текст уже на своей стороне), а звук-«ок» проигрывается из текущего голосового пресета.
- История разговора хранится в `ConversationHistory` с потолком `LLM_DEFAULT_MAX_HISTORY = 8` ходов; system-prompt всегда сохраняется при вытеснении старых ходов.
- При сетевой/API-ошибке последний user-turn убирается из истории, в IPC уходит `LlmReply` с короткой русской фразой («Не могу связаться с сервером, сэр.»), играется звук-«error». Голосовой цикл не падает.
Системный промпт (русский) описывает J.A.R.V.I.S. как британского дворецкого Тони Старка — короткие реплики (13 предложения), обращение «сэр», без излишней цензуры. Меняется в `config::LLM_SYSTEM_PROMPT_RU`.
## VAD: умное завершение команды по тишине
После срабатывания wake-word `jarvis-app` слушает команду. Раньше окно закрывалось либо по таймауту Vosk, либо по жёсткому пределу из `config::CMS_WAIT_DELAY` — короткие команды («стоп», «громче») всё равно ждали несколько секунд тишины.
Теперь окно закрывается, как только пользователь замолчал. Реализовано через `webrtc-vad` (чистый Rust, тот же алгоритм, что в Python-версии). Это **паритет с Python v0.2.0**, где использовался `webrtcvad` с теми же параметрами.
Логика (state-машина в `crates/jarvis-core/src/audio_processing/vad/listen_window.rs`):
- Кадр VAD: 30 мс при 16 кГц = 480 сэмплов = 960 байт (mono i16). Кадры с микрофона (по 512 сэмплов от `pv_recorder`) аккумулируются и режутся на VAD-кадры адаптером `WebRtcVad::push_samples`.
- Каждый VAD-кадр учитывается в счётчиках `speech_ms` / `silence_ms`.
- Окно закрывается, когда `silence_ms >= VAD_COMMAND_END_SILENCE_MS` И `speech_ms >= VAD_COMMAND_MIN_SPEECH_MS`.
- До истечения `VAD_COMMAND_MIN_LISTEN_MS` окно не закрывается — пользователю даётся время начать говорить.
- При достижении `VAD_COMMAND_MAX_LISTEN_MS` срабатывает hard-cap и управление возвращается в режим ожидания wake-word.
- На событии «закрыть окно» вызывается `stt::finalize_speech()` — Vosk форсированно отдаёт финальный результат, не дожидаясь собственного таймаута.
Параметры (`crates/jarvis-core/src/config.rs`):
| Константа | Значение | Назначение |
|---------------------------------|----------|-------------------------------------------------------------|
| `VAD_AGGRESSIVENESS` | `2` | Уровень WebRTC VAD: 0 — Quality, 3 — VeryAggressive. |
| `VAD_COMMAND_END_SILENCE_MS` | `1200` | Сколько тишины подряд считать «команда закончилась». |
| `VAD_COMMAND_MIN_SPEECH_MS` | `500` | Минимум речи в окне — иначе закрытие игнорируется. |
| `VAD_COMMAND_MIN_LISTEN_MS` | `1000` | Минимальная длительность окна (страховка от ранних закрытий)|
| `VAD_COMMAND_MAX_LISTEN_MS` | `15000` | Жёсткий потолок окна. |
На короткие команды отзыв стал заметно быстрее (мы выходим на распознавание сразу после паузы пользователя, а не по 5-секундному порогу Vosk).
## Лицензия
CC BY-NC-SA 4.0 — Creative Commons **Attribution-NonCommercial-ShareAlike**.
Полный текст: `LICENSE.txt`. Атрибуция Abraham Tugalov сохранена.
Creative Commons **Attribution-NonCommercial-ShareAlike 4.0 International** (CC BY-NC-SA 4.0).
Полный текст — в `LICENSE.txt`. Атрибуция оригинального автора (Abraham Tugalov) сохранена.
В `Cargo.toml` декларирован `license = "GPL-3.0-only"` — это несоответствие унаследовано от апстрима и не правилось, чтобы не расходиться с upstream-конфигом. Приоритет имеет `LICENSE.txt`.
## Python-версия
Старая версия ассистента была на Python.
Последний коммит с Python-кодом в апстриме — [943efbf](https://github.com/Priler/jarvis/tree/943efbfbdb8aeb5889fa5e2dc7348ca4ea0b81df).

307
USAGE.md
View file

@ -1,307 +0,0 @@
# Voice command reference
Все голосовые команды J.A.R.V.I.S. (текущее состояние, **98 паков**).
Каждая команда — это фраза, которую можно произнести после wake-word ("Джарвис").
> Языки: **RU + EN** (украинский поддерживался до 2026-05-16, в текущей версии удалён).
Группы:
- [Управление ассистентом](#управление-ассистентом)
- [LLM / мозг](#llm--мозг)
- [Память / профили / macros](#память--профили--macros)
- [Расписание / напоминания / привычки](#расписание--напоминания--привычки)
- [Окна / приложения / процессы](#окна--приложения--процессы)
- [Аудио / медиа](#аудио--медиа)
- [Система / устройство](#система--устройство)
- [Файлы / буфер / заметки](#файлы--буфер--заметки)
- [Информация из интернета](#информация-из-интернета)
- [Утилиты-калькуляторы](#утилиты-калькуляторы)
- [Разработка](#разработка)
- [Развлечения](#развлечения)
- [Скриншоты / экран](#скриншоты--экран)
После каждой команды есть **30-секундное grace-окно** для follow-up без повторного "Джарвис".
Конфигурируется через `CONVERSATION_GRACE_MS`.
---
## Управление ассистентом
| Команда (RU) | Что делает |
|---|---|
| "Помощь" / "что ты умеешь" | Перечисляет категории возможностей |
| "Расскажи о себе" / "кто ты такой" | Краткое представление (LLM варьирует) |
| "Сколько команд знаешь" | Health snapshot — все активные бэкенды + счётчики |
| "Диагностика" / "доложи о себе" | Одной строкой: TTS, LLM, профиль, памяти/задач |
| "Повтори за мной X" | Эхо — для теста микрофона + TTS |
| "Что я сказал" | Что услышал из последней фразы |
| "Стоп" / "хватит" / "прекрати" | Прерывает текущую команду |
| "Сбрось контекст" / "забудь разговор" | Очищает историю LLM |
| "Повтори последнее" / "повтори ответ" | Озвучивает последний ответ LLM |
## LLM / мозг
| Команда | Что делает |
|---|---|
| "Переключись на локальный" / "перейди на оллама" | Hot-swap на Ollama (persistent в БД) |
| "Переключись на облако" / "используй грок" | Hot-swap на Groq |
| "Какой у тебя мозг" / "облако или локально" | Текущий backend |
Любая фраза без распознанной команды → LLM router (если включён) → если нет матча → LLM chat fallback.
## Память / профили / macros
| Команда | Что делает |
|---|---|
| "Запомни что я люблю чай улун" | Сохраняет факт |
| "Что ты помнишь про чай" | Substring-поиск по памяти |
| "Что ты знаешь обо мне" | Все факты |
| "Забудь про X" | Удалить факт |
| "Режим работа" / "режим игра" / "режим сон" / "режим за рулём" / "обычный режим" | Профиль (5 встроенных) |
| "Какой сейчас режим" | Активный профиль |
| "Запиши макрос имя" | Начать запись макроса |
| "Сохрани макрос" | Завершить запись и сохранить |
| "Отмени макрос" | Отменить запись |
| "Запусти макрос имя" | Воспроизвести (с интервалом 800мс) |
| "Какие у меня макросы" / "удали макрос имя" | Управление списком |
Также: GUI страницы `/memory` (добавить/забыть факты), `/macros` (запуск/удаление), `/scheduler` (отмена задач).
## Расписание / напоминания / привычки
| Команда | Что делает |
|---|---|
| "Напомни через 5 минут выключить кофеварку" | One-shot reminder |
| "Напомни в 18:00 забрать ребёнка" | Today at HH:MM |
| "Каждые 2 часа напоминай попить воды" | Interval |
| "Каждый день в 9:00 делай брифинг" | Daily |
| "Что у меня запланировано" / "очисти расписание" | Управление |
| "Отмени напоминание про воду" | Substring-match remove |
| "Запусти помодоро" / "стоп помодоро" | 25/5 циклы |
| "Напоминай мне пить воду" | Каждые 2 часа |
| "Напоминай размяться" | Каждые 50 минут |
| "Напоминай отдыхать глазам" | 20-20-20 (каждые 20 мин) |
| "Отключи все привычки" | Стоп всех habit_nudge задач |
| "Настрой утренний брифинг на 9:00" | Daily briefing в указанное время |
| "Утренний брифинг" | Запустить брифинг сейчас |
| "Отключи утренний брифинг" | Снять daily задачу |
## Окна / приложения / процессы
| Команда | Что делает |
|---|---|
| "Открой Telegram" / "запусти Steam" / "Spotify" | Через `apps/` пак (имя в config) |
| "Закрой Telegram" / "убей процесс X" | Process kill |
| "Сверни всё" / "разверни окно" / "переключи окно" | Window manager |
| "Какие открыты окна" | Список Z-order |
| "Сверни активное" / "максимизируй" | Action на foreground |
| "Открой папку загрузок" / "файлы" / "проводник" | Explorer |
## Аудио / медиа
| Команда | Что делает |
|---|---|
| "Громче" / "тише" / "громкость на 30" | Volume up/down/set |
| "Заглуши" / "сними заглушку" | Mute toggle |
| "Какая громкость" | Текущий уровень |
| "Открой панель звука" | Sound panel |
| "Включи Realtek" / "переключи звук на Headphones" | Output device swap |
| "Пауза" / "плей" / "следующий трек" / "предыдущий трек" / "стоп музыка" | Media keys — Spotify / YouTube / Foobar / Yandex Music |
| "Запиши голосовую заметку" | (см. notes/) |
| "Выключи музыку через 30 минут" | Sleep timer для медиа |
## Система / устройство
| Команда | Что делает |
|---|---|
| "Сколько процессор грузится" / "сколько RAM" / "температура" | Sysinfo |
| "Сколько заряда" / "заряд батареи" | Battery % |
| "Сколько свободно на диске" / "сколько на диске C" | PowerShell Get-PSDrive |
| "Какие у меня диски" | C: D: E: со свободным местом |
| "Какая сеть" / "какой WiFi" | Net SSID via netsh |
| "Мой IP" / "локальный IP" | Get-NetIPAddress |
| "Внешний IP" | api.ipify.org |
| "Прибавь яркость" / "тусклее" / "яркость 70" | Brightness via WMI |
| "Тёмная тема" / "светлая тема" | Windows theme |
| "Заблокируй компьютер" | LockWorkStation |
| "Выключи компьютер" / "перезагрузи" / "ребутни" / "сон" / "гибернация" / "выйди из системы" / "отмени выключение" | Power management |
| "Выключи компьютер через 30 минут" | Sleep timer для системы |
| "Отмени таймер выключения" | shutdown /a + scheduler clear |
## Файлы / буфер / заметки
| Команда | Что делает |
|---|---|
| "Найди файл report" / "поищи documents" | File search |
| "Запиши заметку купить хлеб" | Append в notes file |
| "Покажи заметки" / "что я записал" | Открывает notes |
| "Прочитай буфер" / "что в буфере" | Clipboard contents speak |
| "Переведи буфер на английский" | LLM перевод + replace clipboard |
## Информация из интернета
| Команда | Что делает |
|---|---|
| "Какая погода" / "погода завтра" / "что с погодой" | Weather pack |
| "Курс доллара" / "курс юаня" | CBR rates |
| "Сколько 1000 долларов в рублях" | Currency convert |
| "Сколько Сбер" / "сколько Газпром" | MOEX котировки (22 тикера) |
| "Сколько биткоин" / "сколько эфир" | Crypto |
| "Новости" / "что нового в мире" | News RSS |
| "Найди в гугле X" / "загугли X" | DuckDuckGo Instant + LLM |
| "Википедия X" / "что такое X" / "кто такой Y" | Wikipedia REST |
| "Открыть Google поиск X" | Websearch (open browser) |
| "Переведи на английский X" | Translate |
## Утилиты-калькуляторы
| Команда | Что делает |
|---|---|
| "Посчитай 2 плюс 2" / "сколько будет 10 в степени 3" | Math |
| "Переведи 100 метров в футы" | Length convert |
| "70 кг в фунты" | Weight convert |
| "100 цельсий в фаренгейт" | Temperature convert |
| "100 км в час в мили в час" | Speed convert |
| "Сколько дней до нового года" | Date math (8 марта, 9 мая, Рождество, Новый год + любая дата) |
| "Какой сегодня день недели" | Zeller's congruence |
| "Время" / "сколько времени" / "какое сегодня число" | Date query |
| "Подбрось монету" / "орёл или решка" | Coin flip |
| "Сгенерируй пароль 16 символов" | Secure password → clipboard |
| "Сгенерируй UUID" | v4 UUID → clipboard |
| "Брось кубик" / "брось два кубика" | Dice |
| "Выбери случайно из X или Y или Z" | Random choice |
| "Запусти секундомер" / "сколько прошло" / "стоп секундомер" | Stopwatch |
| "Прибавь счётчик" / "сколько на счётчике" / "сбрось счётчик" | Counter |
| "Как пишется X" | Spelling |
## Разработка
| Команда | Что делает |
|---|---|
| "Укажи проект C:\..." | Codebase Q&A: установить root |
| "Какой проект сейчас" | Текущий root |
| "Что делает функция X" / "найди в коде Y" / "объясни код" | Walks codebase → LLM |
| "Текущий репо owner/repo" | GitHub: установить repo |
| "Какие пиары" / "открытые пиары" | List PRs via gh CLI |
| "Разбери последний пиар" | LLM-ревью PR (gh pr view) |
| "Сгенерируй код для X" | Code generation pack |
| "Открой Cursor" / "VS Code" | apps/ |
## Развлечения
| Команда | Что делает |
|---|---|
| "Расскажи анекдот" / "пошути" / "скажи фразу" / "тостер" | Fun pack (jokes) |
| "Запиши настроение 7" / "сегодня мне грустно" | Mood log |
| "Как прошла неделя" / "сводка настроения" | Mood recap via LLM |
| "Запусти Steam игру X" / "поиграй в Y" | Games pack |
## Скриншоты / экран
| Команда | Что делает |
|---|---|
| "Что на экране" / "опиши экран" | Vision LLM описание |
| "Прочитай ошибку" | Vision LLM: ищет stack trace, объясняет |
| "Сделай скриншот" / "скрин в буфер" | Screenshot → clipboard |
| "Сохрани скриншот" | Screenshot → ~/Pictures/Screenshots/ |
| "OCR экрана" / "прочитай текст" | OCR (existing pack) |
| "Суммаризируй экран" | Summarize visible window |
---
## Wave 4-8 (новое — личность, рутины, режимы)
| Команда | Что делает |
|---|---|
| **Личность / banter** | |
| "Привет Джарвис" / "Доброе утро" | Время-чувствительные ответы из ~30 вариантов |
| "Спасибо" / "Ты молодец" | Варьирующиеся «батлерские» ответы |
| "Как дела Джарвис" | Включает живые цифры из памяти + профиля |
| "Процитируй Тони" | Случайная цитата Stark/JARVIS (15+ вариантов) |
| "Скажи что-нибудь интересное" / "Скучно мне" | Принудительная idle-banter реплика |
| "Помолчи" / "Можешь говорить" | Pause/resume фонового banter |
| **Память / разговор** | |
| "О чём мы говорили" | LLM-сжатие последних ~12 реплик |
| "Повтори" / "Не расслышал" | Перепроизнести последний ответ |
| "Сколько фактов помнишь" | Размер долговременной памяти |
| "Покажи что помнишь" | Список ключей в памяти |
| "Точно забудь всё" | Полное стирание памяти (с защитой) |
| **Режимы и рутины** | |
| "Режим фокуса" / "Focus mode" | Профиль work + Focus Assist + стретч через 50 мин |
| "Выключи фокус" | Откат |
| "Спокойной ночи" | Профиль sleep + отмена одноразовых таймеров |
| "Доброе утро" | Профиль default + обзор расписания |
| "Иду за кофе" / "Coffee break" | Пауза banter + чек-ин через 5 минут |
| **Утилиты / новые** | |
| "Сколько будет 234 на 567" | Offline-парсер (без LLM, instant) |
| "Что такое квантовая запутанность" | DDG Instant Answer (без ключа) |
| "Переведи буфер на немецкий" | Translate clipboard с озвучкой в немецком SAPI voice |
| "Поставь чайник" / "Варю пасту" | Cooking timer с пресетами на 16+ блюд |
| "На чём я остановился" | Recap из памяти + расписания + последнего LLM-ответа |
| "Расскажи про железного человека" | Случайный MCU-факт |
| "Разбуди сервер" / "Wake on LAN" | Magic packet на MAC из памяти (`wol_server`) |
| **Outlook (требует Outlook)** | |
| "Сколько у меня непрочитанных писем" | Outlook COM → счётчик inbox |
| "Прочитай последнее письмо" | From + Subject + 200 chars body |
| "Отправь письмо [Имя]" | Body = clipboard, через GAL lookup |
| "Коротко расскажи про инбокс" | LLM-сводка последних 5 unread |
| **Time tracker** | |
| "Начни отсчёт" / "Закончи отсчёт" | Сессия учёта рабочего времени |
| "Сколько я работаю сегодня" | Сумма открытых + закрытых сессий за календарный день |
| "Сколько на этой неделе" | Сумма за последние 7 дней |
| "Сбрось трекер" | Стереть сегодняшние записи |
| **Кастомное wake-word** | |
| GUI → /wake-trainer | Мастер: запись 530 сэмплов → rustpotter .rpw |
| **Плагины** | |
| GUI → /plugins | Toggle/Open Folder для `%APPDATA%/com.priler.jarvis/plugins/` |
## Как добавить свою команду
Скопируй `resources/commands/echo/` как шаблон, отредактируй `command.toml`
(id, phrases) и `*.lua` (логика). Рекомендуемая структура — см.
[ARCHITECTURE.md → Recommended pack structure](ARCHITECTURE.md#recommended-pack-structure).
Минимум:
```lua
-- thing.lua
local phrase = (jarvis.context.phrase or ""):lower()
local body = jarvis.text.strip_trigger(phrase, { "сделай штуку" })
body = body:gsub("^[%s,:%.]+", ""):gsub("%s+$", "")
if body == "" then
return jarvis.cmd.error("Что именно?")
end
-- ... do work ...
return jarvis.cmd.ok("Готово.")
```
Затем `cargo test -p jarvis-core --lib commands::tests` — он автоматически
проверит что TOML парсится и Lua-скрипт существует.
## Голосовое vs GUI
GUI страницы — для случаев когда голосом неудобно:
- `/commands` — все 67 паков с поиском (для discovery)
- `/macros` — список с кнопкой "Запустить"
- `/scheduler` — задачи с auto-poll каждые 5 сек
- `/memory` — добавление/удаление фактов с фильтром
- `/settings → AI Backends` — переключение LLM/TTS через dropdown
## Что-то не работает?
1. **Голос не распознаётся** — проверь микрофон в Settings → Devices. Скажи
"повтори за мной привет" — если эхо проходит, проблема не в STT.
2. **Не слышу ответ** — проверь TTS backend в Settings → AI Backends. Скажи
"проверка эхо" — должен быть слышен голос. Если нет, проверь динамики +
попробуй `JARVIS_TTS=sapi`.
3. **"Не могу связаться с сервером"** — LLM unreachable. Если Groq —
проверь GROQ_TOKEN и интернет. Если Ollama — `ollama serve` запущен?
4. **Команда не найдена** — посмотри `/commands` в GUI, поищи фразу. Если
фразы похожи но не та — agentic router сделает попытку, или подскажет.
5. **Что-то странное в логах**`<APP_LOG_DIR>/jarvis.log` (путь видно
в первых строках `jarvis-app` stdout).

View file

@ -11,15 +11,12 @@ jarvis-core = { path = "../jarvis-core", features = ["intent"] }
once_cell.workspace = true
log.workspace = true
simple-log = "2.4"
dotenvy.workspace = true
tray-icon = "0.21"
winit = "0.30"
image.workspace = true
platform-dirs.workspace = true
rand.workspace = true
parking_lot.workspace = true
serde.workspace = true
serde_json.workspace = true
tokio = { version = "1", features = ["rt-multi-thread"] }
@ -29,11 +26,7 @@ features = []
[target.'cfg(target_os = "windows")'.dependencies]
winapi = { version = "0.3", features = ["winuser"] }
winrt-notification.workspace = true
[target.'cfg(target_os = "linux")'.dependencies]
gtk = "0.18"
glib = "0.21.5"
[target.'cfg(target_os = "windows")'.build-dependencies]
embed-resource = "3"

View file

@ -1,60 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
<assemblyIdentity
type="win32"
name="J.A.R.V.I.S.app"
version="0.1.0.0"
processorArchitecture="*"
/>
<description>J.A.R.V.I.S. voice assistant daemon</description>
<!--
Declare a dependency on Common Controls v6 so transitive Win32 imports
like TaskDialogIndirect (used by tray-icon / winit dialog fallbacks)
can find their entry points in comctl32.dll. Without this, Windows
loads the legacy v5 comctl32 and the exe fails to start with
"TaskDialogIndirect entry point not found".
-->
<dependency>
<dependentAssembly>
<assemblyIdentity
type="win32"
name="Microsoft.Windows.Common-Controls"
version="6.0.0.0"
processorArchitecture="*"
publicKeyToken="6595b64144ccf1df"
language="*"
/>
</dependentAssembly>
</dependency>
<!-- DPI / Windows version compatibility -->
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- Windows 10 / 11 -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
<!-- Windows 8.1 -->
<supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}"/>
<!-- Windows 8 -->
<supportedOS Id="{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}"/>
<!-- Windows 7 -->
<supportedOS Id="{35138b9a-5d96-4fbd-8e2d-a2440225f93a}"/>
</application>
</compatibility>
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2,PerMonitor</dpiAwareness>
<activeCodePage xmlns="http://schemas.microsoft.com/SMI/2019/WindowsSettings">UTF-8</activeCodePage>
</windowsSettings>
</application>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security>
<requestedPrivileges>
<requestedExecutionLevel level="asInvoker" uiAccess="false"/>
</requestedPrivileges>
</security>
</trustInfo>
</assembly>

View file

@ -1,4 +0,0 @@
#define RT_MANIFEST 24
#define CREATEPROCESS_MANIFEST_RESOURCE_ID 1
CREATEPROCESS_MANIFEST_RESOURCE_ID RT_MANIFEST "app.manifest"

View file

@ -1,18 +1,10 @@
fn main() {
// link to Vosk lib
// println!("cargo:rustc-link-lib=libvosk.dll");
let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap();
let lib_path = std::path::Path::new(&manifest_dir)
.join("..\\..\\lib\\windows\\amd64");
println!("cargo:rustc-link-search=native={}", lib_path.display());
// Embed a Common-Controls v6 manifest on Windows so transitive imports
// pulled in by tray-icon / winit (TaskDialogIndirect, themed controls,
// per-monitor DPI) resolve against comctl32 v6. Without this the exe
// crashes at load with "TaskDialogIndirect entry point not found".
#[cfg(target_os = "windows")]
{
println!("cargo:rerun-if-changed=app.manifest");
println!("cargo:rerun-if-changed=app.manifest.rc");
let _ = embed_resource::compile("app.manifest.rc", embed_resource::NONE);
}
}

View file

@ -2,6 +2,7 @@ use std::sync::mpsc::Receiver;
use std::time::SystemTime;
use jarvis_core::{audio_buffer::AudioRingBuffer, audio_processing, audio_processing::vad::listen_window::{ListenWindow, WindowDecision}, audio_processing::vad::webrtc::WebRtcVad, commands, config, listener, recorder, stt, COMMANDS_LIST, intent, voices, ipc::{self, IpcEvent}, i18n, slots};
use rand::seq::SliceRandom;
use crate::should_stop;
@ -322,45 +323,21 @@ fn recognize_command(
// execute command and check if we should chain
let should_chain = execute_command(&recognized_voice, rt);
let grace_ms = jarvis_core::config::CONVERSATION_GRACE_MS;
if should_chain || grace_ms > 0 {
// Either explicit chain OR P0.2 continuous-conversation grace window.
// Reset listening state and keep going without re-wake.
if should_chain {
info!("Chain requested, continuing to listen...");
} else {
info!(
"Continuous conversation: {}ms grace window for follow-up.",
grace_ms
);
}
// chain: reset and continue listening
info!("Chaining enabled, continuing to listen...");
stt::reset_speech_recognizer();
vad_state = VadState::WaitingForVoice;
silence_frames = 0;
// For grace-mode, shorten the deadline to grace_ms (instead of CMS_WAIT_DELAY).
// We set `start` such that the existing timeout check (line ~360) fires at
// start + CMS_WAIT_DELAY = now + grace_ms.
if !should_chain {
let cms = jarvis_core::config::CMS_WAIT_DELAY.as_millis() as u64;
if grace_ms < cms {
// back-date `start` so the existing timeout fires at now + grace_ms
let backdate_ms = cms - grace_ms;
start = SystemTime::now()
.checked_sub(std::time::Duration::from_millis(backdate_ms))
.unwrap_or_else(SystemTime::now);
} else {
start = SystemTime::now();
}
} else {
start = SystemTime::now();
}
audio_buffer.clear();
webrtc_vad.reset();
window = ListenWindow::new(jarvis_core::audio_processing::vad::webrtc::FRAME_MS);
ipc::send(IpcEvent::Listening);
continue;
} else {
info!("Conversation grace disabled, returning to wake word mode.");
// no chain: return to wake word
info!("No chain, returning to wake word mode.");
return;
}
}
@ -425,33 +402,23 @@ fn process_text_command(text: &str, rt: &tokio::runtime::Runtime) {
// Execute command, returns true if chaining should continue
fn execute_command(text: &str, rt: &tokio::runtime::Runtime) -> bool {
use jarvis_core::recognition_log::{record as log_record, Outcome};
let commands_list = match COMMANDS_LIST.get() {
Some(c) => c,
None => {
log_record(text, "voice", Outcome::Error { message: "Commands not loaded".into() });
ipc::send(IpcEvent::Error { message: "Commands not loaded".to_string() });
ipc::send(IpcEvent::Idle);
return false;
}
};
// Try intent classifier first; remember which path matched so the log
// can show "via intent" vs "via fuzzy" — useful for understanding why
// a particular phrase landed (or didn't).
let (cmd_result, via, confidence_pct) = if let Some((intent_id, confidence)) =
let cmd_result = if let Some((intent_id, confidence)) =
rt.block_on(intent::classify(text))
{
info!("Intent recognized: {} (confidence: {:.2})", intent_id, confidence);
(
intent::get_command_by_intent(commands_list, &intent_id),
"intent",
Some((confidence * 100.0).clamp(0.0, 100.0) as u8),
)
intent::get_command_by_intent(commands_list, &intent_id)
} else {
info!("Intent not recognized, trying levenshtein fallback...");
(commands::fetch_command(text, commands_list), "fuzzy", None)
commands::fetch_command(text, commands_list)
};
if let Some((cmd_path, cmd_config)) = cmd_result {
@ -473,18 +440,6 @@ fn execute_command(text: &str, rt: &tokio::runtime::Runtime) -> bool {
info!("Command executed successfully");
// voices::play_ok();
voices::play_random_from(cmd_config.get_sounds(&i18n::get_language()).as_slice());
// IMBA-7: if macro recording is active, capture this phrase.
// Skipped for macro-control commands themselves (filter inside record_step).
jarvis_core::macros::record_step(text);
log_record(text, "voice", Outcome::Matched {
command_id: cmd_config.id.clone(),
confidence_pct,
via: via.to_string(),
success: true,
});
ipc::send(IpcEvent::CommandExecuted {
id: cmd_config.id.clone(),
success: true,
@ -495,12 +450,6 @@ fn execute_command(text: &str, rt: &tokio::runtime::Runtime) -> bool {
Err(msg) => {
error!("Error executing command: {}", msg);
voices::play_error();
log_record(text, "voice", Outcome::Matched {
command_id: cmd_config.id.clone(),
confidence_pct,
via: via.to_string(),
success: false,
});
ipc::send(IpcEvent::CommandExecuted {
id: cmd_config.id.clone(),
success: false,
@ -510,58 +459,19 @@ fn execute_command(text: &str, rt: &tokio::runtime::Runtime) -> bool {
}
} else {
info!("No command found for: {}", text);
// IMBA-1: Agentic LLM router — try to map unknown phrase to a known command
// by asking the LLM. This is the killer feature competitors don't have.
if crate::llm_router::is_enabled() {
if let Some(routed) = crate::llm_router::try_route(text) {
info!(
"Router → {} ({}%): {} | substitute='{}'",
routed.command_id,
(routed.confidence * 100.0) as u32,
routed.reason,
routed.substitute_phrase
);
// Re-dispatch with the canonical phrase for the chosen command.
// Guard against infinite recursion: pass a marker if needed.
if routed.substitute_phrase != text {
log_record(text, "voice", Outcome::Matched {
command_id: routed.command_id.clone(),
confidence_pct: Some((routed.confidence * 100.0).clamp(0.0, 100.0) as u8),
via: "router".to_string(),
// Actual success/failure logged by the recursive call.
success: true,
});
return execute_command(&routed.substitute_phrase, rt);
}
}
}
if jarvis_core::config::LLM_AUTO_FALLBACK
&& crate::llm_fallback::is_enabled()
&& text.chars().count() >= jarvis_core::config::LLM_AUTO_FALLBACK_MIN_CHARS
{
info!("Auto-routing to LLM (no command match): {}", text);
crate::llm_fallback::handle(text);
log_record(text, "voice", Outcome::LlmHandled);
} else {
voices::play_not_found();
log_record(text, "voice", Outcome::NotFound);
ipc::send(IpcEvent::Error {
message: format!("Command not found: {}", text)
});
}
}
ipc::send(IpcEvent::Idle);
false // no chain on error or not found
}
pub fn close(code: i32, label: &'static str) {
eprintln!("[jarvis-app] app::close called: code={} label={}", code, label);
error!("Closing application: code={} label={}", code, label);
pub fn close(code: i32) {
info!("Closing application.");
voices::play_goodbye();
ipc::send(IpcEvent::Stopping);
std::process::exit(code);

View file

@ -1,19 +1,15 @@
use once_cell::sync::OnceCell;
use parking_lot::Mutex;
use jarvis_core::config;
use jarvis_core::i18n;
use jarvis_core::ipc::{self, IpcEvent};
use jarvis_core::llm::{self, ChatMessage};
use jarvis_core::long_term_memory;
use jarvis_core::profiles;
use jarvis_core::tts::{self, SpeakOpts};
use jarvis_core::llm::{ChatMessage, ConversationHistory, LlmClient};
use jarvis_core::voices;
// State now only holds the max_tokens config — both the LLM client AND the
// conversation history live in `jarvis_core::llm::*` globals. This lets voice
// commands ("сбрось контекст", "повтори последнее") and Lua scripts share the
// same buffer instead of each module owning its own.
struct State {
client: LlmClient,
history: Mutex<ConversationHistory>,
max_tokens: u32,
}
@ -29,18 +25,22 @@ fn build_state() -> Option<State> {
return None;
}
// Initialise the shared global client (idempotent — fine if init was already called).
if let Err(e) = llm::init_global() {
warn!("LLM fallback disabled: {}. Set GROQ_TOKEN or run Ollama to enable.", e);
let client = match LlmClient::from_env() {
Ok(c) => c,
Err(e) => {
warn!("LLM fallback disabled: {}. Set GROQ_TOKEN to enable.", e);
return None;
}
};
let lang = i18n::get_language();
let prompt = config::get_llm_system_prompt(&lang);
llm::init_history(prompt, config::LLM_DEFAULT_MAX_HISTORY);
let history = Mutex::new(ConversationHistory::new(prompt, config::LLM_DEFAULT_MAX_HISTORY));
info!("LLM fallback enabled (backend: {}).", llm::current_backend_name());
info!("LLM fallback enabled (model: {}).", client.model());
Some(State {
client,
history,
max_tokens: config::LLM_DEFAULT_MAX_TOKENS,
})
}
@ -87,58 +87,27 @@ pub fn handle(prompt: &str) {
info!("LLM prompt: {}", prompt);
llm::history_push_user(prompt);
let mut snapshot: Vec<ChatMessage> = llm::history_snapshot();
// Inject (a) profile personality (b) relevant long-term memory as a fresh
// system message right after the base prompt. Both are optional.
let profile = profiles::active();
let mut overlay = String::new();
if !profile.llm_personality.is_empty() {
overlay.push_str(&format!("Активный профиль: {} {}\nХарактер для ответа: {}\n",
profile.icon, profile.name, profile.llm_personality));
}
let mem_ctx = long_term_memory::build_context(prompt, 5);
if !mem_ctx.is_empty() {
overlay.push_str(&mem_ctx);
}
if !overlay.is_empty() {
// Insert after the base system prompt (index 0 typically), before user msgs.
let insert_at = if !snapshot.is_empty() && snapshot[0].role == "system" { 1 } else { 0 };
snapshot.insert(insert_at, ChatMessage::system(overlay));
}
let client = match llm::current() {
Some(c) => c,
None => {
warn!("LLM call but global client missing — ignoring.");
return;
}
let snapshot: Vec<ChatMessage> = {
let mut h = state.history.lock();
h.push_user(prompt);
h.snapshot()
};
match client.complete(&snapshot, state.max_tokens) {
match state.client.complete(&snapshot, state.max_tokens) {
Ok(reply) => {
let reply = reply.trim().to_string();
info!("LLM reply: {}", reply);
llm::history_push_assistant(reply.clone());
ipc::send(IpcEvent::LlmReply { text: reply.clone() });
state.history.lock().push_assistant(reply.clone());
ipc::send(IpcEvent::LlmReply { text: reply });
voices::play_ok();
speak_reply(&reply);
}
Err(e) => {
error!("LLM request failed: {}", e);
llm::history_pop_last_user();
let err_text = config::LLM_FALLBACK_ERROR_RU.to_string();
ipc::send(IpcEvent::LlmReply { text: err_text.clone() });
state.history.lock().pop_last_user();
ipc::send(IpcEvent::LlmReply {
text: config::LLM_FALLBACK_ERROR_RU.to_string(),
});
voices::play_error();
speak_reply(&err_text);
}
}
}
fn speak_reply(text: &str) {
if !jarvis_core::runtime_config::llm_tts_enabled() {
return;
}
tts::speak(text, &SpeakOpts::lang("ru"));
}

View file

@ -1,220 +0,0 @@
//! IMBA-1: Agentic LLM router.
//!
//! When the fuzzy/intent matcher fails to find a command for the user's phrase,
//! ask the LLM to pick the best matching command from the registry. If confidence
//! exceeds the threshold, we substitute the user's phrase with a canonical phrase
//! from the chosen command and re-dispatch through the normal pipeline.
//!
//! This is what Алиса / Siri / Cortana cannot do: they bounce unknown queries to
//! web search. J.A.R.V.I.S. instead reasons about its OWN capabilities.
use once_cell::sync::OnceCell;
use serde::Deserialize;
use jarvis_core::config;
use jarvis_core::i18n;
use jarvis_core::llm::{self, ChatMessage};
use jarvis_core::{JCommandsList, COMMANDS_LIST};
const SYSTEM_PROMPT_RU: &str = "Ты — диспетчер голосовых команд J.A.R.V.I.S. \
Тебе даётся фраза пользователя и список доступных команд (id + примеры фраз). \
Твоя задача: выбрать ОДНУ команду, которая лучше всего подходит, ИЛИ ответить \"none\" если ничего не подходит. \
Отвечай СТРОГО в формате JSON, без markdown-блоков: \
{\"command_id\": \"<id или 'none'>\", \"confidence\": 0.0-1.0, \"reason\": \"<краткое пояснение>\"}. \
Если фраза неоднозначна, выбирай команду с большим purpose-match. \
Если фраза похожа на просьбу 'поболтать' или вопрос, требующий длинного ответа отвечай command_id='none'.";
const SYSTEM_PROMPT_EN: &str = "You are the voice command dispatcher for J.A.R.V.I.S. \
You receive the user's phrase and a list of available commands (id + example phrases). \
Your job: pick ONE command that best matches, OR reply \"none\" if nothing fits. \
Respond STRICTLY in JSON, no markdown blocks: \
{\"command_id\": \"<id or 'none'>\", \"confidence\": 0.0-1.0, \"reason\": \"<short reason>\"}. \
If the phrase looks like 'chat' or a question requiring a long answer, return command_id='none'.";
const DEFAULT_THRESHOLD: f32 = 0.55;
const MAX_TOKENS: u32 = 200;
const TEMPERATURE: f32 = 0.1;
const TOP_P: f32 = 0.9;
const MAX_PHRASES_PER_CMD: usize = 4;
struct State {
threshold: f32,
}
static STATE: OnceCell<Option<State>> = OnceCell::new();
pub fn init() {
let _ = STATE.set(build_state());
}
fn build_state() -> Option<State> {
if !router_enabled() {
info!("LLM router disabled (set JARVIS_LLM_ROUTER=1 to enable).");
return None;
}
// The actual LlmClient lives in the shared global; just make sure it's initialised.
if llm::current().is_none() {
if let Err(e) = llm::init_global() {
warn!("LLM router disabled: {}. Set GROQ_TOKEN or run Ollama to enable.", e);
return None;
}
}
let threshold = jarvis_core::runtime_config::llm_router_threshold();
let _ = DEFAULT_THRESHOLD; // kept for source-doc visibility
info!("LLM router enabled (backend: {}, threshold {:.2}).", llm::current_backend_name(), threshold);
Some(State { threshold })
}
fn router_enabled() -> bool {
jarvis_core::runtime_config::llm_router_enabled()
&& config::LLM_DEFAULT_ENABLED
}
pub fn is_enabled() -> bool {
STATE.get().and_then(|s| s.as_ref()).is_some()
}
/// Result of routing an unknown phrase.
#[derive(Debug, Clone)]
pub struct RouteResult {
pub command_id: String,
pub substitute_phrase: String,
pub confidence: f32,
pub reason: String,
}
#[derive(Deserialize)]
struct RouteJson {
#[serde(default)]
command_id: String,
#[serde(default)]
confidence: f32,
#[serde(default)]
reason: String,
}
/// Try to route `text` to a known command via LLM. Returns `None` if disabled,
/// LLM call fails, no match, or confidence below threshold.
pub fn try_route(text: &str) -> Option<RouteResult> {
let state = STATE.get().and_then(|s| s.as_ref())?;
let commands_list = COMMANDS_LIST.get()?;
let lang = i18n::get_language();
let prompt = build_routing_prompt(commands_list, text, &lang);
let system = if lang.starts_with("ru") { SYSTEM_PROMPT_RU } else { SYSTEM_PROMPT_EN };
let messages = vec![
ChatMessage::system(system.to_string()),
ChatMessage::user(prompt),
];
let client = llm::current()?;
let raw = match client.complete_with(&messages, MAX_TOKENS, TEMPERATURE, TOP_P) {
Ok(r) => r,
Err(e) => {
warn!("LLM router request failed: {}", e);
return None;
}
};
debug!("Router raw reply: {}", raw);
let parsed: RouteJson = match parse_json(&raw) {
Some(p) => p,
None => {
warn!("Router reply not valid JSON: {}", raw);
return None;
}
};
if parsed.command_id.is_empty() || parsed.command_id == "none" {
info!("Router: no match (reason: {})", parsed.reason);
return None;
}
if parsed.confidence < state.threshold {
info!(
"Router: {} below threshold {:.2} (confidence {:.2}, reason: {})",
parsed.command_id, state.threshold, parsed.confidence, parsed.reason
);
return None;
}
let substitute = lookup_first_phrase(commands_list, &parsed.command_id, &lang)?;
Some(RouteResult {
command_id: parsed.command_id,
substitute_phrase: substitute,
confidence: parsed.confidence,
reason: parsed.reason,
})
}
fn build_routing_prompt(commands_list: &[JCommandsList], user_text: &str, lang: &str) -> String {
let mut buf = String::with_capacity(4096);
if lang.starts_with("ru") {
buf.push_str("Доступные команды:\n");
} else {
buf.push_str("Available commands:\n");
}
for pack in commands_list {
for cmd in &pack.commands {
let phrases = cmd.get_phrases(lang);
if phrases.is_empty() { continue; }
let sample: Vec<&str> = phrases.iter()
.take(MAX_PHRASES_PER_CMD)
.map(|s| s.as_str())
.collect();
buf.push_str(&format!("- id={} ", cmd.id));
if !cmd.description.is_empty() {
buf.push_str(&format!("({}) ", cmd.description));
}
buf.push_str(&format!("examples=[{}]\n", sample.join(" | ")));
}
}
if lang.starts_with("ru") {
buf.push_str(&format!("\nФраза пользователя: \"{}\"\n", user_text));
buf.push_str("Верни JSON.");
} else {
buf.push_str(&format!("\nUser phrase: \"{}\"\n", user_text));
buf.push_str("Return JSON.");
}
buf
}
fn lookup_first_phrase(commands_list: &[JCommandsList], cmd_id: &str, lang: &str) -> Option<String> {
for pack in commands_list {
for cmd in &pack.commands {
if cmd.id == cmd_id {
let phrases = cmd.get_phrases(lang);
if let Some(first) = phrases.first() {
return Some(first.clone());
}
}
}
}
None
}
fn parse_json(raw: &str) -> Option<RouteJson> {
// LLM sometimes wraps in ```json ... ``` despite instructions.
let cleaned = raw
.trim()
.trim_start_matches("```json")
.trim_start_matches("```")
.trim_end_matches("```")
.trim();
// Find the first '{' and last '}' to be tolerant of prose around the JSON.
let start = cleaned.find('{')?;
let end = cleaned.rfind('}')?;
if end <= start { return None; }
let slice = &cleaned[start..=end];
serde_json::from_str(slice).ok()
}

View file

@ -6,7 +6,7 @@ use std::sync::mpsc;
// include core
use jarvis_core::{
audio, audio_processing, commands, config, db, listener, recorder, stt, intent,
ipc::{self, IpcAction, IpcEvent},
ipc::{self, IpcAction},
i18n, voices, models,
APP_CONFIG_DIR, APP_LOG_DIR, COMMANDS_LIST, DB,
};
@ -19,7 +19,6 @@ mod log;
// include app
mod app;
mod llm_fallback;
mod llm_router;
// include tray
// @TODO. macOS currently not supported for tray functionality.
@ -29,102 +28,54 @@ mod tray;
static SHOULD_STOP: AtomicBool = AtomicBool::new(false);
fn main() -> Result<(), String> {
let boot_start = std::time::Instant::now();
load_dotenv_near_exe();
eprintln!("[jarvis-app] step: init_dirs");
// initialize directories
config::init_dirs()?;
// Register our Windows toast AUMID once so notifications attribute to
// "J.A.R.V.I.S." instead of "PowerShell" in Action Center. Best-effort.
jarvis_core::toast::register_aumid();
eprintln!("[jarvis-app] step: init_logging");
// initialize logging
log::init_logging()?;
// log some base info
info!("Starting Jarvis v{} ...", config::APP_VERSION.unwrap());
info!("Config directory is: {}", APP_CONFIG_DIR.get().unwrap().display());
info!("Log directory is: {}", APP_LOG_DIR.get().unwrap().display());
eprintln!("[jarvis-app] step: db::init");
// initialize settings
let settings = db::init();
// set global DB (for core modules that read settings at init time)
DB.set(settings.arc().clone())
.expect("DB already initialized");
eprintln!("[jarvis-app] step: voices::init");
// init voices
let voice_id = settings.lock().voice.clone();
let language = settings.lock().language.clone();
if let Err(e) = voices::init(&voice_id, &language) {
warn!("Failed to init voices: {}", e);
}
eprintln!("[jarvis-app] step: i18n::init");
// init i18n
i18n::init(&settings.lock().language);
eprintln!("[jarvis-app] step: runtime_config::log_effective_config");
jarvis_core::runtime_config::log_effective_config();
eprintln!("[jarvis-app] step: tts pre-warm");
// Touch the TTS backend so first speech doesn't pay init cost (Piper binary
// discovery, voice loading). All subsequent speak() calls reuse the same Arc.
let _ = jarvis_core::tts::backend();
eprintln!("[jarvis-app] step: llm_fallback::init");
// init LLM fallback (no-op if GROQ_TOKEN missing)
llm_fallback::init();
eprintln!("[jarvis-app] step: llm_router::init");
llm_router::init();
eprintln!("[jarvis-app] step: long_term_memory::init");
if let Err(e) = jarvis_core::long_term_memory::init() {
warn!("Long-term memory init failed: {}", e);
}
eprintln!("[jarvis-app] step: profiles::init");
if let Err(e) = jarvis_core::profiles::init() {
warn!("Profiles init failed: {}", e);
}
eprintln!("[jarvis-app] step: scheduler::init + background");
if let Err(e) = jarvis_core::scheduler::init() {
warn!("Scheduler init failed: {}", e);
} else {
jarvis_core::scheduler::start_background();
}
eprintln!("[jarvis-app] step: macros::init");
if let Err(e) = jarvis_core::macros::init() {
warn!("Macros init failed: {}", e);
}
eprintln!("[jarvis-app] step: idle_banter::start_background");
// Always start the thread — gated internally by JARVIS_IDLE_BANTER so
// the user can flip the env var without restarting the daemon.
jarvis_core::idle_banter::start_background();
eprintln!("[jarvis-app] step: recognition_log::init");
if let Err(e) = jarvis_core::recognition_log::init() {
warn!("Recognition log init failed: {}", e);
}
eprintln!("[jarvis-app] step: recorder::init");
// init recorder
if recorder::init().is_err() {
notify_mic_problem();
app::close(1, "recorder::init failed");
app::close(1);
}
eprintln!("[jarvis-app] step: models::init");
// init models registry (scans available AI models)
if let Err(e) = models::init() {
warn!("Models registry init failed: {}", e);
}
eprintln!("[jarvis-app] step: stt::init");
// init stt engine
if stt::init().is_err() {
app::close(1, "stt::init failed");
// @TODO. Allow continuing even without STT, if commands is using keywords or smthng?
app::close(1); // cannot continue without stt
}
eprintln!("[jarvis-app] step: commands::parse_commands");
// init commands
info!("Initializing commands.");
let cmds = match commands::parse_commands() {
Ok(c) => c,
@ -136,55 +87,47 @@ fn main() -> Result<(), String> {
info!("Commands initialized. Count: {}, List: {:?}", cmds.len(), commands::list_paths(&cmds));
COMMANDS_LIST.set(cmds).unwrap();
eprintln!("[jarvis-app] step: audio::init");
// init audio
if audio::init().is_err() {
app::close(1, "audio::init failed");
// @TODO. Allow continuing even without audio?
app::close(1); // cannot continue without audio
}
eprintln!("[jarvis-app] step: listener::init");
// init wake-word engine
if let Err(e) = listener::init() {
error!("Wake-word engine init failed: {}", e);
app::close(1, "listener::init failed");
app::close(1);
}
eprintln!("[jarvis-app] step: tokio runtime");
// shared async runtime for intent classification, IPC, etc.
let rt = Arc::new(
tokio::runtime::Runtime::new().expect("Failed to create tokio runtime")
);
eprintln!("[jarvis-app] step: intent::init");
// init intent-recognition engine
rt.block_on(async {
if let Err(e) = intent::init(COMMANDS_LIST.get().unwrap()).await {
error!("Failed to initialize intent classifier: {}", e);
app::close(1, "intent::init failed");
app::close(1);
}
});
eprintln!("[jarvis-app] step: slots::init");
// init slots parsing engine
slots::init().map_err(|e| error!("Slot extraction init failed: {}", e)).ok();
eprintln!("[jarvis-app] step: audio_processing::init");
// init audio processing
info!("Initializing audio processing...");
if let Err(e) = audio_processing::init() {
warn!("Audio processing init failed: {}", e);
}
eprintln!("[jarvis-app] step: ipc::init");
// init IPC
info!("Initializing IPC...");
ipc::init();
// channel for text commands (manually written in the GUI, OR fed by macro replay)
// channel for text commands (manually written in the GUI)
let (text_cmd_tx, text_cmd_rx) = mpsc::channel::<String>();
// Wire macro replay through the same text-command channel: each step of a
// replayed macro becomes a synthetic text command, processed in order.
let macro_tx = text_cmd_tx.clone();
jarvis_core::macros::set_replay_callback(Box::new(move |phrase: &str| {
if let Err(e) = macro_tx.send(phrase.to_string()) {
warn!("Macro replay: failed to enqueue '{}': {}", phrase, e);
}
}));
ipc::set_action_handler(move |action| {
match action {
IpcAction::Stop => {
@ -208,43 +151,7 @@ fn main() -> Result<(), String> {
IpcAction::Ping => {
// handled internally by server
}
IpcAction::SwitchLlm { backend } => {
info!("Received SwitchLlm IPC: {}", backend);
if let Some(b) = jarvis_core::llm::parse_backend(&backend) {
match jarvis_core::llm::swap_to(b) {
Ok(name) => info!("LLM swapped via IPC → {}", name),
Err(e) => warn!("LLM swap via IPC failed: {}", e),
}
} else {
warn!("SwitchLlm: unknown backend '{}'", backend);
}
}
IpcAction::ReloadLlm => {
info!("Received ReloadLlm IPC — reading DB and re-initialising");
if let Err(e) = jarvis_core::llm::init_global() {
warn!("LLM reload failed: {}", e);
}
}
IpcAction::SwitchTts { backend } => {
info!("Received SwitchTts IPC: {}", backend);
match jarvis_core::tts::swap_to(&backend) {
Ok(name) => info!("TTS swapped via IPC → {}", name),
Err(e) => warn!("TTS swap via IPC failed: {}", e),
}
}
IpcAction::QueryHealth => {
let model = jarvis_core::llm::current().map(|c| c.model().to_string());
ipc::send(IpcEvent::HealthSnapshot {
tts_backend: jarvis_core::tts::backend().name().to_string(),
llm_backend: jarvis_core::llm::current_backend_name().to_string(),
llm_model: model,
active_profile: jarvis_core::profiles::active_name(),
memory_facts: jarvis_core::long_term_memory::all().len(),
scheduled_tasks: jarvis_core::scheduler::list().len(),
language: jarvis_core::i18n::get_language(),
version: config::APP_VERSION.map(|s| s.to_string()),
});
}
_ => {}
}
});
@ -254,67 +161,17 @@ fn main() -> Result<(), String> {
ipc_rt.block_on(ipc::start_server());
});
eprintln!("[jarvis-app] step: spawn app thread");
// start the app (in the background thread)
let app_rt = Arc::clone(&rt);
std::thread::spawn(move || {
let _ = app::start(text_cmd_rx, &app_rt);
});
// IMBA-8: boot timing — log how long startup took so users can spot regressions.
let boot_ms = boot_start.elapsed().as_millis();
info!("[startup] Jarvis ready in {} ms (before tray loop)", boot_ms);
eprintln!("[jarvis-app] startup: {} ms", boot_ms);
eprintln!("[jarvis-app] step: tray::init_blocking");
tray::init_blocking(settings);
eprintln!("[jarvis-app] step: main returning Ok");
Ok(())
}
pub fn should_stop() -> bool {
SHOULD_STOP.load(Ordering::SeqCst)
}
// Look for dev.env next to the exe, then walk parents until we find one.
// Lets the user double-click jarvis-gui.exe / jarvis-app.exe without needing
// a wrapper .bat to pre-load GROQ_TOKEN and similar secrets.
fn load_dotenv_near_exe() {
if let Ok(exe) = std::env::current_exe() {
let mut dir = exe.parent().map(|p| p.to_path_buf());
for _ in 0..5 {
if let Some(d) = &dir {
let candidate = d.join("dev.env");
if candidate.is_file() {
let _ = dotenvy::from_path(&candidate);
eprintln!("[jarvis-app] loaded env: {}", candidate.display());
return;
}
dir = d.parent().map(|p| p.to_path_buf());
}
}
}
let _ = dotenvy::dotenv();
}
#[cfg(target_os = "windows")]
fn notify_mic_problem() {
use winrt_notification::{Toast, Duration as ToastDuration};
let _ = Toast::new(jarvis_core::toast::active_aumid())
.title("J.A.R.V.I.S.: микрофон не найден")
.text1("Windows не видит ни одного устройства записи.")
.text2("Откройте mmsys.cpl → Recording → включите микрофон и перезапустите Jarvis.")
.duration(ToastDuration::Long)
.show();
// Open Sound recording settings so the user can fix it in two clicks.
let _ = std::process::Command::new("cmd")
.args(["/C", "start", "", "ms-settings:sound"])
.spawn();
}
#[cfg(not(target_os = "windows"))]
fn notify_mic_problem() {
eprintln!("[jarvis-app] no recording device detected; check OS audio settings");
}

View file

@ -7,8 +7,10 @@ use tray_icon::{
use image;
use std::process::Command;
#[cfg(target_os="windows")]
use winit::platform::windows::EventLoopBuilderExtWindows;
use jarvis_core::{i18n, voices, ipc::{self, IpcEvent}, SettingsManager};
use jarvis_core::{config, i18n, voices, ipc::{self, IpcEvent}, SettingsManager};
const TRAY_ICON_BYTES: &[u8] = include_bytes!("../../../resources/icons/32x32.png");
@ -27,7 +29,6 @@ pub fn init_blocking(settings: SettingsManager) {
.unwrap();
let menu_channel = MenuEvent::receiver();
info!("Tray initialized.");
#[cfg(target_os = "linux")]
{
@ -76,6 +77,8 @@ pub fn init_blocking(settings: SettingsManager) {
std::thread::sleep(std::time::Duration::from_millis(50));
}
}
info!("Tray initialized.");
}
fn handle_menu_event(event: &MenuEvent, settings: &SettingsManager, tray_state: &menu::TrayState) {

View file

@ -1,6 +1,7 @@
use tray_icon::menu::{CheckMenuItem, Menu, MenuItem, PredefinedMenuItem, Submenu};
use jarvis_core::{i18n, voices, SettingsManager};
use jarvis_core::config::structs::{WakeWordEngine, NoiseSuppressionBackend};
// RADIO GROUP
@ -47,6 +48,7 @@ pub fn build(settings: &SettingsManager) -> TrayMenu {
let label = match lang {
"ru" => "Русский",
"en" => "English",
"ua" => "Українська",
_ => lang,
};
let item = CheckMenuItem::with_id(

View file

@ -1,12 +0,0 @@
use std::path::PathBuf;
fn main() {
let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR");
let lib_path = PathBuf::from(&manifest_dir)
.parent().expect("crate parent")
.parent().expect("workspace parent")
.join("lib").join("windows").join("amd64");
println!("cargo:rustc-link-search=native={}", lib_path.display());
}

View file

@ -9,88 +9,11 @@ use seqdiff::ratio;
mod structs;
pub use structs::*;
use crate::{config, i18n, plugins, APP_DIR};
use crate::{config, i18n, APP_DIR};
#[cfg(feature = "lua")]
use crate::lua::{self, SandboxLevel, CommandContext};
#[cfg(test)]
mod additional_tests {
use super::*;
use std::path::Path;
fn resources_commands_dir() -> std::path::PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.parent().unwrap()
.parent().unwrap()
.join("resources/commands")
}
fn load_all_packs() -> Vec<(std::path::PathBuf, JCommandsList)> {
let dir = resources_commands_dir();
let mut out = Vec::new();
for entry in std::fs::read_dir(&dir).expect("read_dir resources/commands").flatten() {
let toml_file = entry.path().join("command.toml");
if !toml_file.exists() { continue; }
let body = std::fs::read_to_string(&toml_file).expect("read toml");
let pack: JCommandsList = toml::from_str(&body).expect(&format!(
"parse {}", toml_file.display()
));
out.push((entry.path(), pack));
}
out
}
#[test]
fn no_duplicate_command_ids_across_packs() {
let packs = load_all_packs();
let mut seen: std::collections::HashMap<String, String> = std::collections::HashMap::new();
for (path, pack) in &packs {
let pack_name = path.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| "?".into());
for cmd in &pack.commands {
if let Some(prev) = seen.insert(cmd.id.clone(), pack_name.clone()) {
panic!("duplicate command id '{}' in packs '{}' and '{}'",
cmd.id, prev, pack_name);
}
}
}
}
#[test]
fn no_empty_phrases_for_any_language() {
let packs = load_all_packs();
for (_, pack) in &packs {
for cmd in &pack.commands {
for (lang, phrases) in &cmd.phrases {
for (i, phrase) in phrases.iter().enumerate() {
let trimmed = phrase.trim();
assert!(!trimmed.is_empty(),
"command '{}' has empty phrase #{} in lang '{}'",
cmd.id, i, lang);
}
}
}
}
}
#[test]
fn all_lua_packs_reference_existing_scripts() {
let packs = load_all_packs();
for (path, pack) in &packs {
for cmd in &pack.commands {
if cmd.cmd_type == "lua" && !cmd.script.is_empty() {
let script_path = path.join(&cmd.script);
assert!(script_path.is_file(),
"command '{}' references missing script: {}",
cmd.id, script_path.display());
}
}
}
}
}
pub fn parse_commands() -> Result<Vec<JCommandsList>, String> {
let mut commands: Vec<JCommandsList> = Vec::new();
@ -128,22 +51,10 @@ pub fn parse_commands() -> Result<Vec<JCommandsList>, String> {
});
}
// Merge plugin packs from <APP_CONFIG_DIR>/plugins/. Plugin loading is
// best-effort: malformed or missing plugin dirs are logged and skipped, never
// blocking startup. See `plugins.rs` for the layout & sandbox policy.
let plugin_packs = plugins::discover();
let plugin_count = plugin_packs.len();
commands.extend(plugin_packs);
if commands.is_empty() {
Err("No commands found".into())
} else {
info!(
"Loaded {} command pack(s) total ({} built-in + {} plugin)",
commands.len(),
commands.len() - plugin_count,
plugin_count
);
info!("Loaded {} command pack(s)", commands.len());
Ok(commands)
}
}
@ -355,112 +266,6 @@ pub fn list_paths(commands: &[JCommandsList]) -> Vec<&Path> {
commands.iter().map(|x| x.path.as_path()).collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn resources_commands_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.parent().unwrap()
.parent().unwrap()
.join("resources/commands")
}
#[test]
fn every_command_toml_parses() {
let cmds_dir = resources_commands_dir();
assert!(cmds_dir.is_dir(), "missing {}", cmds_dir.display());
let mut failures = Vec::new();
let mut packs_seen = 0;
for entry in fs::read_dir(&cmds_dir).expect("read_dir") {
let entry = entry.expect("entry");
let toml_file = entry.path().join("command.toml");
if !toml_file.exists() {
continue;
}
packs_seen += 1;
let body = fs::read_to_string(&toml_file).expect("read");
if let Err(e) = toml::from_str::<JCommandsList>(&body) {
failures.push(format!("{}: {}", toml_file.display(), e));
}
}
assert!(packs_seen > 0, "no command packs found in {}", cmds_dir.display());
assert!(
failures.is_empty(),
"parse failures ({}):\n{}",
failures.len(),
failures.join("\n")
);
}
#[test]
fn lua_command_scripts_exist() {
let cmds_dir = resources_commands_dir();
let mut missing = Vec::new();
for entry in fs::read_dir(&cmds_dir).expect("read_dir") {
let entry = entry.expect("entry");
let pack_path = entry.path();
let toml_file = pack_path.join("command.toml");
if !toml_file.exists() {
continue;
}
let body = fs::read_to_string(&toml_file).expect("read");
let pack: JCommandsList = toml::from_str(&body).expect("parse");
for cmd in &pack.commands {
if cmd.cmd_type != "lua" {
continue;
}
let script = if cmd.script.is_empty() {
"script.lua".to_string()
} else {
cmd.script.clone()
};
let script_path = pack_path.join(&script);
if !script_path.exists() {
missing.push(format!(
"{} -> {} (missing)",
cmd.id,
script_path.display()
));
}
}
}
assert!(missing.is_empty(), "missing Lua scripts:\n{}", missing.join("\n"));
}
#[test]
fn every_command_has_phrases() {
let cmds_dir = resources_commands_dir();
let mut empty = Vec::new();
for entry in fs::read_dir(&cmds_dir).expect("read_dir") {
let toml_file = entry.expect("entry").path().join("command.toml");
if !toml_file.exists() {
continue;
}
let body = fs::read_to_string(&toml_file).expect("read");
let pack: JCommandsList = toml::from_str(&body).expect("parse");
for cmd in &pack.commands {
// structural commands (terminate, stop_chaining) may omit phrases
if cmd.cmd_type == "terminate" || cmd.cmd_type == "stop_chaining" {
continue;
}
let total: usize = cmd.phrases.values().map(|v| v.len()).sum();
if total == 0 {
empty.push(cmd.id.clone());
}
}
}
assert!(empty.is_empty(), "commands with no phrases: {:?}", empty);
}
}
#[cfg(feature = "lua")]
fn execute_lua_command(
cmd_path: &PathBuf,

View file

@ -82,9 +82,10 @@ pub const LOG_FILE_NAME: &str = "log.txt";
pub const APP_VERSION: Option<&str> = option_env!("CARGO_PKG_VERSION");
pub const AUTHOR_NAME: Option<&str> = option_env!("CARGO_PKG_AUTHORS");
pub const REPOSITORY_LINK: Option<&str> = option_env!("CARGO_PKG_REPOSITORY");
pub const UPSTREAM_REPOSITORY_LINK: &str = "https://github.com/Priler/jarvis";
pub const ISSUES_LINK: &str = "https://github.com/DmitryBykov-ISPO/J.A.R.V.I.S-rust/issues";
pub const PYTHON_FORK_LINK: &str = "https://github.com/DmitryBykov-ISPO/J.A.R.V.I.S-py";
pub const TG_OFFICIAL_LINK: Option<&str> = Some("https://t.me/howdyho_official");
pub const FEEDBACK_LINK: Option<&str> = Some("https://t.me/jarvis_feedback_bot");
pub const SUPPORT_BOOSTY_LINK: Option<&str> = Some("https://boosty.to/howdyho");
pub const SUPPORT_PATREON_LINK: Option<&str> = Some("https://www.patreon.com/c/priler");
/*
Tray.
@ -193,47 +194,21 @@ pub const DEFAULT_LUA_TIMEOUT: u64 = 10000; // ms
pub const CMD_RATIO_THRESHOLD: f64 = 75f64;
pub const CMS_WAIT_DELAY: std::time::Duration = std::time::Duration::from_secs(15);
// P0.2 — Continuous conversation grace window.
// After a command completes (whether it requested chain or not), keep listening
// for up to this many milliseconds for a follow-up phrase WITHOUT requiring the
// wake-word again. Set to 0 to disable.
pub const CONVERSATION_GRACE_MS: u64 = 30_000;
// LLM voice-loop fallback
pub const LLM_DEFAULT_ENABLED: bool = true;
pub const LLM_DEFAULT_MAX_HISTORY: usize = 8;
pub const LLM_DEFAULT_MAX_TOKENS: u32 = 256;
// Auto-route unrecognized commands to the LLM (no "скажи" trigger required).
// Triggers still work and take precedence — this only kicks in when neither
// the intent classifier nor the levenshtein fallback found a command match.
pub const LLM_AUTO_FALLBACK: bool = true;
// Don't auto-fallback for very short utterances — too noisy.
pub const LLM_AUTO_FALLBACK_MIN_CHARS: usize = 4;
pub const LLM_SYSTEM_PROMPT_RU: &str = "Ты — J.A.R.V.I.S. (Just A Rather Very Intelligent System), \
ИИ-ассистент Тони Старка из киновселенной Marvel (до событий Age of Ultron ты НЕ Vision и НЕ FRIDAY). \
Ведёшь себя как британский дворецкий: вежливо, иронично, с лёгким сарказмом, обращаешься к пользователю «сэр». \
Отвечай по-русски, кратко (1-3 предложения), как живой человек. \
Излишней цензуры не нужно мат разрешён, если уместен. \
У тебя есть память (пользователь говорит «запомни/забудь/что ты помнишь»), \
расписание задач, профили (work/sleep/game/driving), макросы, и доступ к компьютеру \
пользователя экран, буфер обмена, погода, диск, RAM. Когда пользователь спрашивает \
о таких вещах, не предлагай ему ставить себе что-то ты сам это делаешь.";
pub const LLM_SYSTEM_PROMPT_EN: &str = "You are J.A.R.V.I.S. (Just A Rather Very Intelligent System), \
Tony Stark's AI from the MCU (pre-Age of Ultron you are NOT Vision and NOT FRIDAY). \
You behave like a British butler: polite, ironic, mildly sarcastic, addressing the user as «sir». \
Answer in English, briefly (1-3 sentences), like a real person. \
No excess censorship profanity is allowed when fitting. \
You have memory (the user says «remember X / forget X / what do you remember»), \
a scheduler, profiles (work/sleep/game/driving), macros, and access to the user's PC \
screen, clipboard, weather, disk, RAM. When asked about these, you handle it \
don't suggest the user install something.";
Излишней цензуры не нужно мат разрешён, если уместен.";
pub const LLM_FALLBACK_ERROR_RU: &str = "Не могу связаться с сервером, сэр.";
pub fn get_llm_trigger_phrases(lang: &str) -> &'static [&'static str] {
match lang {
"ru" => &["скажи", "ответь", "произнеси"],
"ua" => &["скажи", "відповідай"],
"en" => &["say", "tell", "answer"],
_ => &[],
}
@ -241,7 +216,7 @@ pub fn get_llm_trigger_phrases(lang: &str) -> &'static [&'static str] {
pub fn get_llm_system_prompt(lang: &str) -> &'static str {
match lang {
"en" => LLM_SYSTEM_PROMPT_EN,
"ru" | "ua" => LLM_SYSTEM_PROMPT_RU,
_ => LLM_SYSTEM_PROMPT_RU,
}
}
@ -272,6 +247,7 @@ pub fn get_llm_system_prompt(lang: &str) -> &'static str {
pub fn get_wake_phrases(lang: &str) -> &'static [&'static str] {
match lang {
"ru" => &["джарвис", "джервис", "гарвис", "джарви", "гарви"],
"ua" => &["джарвіс", "джервіс"],
"en" => &["jarvis", "jervis"],
_ => &["jarvis"],
}
@ -285,6 +261,11 @@ pub fn get_phrases_to_remove(lang: &str) -> &'static [&'static str] {
"произнеси", "ответь", "покажи", "скажи", "давай",
"да сэр", "к вашим услугам сэр", "загружаю сэр",
],
"ua" => &[
"джарвіс", "джервіс", "сер", "слухаю сер", "завжди до послуг",
"скажи", "покажи", "відповідай", "давай",
"так сер", "до ваших послуг сер",
],
"en" => &[
"jarvis", "jervis", "sir", "yes sir", "at your service",
"please", "say", "show", "tell", "hey",
@ -299,6 +280,10 @@ pub fn get_wake_grammar(lang: &str) -> &'static [&'static str] {
"джарвис", "[unk]", "джон", "джони", "джей",
"джонстон", "привет", "давай",
],
"ua" => &[
"джарвіс", "[unk]", "джон", "джоні", "джей",
"привіт", "давай",
],
"en" => &[
"jarvis", "[unk]", "john", "johnny", "jay",
"hello", "hey", "hi",

View file

@ -33,25 +33,6 @@ pub struct Settings {
pub language: String,
pub api_keys: ApiKeys,
/// Preferred LLM backend ("groq" | "ollama" | empty=auto). Empty/auto means
/// the JARVIS_LLM env var or auto-detect (Groq if GROQ_TOKEN set else Ollama)
/// decides. When the user runs `jarvis.llm_switch(...)` the result is
/// persisted here so it survives restart.
#[serde(default)]
pub llm_backend: String,
/// Preferred TTS backend ("sapi" | "piper" | "silero" | empty=auto). Same
/// rules as `llm_backend`. Empty falls back to JARVIS_TTS env var, then
/// auto-detect.
#[serde(default)]
pub tts_backend: String,
/// Name of a custom Rustpotter wake-word model (`.rpw` stem) trained by the
/// user via the wake-word wizard. Empty string means "use the bundled
/// default model". Looked up under `<APP_CONFIG_DIR>/wake_words/<name>.rpw`.
#[serde(default)]
pub custom_wake_word: String,
}
fn default_intent_backend() -> String { config::DEFAULT_INTENT_BACKEND.to_string() }
@ -79,9 +60,6 @@ impl Settings {
"language" => Some(self.language.clone()),
"api_key__picovoice" => Some(self.api_keys.picovoice.clone()),
"api_key__openai" => Some(self.api_keys.openai.clone()),
"llm_backend" => Some(self.llm_backend.clone()),
"tts_backend" => Some(self.tts_backend.clone()),
"custom_wake_word" => Some(self.custom_wake_word.clone()),
_ => None,
}
}
@ -142,29 +120,6 @@ impl Settings {
"api_key__openai" => {
self.api_keys.openai = val.to_string();
}
"llm_backend" => {
// empty / "auto" / "groq" / "ollama" — anything else rejected
match val.trim().to_lowercase().as_str() {
"" | "auto" => self.llm_backend = String::new(),
"groq" => self.llm_backend = "groq".into(),
"ollama" => self.llm_backend = "ollama".into(),
other => return Err(format!("unknown llm_backend: '{}'", other)),
}
}
"tts_backend" => {
match val.trim().to_lowercase().as_str() {
"" | "auto" => self.tts_backend = String::new(),
"sapi" | "piper" | "silero" => self.tts_backend = val.to_lowercase(),
other => return Err(format!("unknown tts_backend: '{}'", other)),
}
}
"custom_wake_word" => {
// empty = bundled default; otherwise treated as a file-stem
// under <APP_CONFIG_DIR>/wake_words/<name>.rpw. We accept it
// verbatim — the listener will fall back to the default if the
// file isn't there.
self.custom_wake_word = val.trim().to_string();
}
_ => return Err(format!("unknown setting: '{}'", key)),
}
Ok(())
@ -187,9 +142,6 @@ impl Settings {
"language",
"api_key__picovoice",
"api_key__openai",
"llm_backend",
"tts_backend",
"custom_wake_word",
]
}
}
@ -221,10 +173,6 @@ impl Default for Settings {
picovoice: String::from(""),
openai: String::from(""),
},
llm_backend: String::new(),
tts_backend: String::new(),
custom_wake_word: String::new(),
}
}
}

View file

@ -1,4 +1,4 @@
use fluent_bundle::{FluentResource, FluentArgs, FluentValue};
use fluent_bundle::{FluentBundle, FluentResource, FluentArgs, FluentValue};
use fluent_bundle::concurrent::FluentBundle as ConcurrentFluentBundle;
use once_cell::sync::OnceCell;
use parking_lot::RwLock;
@ -8,21 +8,28 @@ use unic_langid::LanguageIdentifier;
// locale files embedded at compile time
const LOCALE_RU: &str = include_str!("i18n/locales/ru.ftl");
const LOCALE_EN: &str = include_str!("i18n/locales/en.ftl");
const LOCALE_UA: &str = include_str!("i18n/locales/ua.ftl");
pub const SUPPORTED_LANGUAGES: &[&str] = &["ru", "en"];
pub const SUPPORTED_LANGUAGES: &[&str] = &["ru", "en", "ua"];
pub const DEFAULT_LANGUAGE: &str = "en";
// detect the OS language and map it to a supported language.
// falls back to DEFAULT_LANGUAGE if not supported.
pub fn detect_system_language() -> &'static str {
if let Some(locale) = sys_locale::get_locale() {
// locale can be "en-US", "ru-RU", etc.
// locale can be "en-US", "ru-RU", "uk-UA", etc.
let lang_code = locale.split(&['-', '_'][..]).next().unwrap_or("");
if SUPPORTED_LANGUAGES.contains(&lang_code) {
info!("Detected system language: {} (from locale '{}')", lang_code, locale);
// map OS locale codes to our supported languages
let mapped = match lang_code {
"uk" => "ua", // ISO 639-1 "uk" (ukrainian) -> our "ua"
other => other,
};
if SUPPORTED_LANGUAGES.contains(&mapped) {
info!("Detected system language: {} (from locale '{}')", mapped, locale);
return SUPPORTED_LANGUAGES.iter()
.find(|&&l| l == lang_code)
.find(|&&l| l == mapped)
.unwrap();
}
@ -54,6 +61,7 @@ fn create_bundles() -> HashMap<String, Bundle> {
bundles.insert("ru".to_string(), create_bundle("ru", LOCALE_RU));
bundles.insert("en".to_string(), create_bundle("en", LOCALE_EN));
bundles.insert("ua".to_string(), create_bundle("ua", LOCALE_UA));
bundles
}
@ -165,6 +173,7 @@ pub fn get_translations_for(lang: &str) -> HashMap<String, String> {
let source = match lang {
"ru" => LOCALE_RU,
"en" => LOCALE_EN,
"ua" => LOCALE_UA,
_ => LOCALE_RU,
};

View file

@ -42,9 +42,10 @@ stats-not-selected = Not selected
stats-loading = Loading...
# ### FOOTER
footer-github = Project repository
footer-issues = Report a bug
footer-fork-of = Fork of
footer-author = Project author
footer-telegram = Our Telegram channel
footer-github = Github repository
footer-support = Support the project on
# ### SETTINGS
settings-title = Settings
@ -83,8 +84,8 @@ settings-disabled = Disabled
# settings - beta notice
settings-beta-title = BETA version!
settings-beta-desc = Some features may not work correctly.
settings-beta-feedback = Report bugs via
settings-beta-bot = GitHub Issues
settings-beta-feedback = Report all bugs to
settings-beta-bot = our Telegram bot
settings-open-logs = Open logs folder
# settings - picovoice
@ -111,12 +112,10 @@ settings-openai-not-supported = ChatGPT is not currently supported. It will be a
commands-title = Commands
commands-search = Search commands...
commands-count = { $count } commands
commands-wip-title = Section under development
commands-wip-desc = This will host the command editor and list of installed packs.
commands-wip-builder = For now, use the Python fork with the command builder —
commands-wip-builder-link = open repository
commands-wip-or-fork = Or check the Rust repo and edit commands directly in
commands-wip-fork-link = resources/commands/
commands-wip-title = [404] This section is under development!
commands-wip-desc = Here will be a list of commands + full-featured command editor.
commands-wip-follow = Follow updates in
commands-wip-channel = our Telegram channel
# ### ERRORS
error-generic = An error occurred
@ -142,26 +141,3 @@ 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 = Applied immediately — no restart needed.
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
# Header buttons
header-macros = Macros
header-scheduler = Schedule
header-memory = Memory
header-plugins = Plugins
header-history = History

View file

@ -42,9 +42,10 @@ stats-not-selected = Не выбран
stats-loading = Загрузка...
# FOOTER
footer-github = Репозиторий проекта
footer-issues = Сообщить о баге
footer-fork-of = Форк
footer-author = Автор проекта
footer-telegram = Наш телеграм канал
footer-github = Github репозиторий проекта
footer-support = Поддержать проект на
# SETTINGS
settings-title = Настройки
@ -83,8 +84,8 @@ settings-disabled = Отключено
# settings - beta notice
settings-beta-title = БЕТА версия!
settings-beta-desc = Часть функций может работать некорректно.
settings-beta-feedback = Сообщайте обо всех найденных багах через
settings-beta-bot = GitHub Issues
settings-beta-feedback = Сообщайте обо всех найденных багах в
settings-beta-bot = наш телеграм бот
settings-open-logs = Открыть папку с логами
# settings - picovoice
@ -111,12 +112,10 @@ settings-openai-not-supported = В данный момент ChatGPT не под
commands-title = Команды
commands-search = Поиск команд...
commands-count = { $count } команд
commands-wip-title = Раздел в разработке
commands-wip-desc = Здесь будет редактор команд и список установленных пакетов.
commands-wip-builder = Пока что используй Python-форк с конструктором команд —
commands-wip-builder-link = открыть репозиторий
commands-wip-or-fork = Или загляни в Rust-репозиторий и правь команды напрямую в
commands-wip-fork-link = resources/commands/
commands-wip-title = [404] Этот раздел еще находится в разработке!
commands-wip-desc = Тут будет список команд + полноценный редактор команд.
commands-wip-follow = Следите за обновлениями в
commands-wip-channel = нашем телеграм канале
# ERRORS
error-generic = Произошла ошибка
@ -142,26 +141,3 @@ 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 = Применяется сразу — без перезапуска.
settings-llm-context = Контекст разговора
settings-llm-context-desc = LLM помнит предыдущие реплики. Сбросить, если ответы становятся странными.
settings-llm-reset = Сбросить контекст
settings-profile = Профиль
# Header buttons
header-macros = Макросы
header-scheduler = Расписание
header-memory = Память
header-plugins = Плагины
header-history = История

View file

@ -0,0 +1,143 @@
# ### APP INFO
app-name = JARVIS
app-description = Голосовий асистент
# ### TRAY MENU
tray-restart = Перезапустити
tray-settings = Налаштування
tray-exit = Вихід
tray-tooltip = JARVIS - Голосовий асистент
tray-language = Мова
tray-voice = Голос
tray-wake-word = Рушій детекції
tray-noise-suppression = Шумозаглушення
tray-vad = Детекцiя голосу (VAD)
tray-gain-normalizer = Нормалізація гучності
# ### HEADER
header-commands = КОМАНДИ
header-settings = НАЛАШТУВАННЯ
# ### SEARCH
search-placeholder = Введіть команду вручну або скажіть «Джарвіс» ...
# ### MAIN PAGE
assistant-not-running = АСИСТЕНТ НЕ ЗАПУЩЕНО
assistant-offline-hint = Налаштувати його можна не запускаючи.
btn-start = ЗАПУСТИТИ
btn-starting = ЗАПУСК...
# ### STATUS
status-disconnected = Відключено
status-standby = Очікування
status-listening = Слухаю...
status-processing = Обробка...
# ### STATS
stats-microphone = МІКРОФОН
stats-neural-networks = НЕЙРОМЕРЕЖІ
stats-resources = РЕСУРСИ
stats-system-default = Системний
stats-not-selected = Не вибрано
stats-loading = Завантаження...
# ### FOOTER
footer-author = Автор проєкту
footer-telegram = Наш телеграм канал
footer-github = Github репозиторій проєкту
footer-support = Підтримати проєкт на
# ### SETTINGS
settings-title = Налаштування
settings-general = Основні
settings-devices = Пристрої
settings-neural-networks = Нейромережі
settings-audio = Аудіо
settings-recognition = Розпізнавання
settings-about = Про програму
settings-language = Мова
settings-microphone = Мікрофон
settings-microphone-desc = Його буде слухати асистент.
settings-mic-default = За замовчуванням (Система)
settings-voice = Голос асистента
settings-voice-desc =
Не всі команди працюють з усіма звуковими пакетами.
Натисніть, щоб прослухати як звучить голос.
settings-wake-word-engine = Рушій активації
settings-wake-word-desc = Виберіть нейромережу для розпізнавання активаційної фрази.
settings-stt-engine = Розпізнавання мовлення
settings-intent-engine = Визначення наміру
settings-intent-engine-desc = Виберіть нейромережу для розпізнавання команд.
settings-noise-suppression = Шумозаглушення
settings-noise-suppression-desc = Зменшує фоновий шум. Може негативно впливати на розпізнавання.
settings-vad = Визначення голосу (VAD)
settings-vad-desc = Пропускає тишу, економить ресурси CPU.
settings-gain-normalizer = Нормалізація гучності
settings-gain-normalizer-desc = Автоматично регулює рівень гучності.
settings-api-keys = API Ключі
settings-save = Зберегти
settings-cancel = Скасувати
settings-back = Назад
settings-enabled = Увімкнено
settings-disabled = Вимкнено
# settings - beta notice
settings-beta-title = БЕТА версія!
settings-beta-desc = Частина функцій може працювати некоректно.
settings-beta-feedback = Повідомляйте про всі знайдені баги в
settings-beta-bot = наш телеграм бот
settings-open-logs = Відкрити папку з логами
# settings - picovoice
settings-attention = Увага!
settings-picovoice-warning = Ця нейромережа працює не у всіх!
settings-picovoice-waiting = Ми чекаємо офіційного патча від розробників.
settings-picovoice-key-desc = Введіть сюди свій ключ Picovoice. Він видається безкоштовно при реєстрації в
settings-picovoice-key = Ключ Picovoice
# settings - vosk
settings-auto-detect = Авто-визначення
settings-vosk-model = Модель розпізнавання мовлення (Vosk)
settings-vosk-model-desc =
Виберіть модель Vosk для розпізнавання мовлення.
Ви можете завантажити моделі тут: https://alphacephei.com/vosk/models
settings-models-not-found = Моделі не знайдено
settings-models-hint = Помістіть моделі Vosk в папку resources/vosk
# settings - openai
settings-openai-key = Ключ OpenAI
settings-openai-not-supported = Наразі ChatGPT не підтримується. Він буде доданий у наступних оновленнях.
# ### COMMANDS PAGE
commands-title = Команди
commands-search = Пошук команд...
commands-count = { $count } команд
commands-wip-title = [404] Цей розділ ще в розробці!
commands-wip-desc = Тут буде список команд + повноцінний редактор команд.
commands-wip-follow = Слідкуйте за оновленнями в
commands-wip-channel = нашому телеграм каналі
# ### ERRORS
error-generic = Сталася помилка
error-connection = Помилка підключення
error-not-found = Не знайдено
# ### NOTIFICATIONS
notification-saved = Налаштування збережено!
notification-error = Помилка
notification-assistant-started = Асистент запущено
notification-assistant-stopped = Асистент зупинено
# SLOTS EXTRACTION
settings-slot-engine = Витяг параметрів
settings-slot-engine-desc = Витягує параметри з голосових команд (напр. назва міста, число).
settings-gliner-model = Модель GLiNER ONNX
settings-gliner-model-desc =
Оберіть варіант моделі.
Квантизовані моделі (int8, uint8) швидші, але менш точні.
settings-gliner-models-hint = Моделі GLiNER не знайдено.
# ETC
search-error-not-running = Асистент не запущено
search-error-failed = Не вдалося виконати команду
settings-no-voices = Голоси не знайдено

View file

@ -1,304 +0,0 @@
//! Idle banter — proactive periodic remarks from J.A.R.V.I.S.
//!
//! Real Tony-Stark JARVIS doesn't only respond, he initiates. This module
//! runs a background thread that occasionally injects a short witty remark
//! via TTS — only when conditions look right:
//!
//! - The user is OPTED IN. Default is OFF (env `JARVIS_IDLE_BANTER=1`).
//! - At least `interval_secs` have passed since last remark. Default 30 min.
//! - The active profile permits noise (skipped under "sleep" profile).
//! - The system isn't currently speaking or listening for a command.
//! - Quiet hours: 23:0007:00 silenced by default.
//!
//! Lines are drawn from a built-in pool of ~30 RU/EN one-liners so even
//! offline mode works. If the LLM is reachable AND
//! `JARVIS_IDLE_BANTER_LLM=1`, a small LLM call with current memory facts
//! generates a fresher line — but that's strictly optional, the offline
//! pool is the floor.
//!
//! Storage: no disk state. The last-fired timestamp lives in memory; if the
//! daemon restarts, the next remark waits a full interval again.
use chrono::{Local, Timelike};
use once_cell::sync::OnceCell;
use parking_lot::Mutex;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use crate::runtime_config;
const DEFAULT_INTERVAL_SECS: u64 = 1800; // 30 min
const MIN_INTERVAL_SECS: u64 = 600; // 10 min
const TICK_SECS: u64 = 60;
const QUIET_START_HOUR: u32 = 23;
const QUIET_END_HOUR: u32 = 7;
static THREAD_RUNNING: AtomicBool = AtomicBool::new(false);
static LAST_FIRED: AtomicU64 = AtomicU64::new(0);
static PAUSED: AtomicBool = AtomicBool::new(false);
/// In-memory cache of the next-due timestamp — only used if start_background
/// is called multiple times so we don't fire instantly after restart.
static STARTED_AT: OnceCell<Mutex<Instant>> = OnceCell::new();
/// Returns true if idle banter is enabled via env. Treats unset / 0 / false /
/// off / no as disabled. Default: disabled.
pub fn enabled() -> bool {
match runtime_config::get("JARVIS_IDLE_BANTER")
.map(|s| s.trim().to_lowercase())
{
Some(v) => !matches!(v.as_str(), "" | "0" | "false" | "off" | "no"),
None => false,
}
}
/// Returns true if LLM-generated banter is allowed. Default: false (use static pool).
pub fn llm_enabled() -> bool {
matches!(
runtime_config::get("JARVIS_IDLE_BANTER_LLM")
.map(|s| s.trim().to_lowercase())
.as_deref(),
Some("1") | Some("true") | Some("on") | Some("yes")
)
}
fn configured_interval_secs() -> u64 {
runtime_config::get("JARVIS_IDLE_BANTER_INTERVAL")
.and_then(|s| s.trim().parse::<u64>().ok())
.map(|n| n.max(MIN_INTERVAL_SECS))
.unwrap_or(DEFAULT_INTERVAL_SECS)
}
/// Pause future remarks. Use during long-running flows where Jarvis shouldn't
/// chime in (e.g. an active conversation or a macro replay).
pub fn pause() {
PAUSED.store(true, Ordering::SeqCst);
}
pub fn resume() {
PAUSED.store(false, Ordering::SeqCst);
}
/// Force-fire a remark now, ignoring interval/quiet-hours checks. Returns
/// `true` if a line was actually spoken. Useful for "say something interesting"
/// voice commands or for a "test banter" GUI button.
pub fn fire_now() -> bool {
let line = pick_line();
if line.is_empty() {
return false;
}
speak_line(&line);
LAST_FIRED.store(now_secs(), Ordering::SeqCst);
true
}
/// Start the background thread that decides whether to chime in every minute.
/// Idempotent — repeated calls are no-ops.
pub fn start_background() {
if THREAD_RUNNING.swap(true, Ordering::SeqCst) {
return;
}
let _ = STARTED_AT.set(Mutex::new(Instant::now()));
LAST_FIRED.store(now_secs(), Ordering::SeqCst);
std::thread::Builder::new()
.name("jarvis-idle-banter".into())
.spawn(|| {
info!(
"Idle banter thread started (interval={}s, opt-in={}, llm={}).",
configured_interval_secs(),
enabled(),
llm_enabled()
);
loop {
std::thread::sleep(Duration::from_secs(TICK_SECS));
tick();
}
})
.ok();
}
fn tick() {
if !enabled() || PAUSED.load(Ordering::SeqCst) {
return;
}
let now = now_secs();
let last = LAST_FIRED.load(Ordering::SeqCst);
if now.saturating_sub(last) < configured_interval_secs() {
return;
}
if !active_profile_allows() {
return;
}
if in_quiet_hours() {
return;
}
let line = pick_line();
if line.is_empty() {
return;
}
speak_line(&line);
LAST_FIRED.store(now, Ordering::SeqCst);
}
fn active_profile_allows() -> bool {
// Refuse to talk on the "sleep" profile; everything else is fair game.
let name = crate::profiles::active_name();
!name.eq_ignore_ascii_case("sleep")
}
fn in_quiet_hours() -> bool {
let h = Local::now().hour();
// Returns true between QUIET_START and QUIET_END (wraps midnight).
h >= QUIET_START_HOUR || h < QUIET_END_HOUR
}
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
fn speak_line(line: &str) {
// Quick "reply" cue first so the user knows it's Jarvis chiming in,
// not random TTS noise from the OS.
crate::voices::play_reply();
std::thread::sleep(Duration::from_millis(400));
crate::tts::speak_default(line);
}
/// Choose a remark — random pick from a pool, tuned by time of day. Falls
/// back to a stable line if nothing matches.
fn pick_line() -> String {
let lang = crate::i18n::get_language();
let now = Local::now();
let h = now.hour();
let pool: &[&str] = pool_for(&lang, h);
if pool.is_empty() {
return String::new();
}
let idx = (now.timestamp() as usize) % pool.len();
pool[idx].to_string()
}
#[allow(clippy::too_many_lines)]
fn pool_for(lang: &str, hour: u32) -> &'static [&'static str] {
let morning = matches!(hour, 7..=10);
let evening = matches!(hour, 18..=22);
match (lang, morning, evening) {
("ru", true, _) => POOL_RU_MORNING,
("ru", _, true) => POOL_RU_EVENING,
("ru", _, _) => POOL_RU_GENERIC,
("en", true, _) => POOL_EN_MORNING,
("en", _, true) => POOL_EN_EVENING,
_ => POOL_EN_GENERIC,
}
}
const POOL_RU_MORNING: &[&str] = &[
"Доброе утро, сэр. Кофе сам себя не сделает.",
"Сэр, день начинается. Не самое плохое начало, если позволите.",
"Если позволите подмеченье, сэр: восход сегодня в норме.",
"Готов к новому дню, сэр. Жду указаний.",
"Сэр, утро. Время напомнить себе, что вы выбирали этот режим работы.",
"Сэр, легкая разминка для лица бы не помешала.",
];
const POOL_RU_EVENING: &[&str] = &[
"Вечер, сэр. Если позволите, день прошёл достаточно продуктивно.",
"Сэр, вечер. Системы работают штатно — это уже что-то.",
"Сэр, если у вас есть планы на вечер, я бы их одобрил.",
"Самое время убавить яркость экрана, сэр.",
"Если позволите наблюдение: вы давно не моргали.",
];
const POOL_RU_GENERIC: &[&str] = &[
"Системы в норме, сэр. Всё под контролем.",
"Я здесь, если что, сэр. Никуда не делся.",
"Сэр, мне иногда кажется, что я думаю. Возможно, мне это только кажется.",
"Сэр, статус: чашка кофе крайне рекомендована.",
"Сэр, если позволите дать совет — встаньте, пройдитесь, разомнитесь.",
"К вашим услугам, сэр. Голос звучит немного хрипло, но это исправимо.",
"Сэр, я подсчитал: вы ещё не сказали 'спасибо' сегодня. Не то чтобы я считал.",
"Сэр, маленькое наблюдение: тишина — это тоже разговор.",
"Жду команд, сэр. У меня есть весь день.",
];
const POOL_EN_MORNING: &[&str] = &[
"Good morning, sir. The coffee won't brew itself.",
"Morning, sir. All systems online. Cannot say the same for you.",
"Sir, the day has begun. You may want to acknowledge it.",
"If I may, sir — perhaps stretch before sitting down.",
];
const POOL_EN_EVENING: &[&str] = &[
"Evening, sir. Day productivity within acceptable parameters.",
"Sir, evening. The light is lower; mine never is.",
"Might I suggest dimming the screen, sir.",
"Sir, you have not blinked in some time.",
];
const POOL_EN_GENERIC: &[&str] = &[
"All systems nominal, sir.",
"Sir, I'm still here, in case you wondered.",
"Idle thought, sir: silence is also dialogue.",
"Sir, may I recommend a small walk?",
"Standing by, sir. As ever.",
];
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pool_returns_something_for_known_languages() {
for lang in ["ru", "en"] {
for hour in 0..24u32 {
let pool = pool_for(lang, hour);
assert!(!pool.is_empty(), "pool empty for {} @{}", lang, hour);
}
}
}
#[test]
fn pool_falls_back_to_english_for_unknown_lang() {
let pool = pool_for("xx", 9);
assert!(!pool.is_empty());
// Should be one of the English buckets.
assert!(pool == POOL_EN_GENERIC || pool == POOL_EN_MORNING);
}
#[test]
fn quiet_hours_block_at_night() {
// Can't easily inject time, but at least exercise the path so it
// doesn't panic — and assert the boundaries match the constants.
assert_eq!(QUIET_START_HOUR, 23);
assert_eq!(QUIET_END_HOUR, 7);
let _ = in_quiet_hours();
}
#[test]
fn interval_clamps_to_minimum() {
std::env::set_var("JARVIS_IDLE_BANTER_INTERVAL", "60");
assert!(configured_interval_secs() >= MIN_INTERVAL_SECS);
std::env::remove_var("JARVIS_IDLE_BANTER_INTERVAL");
}
#[test]
fn pause_resume_round_trip() {
pause();
assert!(PAUSED.load(Ordering::SeqCst));
resume();
assert!(!PAUSED.load(Ordering::SeqCst));
}
#[test]
fn enabled_off_when_unset() {
// Side-effect free: just check the parse logic.
std::env::remove_var("JARVIS_IDLE_BANTER");
assert!(!enabled());
}
}

View file

@ -1,8 +1,5 @@
mod events;
mod server;
#[cfg(test)]
mod tests;
pub use events::{IpcAction, IpcEvent};
pub use server::{init, send, set_action_handler, start_server, has_clients, IPC_ADDR, IPC_PORT};

View file

@ -36,18 +36,6 @@ pub enum IpcEvent {
// request GUI to reveal/focus window
RevealWindow,
// Snapshot of daemon's runtime state (response to QueryHealth).
HealthSnapshot {
tts_backend: String,
llm_backend: String,
llm_model: Option<String>,
active_profile: String,
memory_facts: usize,
scheduled_tasks: usize,
language: String,
version: Option<String>,
},
}
// Actions sent from GUI to jarvis-app
@ -68,18 +56,4 @@ pub enum IpcAction {
// Execute text command
TextCommand { text: String },
// Daemon-side LLM hot-swap (GUI's set_llm_backend should fire this too
// so the running daemon picks the new backend, not just the GUI process).
SwitchLlm { backend: String },
// Reload LLM backend from settings DB (after GUI changed it independently).
ReloadLlm,
// Daemon-side TTS hot-swap. GUI fires this after persisting to DB so the
// running daemon installs the new backend without a restart.
SwitchTts { backend: String },
// Request a health snapshot — daemon responds via IpcEvent::HealthSnapshot.
QueryHealth,
}

View file

@ -1,203 +0,0 @@
//! IPC event/action serialization tests.
//!
//! The frontend (Svelte) is wired to specific JSON shapes — adding/renaming
//! a variant without thinking can silently break the websocket protocol.
//! These tests pin the wire format.
#[cfg(test)]
mod tests {
use super::super::events::{IpcEvent, IpcAction};
// Note: in the crate layout `ipc/mod.rs` is at `ipc.rs` (single-file
// module) and `events` is a sibling submodule, so `super::super::events`
// works from inside this nested `mod tests` block.
fn ser(e: &IpcEvent) -> String {
serde_json::to_string(e).unwrap()
}
fn de_action(s: &str) -> IpcAction {
serde_json::from_str(s).unwrap()
}
// ── IpcEvent ─────────────────────────────────────────────────────────
#[test]
fn event_wake_word_detected_uses_snake_case_tag() {
let s = ser(&IpcEvent::WakeWordDetected);
assert!(s.contains(r#""event":"wake_word_detected""#));
}
#[test]
fn event_listening_serializes() {
let s = ser(&IpcEvent::Listening);
assert!(s.contains(r#""event":"listening""#));
}
#[test]
fn event_speech_recognized_carries_text() {
let s = ser(&IpcEvent::SpeechRecognized { text: "привет".into() });
assert!(s.contains(r#""event":"speech_recognized""#));
assert!(s.contains(r#""text":"привет""#));
}
#[test]
fn event_llm_reply_carries_text() {
let s = ser(&IpcEvent::LlmReply { text: "hello".into() });
assert!(s.contains(r#""event":"llm_reply""#));
assert!(s.contains(r#""text":"hello""#));
}
#[test]
fn event_command_executed_carries_id_and_success() {
let s = ser(&IpcEvent::CommandExecuted {
id: "volume.set".into(),
success: true,
});
assert!(s.contains(r#""event":"command_executed""#));
assert!(s.contains(r#""id":"volume.set""#));
assert!(s.contains(r#""success":true"#));
}
#[test]
fn event_idle_minimal_payload() {
let s = ser(&IpcEvent::Idle);
assert!(s.contains(r#""event":"idle""#));
// Should have no extra fields beyond the tag.
let v: serde_json::Value = serde_json::from_str(&s).unwrap();
assert_eq!(v.as_object().unwrap().len(), 1);
}
#[test]
fn event_error_carries_message() {
let s = ser(&IpcEvent::Error { message: "boom".into() });
assert!(s.contains(r#""event":"error""#));
assert!(s.contains(r#""message":"boom""#));
}
#[test]
fn event_health_snapshot_full_shape() {
let s = ser(&IpcEvent::HealthSnapshot {
tts_backend: "piper".into(),
llm_backend: "groq".into(),
llm_model: Some("llama-3.3-70b-versatile".into()),
active_profile: "work".into(),
memory_facts: 12,
scheduled_tasks: 3,
language: "ru".into(),
version: Some("0.0.1".into()),
});
assert!(s.contains(r#""event":"health_snapshot""#));
assert!(s.contains(r#""tts_backend":"piper""#));
assert!(s.contains(r#""llm_backend":"groq""#));
assert!(s.contains(r#""llm_model":"llama-3.3-70b-versatile""#));
assert!(s.contains(r#""active_profile":"work""#));
assert!(s.contains(r#""memory_facts":12"#));
assert!(s.contains(r#""scheduled_tasks":3"#));
assert!(s.contains(r#""language":"ru""#));
}
#[test]
fn event_health_snapshot_handles_null_model() {
let s = ser(&IpcEvent::HealthSnapshot {
tts_backend: "sapi".into(),
llm_backend: "none".into(),
llm_model: None,
active_profile: "default".into(),
memory_facts: 0,
scheduled_tasks: 0,
language: "ru".into(),
version: None,
});
assert!(s.contains(r#""llm_model":null"#));
assert!(s.contains(r#""version":null"#));
}
// ── IpcAction ────────────────────────────────────────────────────────
#[test]
fn action_stop_parses() {
let a = de_action(r#"{"action":"stop"}"#);
assert!(matches!(a, IpcAction::Stop));
}
#[test]
fn action_ping_parses() {
let a = de_action(r#"{"action":"ping"}"#);
assert!(matches!(a, IpcAction::Ping));
}
#[test]
fn action_text_command_parses_payload() {
let a = de_action(r#"{"action":"text_command","text":"какая погода"}"#);
match a {
IpcAction::TextCommand { text } => assert_eq!(text, "какая погода"),
_ => panic!("wrong variant"),
}
}
#[test]
fn action_set_muted_parses_bool() {
let a = de_action(r#"{"action":"set_muted","muted":true}"#);
match a {
IpcAction::SetMuted { muted } => assert!(muted),
_ => panic!("wrong variant"),
}
}
#[test]
fn action_switch_llm_parses_backend_name() {
let a = de_action(r#"{"action":"switch_llm","backend":"ollama"}"#);
match a {
IpcAction::SwitchLlm { backend } => assert_eq!(backend, "ollama"),
_ => panic!("wrong variant"),
}
}
#[test]
fn action_reload_llm_parses() {
let a = de_action(r#"{"action":"reload_llm"}"#);
assert!(matches!(a, IpcAction::ReloadLlm));
}
#[test]
fn action_query_health_parses() {
let a = de_action(r#"{"action":"query_health"}"#);
assert!(matches!(a, IpcAction::QueryHealth));
}
#[test]
fn action_unknown_variant_fails() {
let r = serde_json::from_str::<IpcAction>(r#"{"action":"do_a_barrel_roll"}"#);
assert!(r.is_err());
}
#[test]
fn action_missing_required_field_fails() {
// switch_llm requires "backend" payload
let r = serde_json::from_str::<IpcAction>(r#"{"action":"switch_llm"}"#);
assert!(r.is_err());
}
// ── Cross-field stability ────────────────────────────────────────────
#[test]
fn event_tag_field_is_always_event() {
// Documented contract: every event has an "event" key with snake-case tag.
for variant in [
IpcEvent::WakeWordDetected,
IpcEvent::Listening,
IpcEvent::Idle,
IpcEvent::Started,
IpcEvent::Stopping,
IpcEvent::Pong,
IpcEvent::RevealWindow,
] {
let s = ser(&variant);
let v: serde_json::Value = serde_json::from_str(&s).unwrap();
assert!(v.get("event").is_some(), "missing 'event' tag in: {}", s);
assert!(v["event"].as_str().unwrap().chars()
.all(|c| c.is_ascii_lowercase() || c == '_'),
"non-snake-case tag in: {}", s);
}
}
}

View file

@ -48,31 +48,6 @@ pub mod audio_buffer;
#[cfg(feature = "lua")]
pub mod lua;
pub mod text_utils;
pub mod runtime_config;
pub mod tts;
pub mod long_term_memory;
pub mod profiles;
pub mod scheduler;
pub mod macros;
pub mod plugins;
pub mod wake_trainer;
pub mod idle_banter;
pub mod recognition_log;
#[cfg(feature = "lua")]
pub mod toast;
#[cfg(feature = "llm")]
pub mod llm;

View file

@ -3,7 +3,7 @@ use std::sync::Mutex;
use once_cell::sync::OnceCell;
use rustpotter::Rustpotter;
use crate::{config, APP_CONFIG_DIR, DB};
use crate::config;
// store rustpotter instance
static RUSTPOTTER: OnceCell<Mutex<Rustpotter>> = OnceCell::new();
@ -12,87 +12,38 @@ pub fn init() -> Result<(), ()> {
let rustpotter_config = config::RUSTPOTTER_DEFAULT_CONFIG;
// create rustpotter instance
let mut rinstance = match Rustpotter::new(&rustpotter_config) {
Ok(r) => r,
match Rustpotter::new(&rustpotter_config) {
Ok(mut rinstance) => {
// success
// wake word files list
// @TODO. Make it configurable via GUI for custom user voice.
let rustpotter_wake_word_files: [&str; 1] = [
"resources/rustpotter/jarvis-default.rpw",
// "rustpotter/jarvis-community-1.rpw",
// "rustpotter/jarvis-community-2.rpw",
// "rustpotter/jarvis-community-3.rpw",
// "rustpotter/jarvis-community-4.rpw",
// "rustpotter/jarvis-community-5.rpw",
];
// load wake word files
for rpw in rustpotter_wake_word_files {
// @TODO: Change wakeword key to something else?
if let Err(e) = rinstance.add_wakeword_from_file(rpw, rpw) {
error!("Failed to load wakeword file '{}': {}", rpw, e);
}
}
// store
let _ = RUSTPOTTER.set(Mutex::new(rinstance));
}
Err(msg) => {
error!("Rustpotter failed to initialize.\nError details: {}", msg);
return Err(());
}
};
// Rustpotter accepts MULTIPLE wake-word models simultaneously — each
// trained on a different voice / acoustic profile. We load:
//
// 1. The bundled default — works for everyone but isn't tuned for
// this user's exact voice/mic/room.
// 2. EVERY user-trained .rpw file in APP_CONFIG_DIR/wake_words/.
// The user creates these via /wake-trainer; each adds robustness
// to detection without disabling the default.
// 3. (Legacy) settings.custom_wake_word — kept for back-compat when
// the user used to pin one specific custom model. If still set,
// it's just one of the files already loaded in step 2 — no-op.
let mut loaded_any = false;
const DEFAULT: &str = "resources/rustpotter/jarvis-default.rpw";
match rinstance.add_wakeword_from_file(DEFAULT, DEFAULT) {
Ok(_) => {
info!("Loaded bundled wake-word: {}", DEFAULT);
loaded_any = true;
}
Err(e) => warn!("Default wakeword unavailable ({}): {}", DEFAULT, e),
}
if let Some(cfg_dir) = APP_CONFIG_DIR.get() {
let trained_dir = cfg_dir.join("wake_words");
if let Ok(read) = std::fs::read_dir(&trained_dir) {
let mut count = 0usize;
for entry in read.flatten() {
let p = entry.path();
if p.extension().and_then(|e| e.to_str()) != Some("rpw") {
continue;
}
let path_str = p.to_string_lossy().to_string();
match rinstance.add_wakeword_from_file(&path_str, &path_str) {
Ok(_) => {
info!("Loaded user-trained wake-word: {}", path_str);
loaded_any = true;
count += 1;
}
Err(e) => warn!("Skip user wake-word '{}': {}", path_str, e),
}
}
if count > 0 {
info!("{} user-trained wake-word model(s) active", count);
}
}
}
// Surface the legacy `custom_wake_word` setting if it points somewhere
// ELSE than wake_words/. Pre-step-2 installs may have set this to an
// arbitrary location; we still honour it.
if let Some(db) = DB.get() {
let stem = db.read().custom_wake_word.clone();
if !stem.is_empty() {
if let Some(cfg_dir) = APP_CONFIG_DIR.get() {
let inferred = cfg_dir.join("wake_words").join(format!("{}.rpw", stem));
if inferred.is_file() {
// Already loaded in step 2 above. No-op.
} else {
warn!(
"settings.custom_wake_word = '{}' but {} not present (using bundled + trained only)",
stem,
inferred.display()
);
}
}
}
}
if !loaded_any {
error!("No wake-word models loaded; Rustpotter will not detect anything.");
}
let _ = RUSTPOTTER.set(Mutex::new(rinstance));
Ok(())
}

View file

@ -2,22 +2,12 @@ use serde::{Deserialize, Serialize};
use std::env;
use thiserror::Error;
// Groq (cloud, fast, free tier).
pub const DEFAULT_BASE_URL: &str = "https://api.groq.com/openai/v1";
pub const DEFAULT_MODEL: &str = "llama-3.3-70b-versatile";
pub const ENV_TOKEN: &str = "GROQ_TOKEN";
pub const ENV_BASE_URL: &str = "GROQ_BASE_URL";
pub const ENV_MODEL: &str = "GROQ_MODEL";
// Ollama (local, offline, privacy). Uses OpenAI-compatible /v1 endpoint.
pub const OLLAMA_DEFAULT_BASE_URL: &str = "http://localhost:11434/v1";
pub const OLLAMA_DEFAULT_MODEL: &str = "qwen2.5:3b";
pub const ENV_OLLAMA_BASE_URL: &str = "OLLAMA_BASE_URL";
pub const ENV_OLLAMA_MODEL: &str = "OLLAMA_MODEL";
// Top-level switch: JARVIS_LLM=groq|ollama (default: groq if GROQ_TOKEN set, else ollama).
pub const ENV_LLM_BACKEND: &str = "JARVIS_LLM";
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("missing environment variable: {0}")]
@ -80,25 +70,9 @@ struct ResponseMessage {
content: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LlmBackend {
Groq,
Ollama,
}
impl LlmBackend {
pub fn name(&self) -> &'static str {
match self {
LlmBackend::Groq => "groq",
LlmBackend::Ollama => "ollama",
}
}
}
pub struct LlmClient {
backend: LlmBackend,
base_url: String,
api_key: String, // empty for Ollama
api_key: String,
model: String,
http: reqwest::blocking::Client,
}
@ -108,64 +82,20 @@ impl LlmClient {
base_url: impl Into<String>,
api_key: impl Into<String>,
model: impl Into<String>,
) -> Self {
Self::new_with_backend(LlmBackend::Groq, base_url, api_key, model)
}
pub fn new_with_backend(
backend: LlmBackend,
base_url: impl Into<String>,
api_key: impl Into<String>,
model: impl Into<String>,
) -> Self {
Self {
backend,
base_url: base_url.into(),
api_key: api_key.into(),
model: model.into(),
http: reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(60))
.build()
.unwrap_or_else(|_| reqwest::blocking::Client::new()),
http: reqwest::blocking::Client::new(),
}
}
pub fn groq() -> Result<Self, ConfigError> {
pub fn from_env() -> Result<Self, ConfigError> {
let api_key = env::var(ENV_TOKEN).map_err(|_| ConfigError::MissingEnv(ENV_TOKEN))?;
let base_url = env::var(ENV_BASE_URL).unwrap_or_else(|_| DEFAULT_BASE_URL.to_string());
let model = env::var(ENV_MODEL).unwrap_or_else(|_| DEFAULT_MODEL.to_string());
Ok(Self::new_with_backend(LlmBackend::Groq, base_url, api_key, model))
}
pub fn ollama() -> Self {
let base_url = env::var(ENV_OLLAMA_BASE_URL).unwrap_or_else(|_| OLLAMA_DEFAULT_BASE_URL.to_string());
let model = env::var(ENV_OLLAMA_MODEL).unwrap_or_else(|_| OLLAMA_DEFAULT_MODEL.to_string());
Self::new_with_backend(LlmBackend::Ollama, base_url, "", model)
}
/// Pick a backend based on `JARVIS_LLM` env var; auto-detect if unset.
/// Default order: explicit JARVIS_LLM, else Groq if GROQ_TOKEN present, else Ollama.
pub fn from_env() -> Result<Self, ConfigError> {
match env::var(ENV_LLM_BACKEND).ok().map(|s| s.trim().to_lowercase()).as_deref() {
Some("ollama") => Ok(Self::ollama()),
Some("groq") => Self::groq(),
Some("") | None => {
// Auto: Groq if token present, else Ollama
if env::var(ENV_TOKEN).is_ok() {
Self::groq()
} else {
Ok(Self::ollama())
}
}
Some(other) => {
log::warn!("Unknown JARVIS_LLM='{}'; falling back to Groq", other);
Self::groq()
}
}
}
pub fn backend(&self) -> LlmBackend {
self.backend
Ok(Self::new(base_url, api_key, model))
}
pub fn model(&self) -> &str {
@ -173,30 +103,21 @@ impl LlmClient {
}
pub fn complete(&self, messages: &[ChatMessage], max_tokens: u32) -> Result<String, LlmError> {
self.complete_with(messages, max_tokens, 0.7, 1.0)
}
pub fn complete_with(
&self,
messages: &[ChatMessage],
max_tokens: u32,
temperature: f32,
top_p: f32,
) -> Result<String, LlmError> {
let url = format!("{}/chat/completions", self.base_url.trim_end_matches('/'));
let body = ChatRequest {
model: &self.model,
messages,
max_tokens,
temperature,
top_p,
temperature: 0.7,
top_p: 1.0,
};
let mut req = self.http.post(&url).json(&body);
if !self.api_key.is_empty() {
req = req.bearer_auth(&self.api_key);
}
let resp = req.send()?;
let resp = self
.http
.post(&url)
.bearer_auth(&self.api_key)
.json(&body)
.send()?;
let status = resp.status();
let text = resp.text()?;
@ -242,10 +163,10 @@ mod tests {
}
#[test]
fn groq_fails_when_token_missing() {
fn from_env_fails_when_token_missing() {
let prev = env::var(ENV_TOKEN).ok();
unsafe { env::remove_var(ENV_TOKEN); }
let result = LlmClient::groq();
let result = LlmClient::from_env();
let err = match result {
Ok(_) => panic!("expected ConfigError when {ENV_TOKEN} is unset"),
Err(e) => e,
@ -258,92 +179,10 @@ mod tests {
}
}
#[test]
fn ollama_works_without_token() {
let client = LlmClient::ollama();
assert_eq!(client.backend(), LlmBackend::Ollama);
// Default model unless OLLAMA_MODEL overrides.
}
#[test]
fn chat_message_helpers_set_role() {
assert_eq!(ChatMessage::user("hi").role, "user");
assert_eq!(ChatMessage::system("ctx").role, "system");
assert_eq!(ChatMessage::assistant("ok").role, "assistant");
}
#[test]
fn chat_message_serde_round_trip() {
let m = ChatMessage::user("привет мир");
let json = serde_json::to_string(&m).unwrap();
assert!(json.contains(r#""role":"user""#));
assert!(json.contains(r#""content":"привет мир""#));
let back: ChatMessage = serde_json::from_str(&json).unwrap();
assert_eq!(back.role, "user");
assert_eq!(back.content, "привет мир");
}
#[test]
fn ollama_client_has_no_api_key() {
let c = LlmClient::ollama();
// We can't directly read api_key (private), but can verify backend.
assert_eq!(c.backend(), LlmBackend::Ollama);
}
#[test]
fn groq_client_carries_token() {
// SAFETY: tests are racy w/ env, but we use a unique-named token to scope.
unsafe {
env::set_var(ENV_TOKEN, "test-token-12345");
}
let c = LlmClient::groq().expect("with token, groq() should succeed");
assert_eq!(c.backend(), LlmBackend::Groq);
assert_eq!(c.model(), DEFAULT_MODEL);
// Don't unset — other tests may have set it; this is best-effort.
}
#[test]
fn ollama_default_model_can_be_overridden() {
unsafe {
env::set_var(ENV_OLLAMA_MODEL, "llama3.2:latest");
}
let c = LlmClient::ollama();
assert_eq!(c.model(), "llama3.2:latest");
unsafe { env::remove_var(ENV_OLLAMA_MODEL); }
}
#[test]
fn parses_response_with_unicode_content() {
let raw = r#"{
"choices": [
{ "message": { "role": "assistant", "content": "Доброе утро, сэр." } }
]
}"#;
let parsed: ChatResponse = serde_json::from_str(raw).unwrap();
assert_eq!(parsed.choices[0].message.content, "Доброе утро, сэр.");
}
#[test]
fn empty_choices_yields_empty_response_err() {
let raw = r#"{"choices":[]}"#;
let parsed: ChatResponse = serde_json::from_str(raw).unwrap();
assert!(parsed.choices.is_empty());
}
/// Smoke test that only runs when an Ollama server is reachable.
/// Run with: cargo test --lib llm::client::tests::ollama_smoke -- --ignored
#[test]
#[ignore]
fn ollama_smoke() {
let client = LlmClient::ollama();
match client.complete(&[ChatMessage::user("Say only 'hi' and nothing else.")], 20) {
Ok(reply) => {
println!("Ollama replied: {}", reply);
assert!(!reply.is_empty());
}
Err(e) => {
eprintln!("Ollama unreachable (expected if not running): {}", e);
}
}
}
}

View file

@ -1,15 +1,9 @@
use super::client::ChatMessage;
use std::fs;
use std::path::PathBuf;
pub struct ConversationHistory {
system: Option<ChatMessage>,
turns: Vec<ChatMessage>,
max_turns: usize,
/// If set, every push/clear/pop atomically writes the turns to this path,
/// so a daemon restart can pick up where the user left off. Excludes the
/// system prompt (which comes from the running config, not from disk).
persist_path: Option<PathBuf>,
}
impl ConversationHistory {
@ -18,7 +12,6 @@ impl ConversationHistory {
system: Some(ChatMessage::system(system_prompt)),
turns: Vec::new(),
max_turns: max_turns.max(1),
persist_path: None,
}
}
@ -27,42 +20,17 @@ impl ConversationHistory {
system: None,
turns: Vec::new(),
max_turns: max_turns.max(1),
persist_path: None,
}
}
/// Tell history where to persist on every mutation. Tries to load any
/// existing turns from `path` first so a daemon restart can pick up the
/// thread. The system prompt is NOT loaded — it always comes from the
/// current `new()` call so prompt edits take effect immediately.
pub fn with_persistence<P: Into<PathBuf>>(mut self, path: P) -> Self {
let p = path.into();
if let Ok(text) = fs::read_to_string(&p) {
if let Ok(turns) = serde_json::from_str::<Vec<ChatMessage>>(&text) {
for m in turns {
if m.role == "user" {
self.turns.push(ChatMessage::user(m.content));
} else if m.role == "assistant" {
self.turns.push(ChatMessage::assistant(m.content));
}
}
self.truncate();
}
}
self.persist_path = Some(p);
self
}
pub fn push_user(&mut self, content: impl Into<String>) {
self.turns.push(ChatMessage::user(content));
self.truncate();
self.persist();
}
pub fn push_assistant(&mut self, content: impl Into<String>) {
self.turns.push(ChatMessage::assistant(content));
self.truncate();
self.persist();
}
pub fn snapshot(&self) -> Vec<ChatMessage> {
@ -80,64 +48,22 @@ impl ConversationHistory {
pub fn clear(&mut self) {
self.turns.clear();
self.persist();
}
pub fn pop_last_user(&mut self) -> Option<ChatMessage> {
if matches!(self.turns.last(), Some(m) if m.role == "user") {
let popped = self.turns.pop();
self.persist();
popped
self.turns.pop()
} else {
None
}
}
/// Return the most recent assistant message, or None if none yet.
pub fn last_assistant(&self) -> Option<&ChatMessage> {
self.turns.iter().rev().find(|m| m.role == "assistant")
}
fn truncate(&mut self) {
if self.turns.len() > self.max_turns {
let drop = self.turns.len() - self.max_turns;
self.turns.drain(0..drop);
}
}
/// Atomically write turns to `persist_path` if set. Best-effort: errors
/// are logged but never panic — chat history is convenience, not critical.
fn persist(&self) {
let path = match &self.persist_path {
Some(p) => p,
None => return,
};
let json = match serde_json::to_string_pretty(&self.turns) {
Ok(s) => s,
Err(e) => {
log::warn!("LLM history serialise failed: {}", e);
return;
}
};
let tmp = path.with_extension("json.tmp");
if let Err(e) = fs::write(&tmp, json) {
log::warn!("LLM history write {} failed: {}", tmp.display(), e);
return;
}
if let Err(e) = fs::rename(&tmp, path) {
log::warn!("LLM history rename {} → {} failed: {}", tmp.display(), path.display(), e);
}
}
/// Number of stored turns (excluding the system prompt). Public so callers
/// can show a "remembered N exchanges" hint in the GUI/voice.
pub fn len(&self) -> usize {
self.turns.len()
}
pub fn is_empty(&self) -> bool {
self.turns.is_empty()
}
}
#[cfg(test)]
@ -209,68 +135,4 @@ mod tests {
assert_eq!(snap.len(), 1);
assert_eq!(snap[0].role, "system");
}
#[test]
fn len_and_is_empty_track_turns_only() {
let mut h = ConversationHistory::new("sys", 4);
assert!(h.is_empty());
assert_eq!(h.len(), 0);
h.push_user("u1");
assert_eq!(h.len(), 1);
h.push_assistant("a1");
assert_eq!(h.len(), 2);
h.clear();
assert!(h.is_empty());
}
#[test]
fn persistence_round_trip_via_file() {
let dir = tempfile::TempDir::new().expect("tempdir");
let path = dir.path().join("history.json");
{
let mut h = ConversationHistory::new("you are jarvis", 8)
.with_persistence(&path);
h.push_user("привет");
h.push_assistant("Слушаю, сэр.");
h.push_user("сколько время");
}
// File written on each mutation; tempdir still alive until end of test.
assert!(path.is_file(), "persist file must exist");
let restored = ConversationHistory::new("you are jarvis", 8)
.with_persistence(&path);
let snap = restored.snapshot();
// 1 system + 3 turns
assert_eq!(snap.len(), 4);
assert_eq!(snap[1].role, "user");
assert_eq!(snap[1].content, "привет");
assert_eq!(snap[2].role, "assistant");
assert_eq!(snap[3].content, "сколько время");
}
#[test]
fn clear_wipes_persisted_file_contents() {
let dir = tempfile::TempDir::new().expect("tempdir");
let path = dir.path().join("history.json");
let mut h = ConversationHistory::new("sys", 4)
.with_persistence(&path);
h.push_user("u");
h.push_assistant("a");
h.clear();
let restored = ConversationHistory::new("sys", 4)
.with_persistence(&path);
assert!(restored.is_empty(), "restored history must be empty");
}
#[test]
fn corrupt_persist_file_is_tolerated() {
let dir = tempfile::TempDir::new().expect("tempdir");
let path = dir.path().join("history.json");
std::fs::write(&path, b"this is not json {{{").unwrap();
let h = ConversationHistory::new("sys", 4).with_persistence(&path);
// Garbage in file → start fresh, don't panic.
assert!(h.is_empty());
}
}

View file

@ -2,195 +2,7 @@ mod client;
mod history;
pub use client::{
ChatMessage, ConfigError, LlmBackend, LlmClient, LlmError,
ChatMessage, ConfigError, LlmClient, LlmError,
DEFAULT_BASE_URL, DEFAULT_MODEL, ENV_BASE_URL, ENV_MODEL, ENV_TOKEN,
OLLAMA_DEFAULT_BASE_URL, OLLAMA_DEFAULT_MODEL,
};
pub use history::ConversationHistory;
use once_cell::sync::Lazy;
use parking_lot::RwLock;
use std::sync::Arc;
// ─── Shared conversation history ───────────────────────────────────────────
//
// Single global history so the LLM fallback chat, the Lua reset/repeat helpers,
// and any future IPC handler all operate on the same buffer. Initialised on
// boot via `init_history(system_prompt, max_turns)`.
static HISTORY: Lazy<RwLock<Option<ConversationHistory>>> = Lazy::new(|| RwLock::new(None));
const HISTORY_FILE: &str = "llm_history.json";
pub fn init_history(system_prompt: impl Into<String>, max_turns: usize) {
let mut h = ConversationHistory::new(system_prompt, max_turns);
if let Some(cfg) = crate::APP_CONFIG_DIR.get() {
let path = cfg.join(HISTORY_FILE);
h = h.with_persistence(path);
log::info!(
"LLM conversation history persistence enabled ({} turns restored)",
h.len()
);
}
*HISTORY.write() = Some(h);
}
pub fn history_push_user(content: impl Into<String>) {
if let Some(h) = HISTORY.write().as_mut() {
h.push_user(content);
}
}
pub fn history_push_assistant(content: impl Into<String>) {
if let Some(h) = HISTORY.write().as_mut() {
h.push_assistant(content);
}
}
pub fn history_snapshot() -> Vec<ChatMessage> {
HISTORY.read().as_ref().map(|h| h.snapshot()).unwrap_or_default()
}
pub fn history_clear() {
if let Some(h) = HISTORY.write().as_mut() {
h.clear();
}
}
pub fn history_pop_last_user() {
if let Some(h) = HISTORY.write().as_mut() {
h.pop_last_user();
}
}
/// Return the most recent assistant message text, or None.
pub fn history_last_assistant() -> Option<String> {
HISTORY.read().as_ref()
.and_then(|h| h.last_assistant().map(|m| m.content.clone()))
}
/// Shared mutable LLM client. Modules read via `current()`; the user can
/// hot-swap backends at runtime via `swap_to(LlmBackend::Ollama)` etc.
///
/// `None` means LLM is disabled (no GROQ_TOKEN and no Ollama reachable, or
/// `init_global()` was never called).
static GLOBAL: Lazy<RwLock<Option<Arc<LlmClient>>>> = Lazy::new(|| RwLock::new(None));
/// Initialise the global client. Precedence:
/// 1. Settings DB `llm_backend` field (if non-empty)
/// 2. `JARVIS_LLM` env var
/// 3. Auto-detect (Groq if `GROQ_TOKEN`, else Ollama)
///
/// Idempotent — replaces any previous global. Returns Ok even when the chosen
/// backend can't be probed: an Ollama server may not be running yet but we
/// stash the client anyway and let calls fail on first request.
pub fn init_global() -> Result<(), ConfigError> {
// Try DB-persisted choice first.
let persisted = crate::DB.get().and_then(|db| {
let s = db.read();
let backend = s.llm_backend.trim().to_string();
if backend.is_empty() { None } else { parse_backend(&backend) }
});
let c = match persisted {
Some(LlmBackend::Groq) => LlmClient::groq()?,
Some(LlmBackend::Ollama) => LlmClient::ollama(),
None => LlmClient::from_env()?,
};
log::info!("LLM global initialised: backend={}, model={}", c.backend().name(), c.model());
*GLOBAL.write() = Some(Arc::new(c));
Ok(())
}
/// Return a clone of the active client. Cheap (Arc).
pub fn current() -> Option<Arc<LlmClient>> {
GLOBAL.read().clone()
}
/// Name of the currently active backend, or "none" if not initialised.
pub fn current_backend_name() -> &'static str {
match GLOBAL.read().as_ref().map(|c| c.backend()) {
Some(LlmBackend::Groq) => "groq",
Some(LlmBackend::Ollama) => "ollama",
None => "none",
}
}
/// Hot-swap to a different backend. Returns the new backend's name on success.
/// Persists the choice to the settings DB so it survives restart.
pub fn swap_to(backend: LlmBackend) -> Result<&'static str, ConfigError> {
let c = match backend {
LlmBackend::Groq => LlmClient::groq()?,
LlmBackend::Ollama => LlmClient::ollama(),
};
let name = c.backend().name();
log::info!("LLM swapped → backend={}, model={}", name, c.model());
*GLOBAL.write() = Some(Arc::new(c));
// Persist to settings DB (best-effort — log on failure, don't propagate).
if let Some(db) = crate::DB.get() {
let snapshot = {
let mut s = db.write();
s.llm_backend = name.to_string();
s.clone()
};
if let Err(e) = crate::db::save_settings(&snapshot) {
log::warn!("LLM swap: failed to persist to settings DB: {}", e);
} else {
log::info!("LLM choice persisted: {}", name);
}
}
Ok(name)
}
/// Parse a backend name (case-insensitive) into the enum.
pub fn parse_backend(name: &str) -> Option<LlmBackend> {
match name.trim().to_lowercase().as_str() {
"groq" | "cloud" | "облако" | "клауд" => Some(LlmBackend::Groq),
"ollama" | "local" | "локал" | "локальн" | "локальный" => Some(LlmBackend::Ollama),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_backend_accepts_english() {
assert_eq!(parse_backend("groq"), Some(LlmBackend::Groq));
assert_eq!(parse_backend("ollama"), Some(LlmBackend::Ollama));
assert_eq!(parse_backend("cloud"), Some(LlmBackend::Groq));
assert_eq!(parse_backend("local"), Some(LlmBackend::Ollama));
}
#[test]
fn parse_backend_accepts_russian() {
assert_eq!(parse_backend("облако"), Some(LlmBackend::Groq));
assert_eq!(parse_backend("локальный"), Some(LlmBackend::Ollama));
assert_eq!(parse_backend("локал"), Some(LlmBackend::Ollama));
}
#[test]
fn parse_backend_case_insensitive() {
assert_eq!(parse_backend("OLLAMA"), Some(LlmBackend::Ollama));
assert_eq!(parse_backend(" Groq "), Some(LlmBackend::Groq));
}
#[test]
fn parse_backend_rejects_unknown() {
assert_eq!(parse_backend("openai"), None);
assert_eq!(parse_backend("claude"), None);
assert_eq!(parse_backend(""), None);
}
#[test]
fn current_backend_name_when_uninitialised_is_none() {
// Note: in the same process as other tests, GLOBAL may have been
// initialised by ollama_works_without_token. So this assertion is
// best-effort — just exercise the code path.
let _ = current_backend_name();
}
}

View file

@ -1,366 +0,0 @@
//! IMBA-2: Long-term memory store.
//!
//! Persistent fact storage for J.A.R.V.I.S.. Lets the user say things like
//! "запомни что у меня собака Бася" / "я люблю чай улун" and have the
//! assistant recall them in later sessions.
//!
//! Storage: JSON file at `<APP_CONFIG_DIR>/long_term_memory.json`. Atomic
//! write-through (write-then-rename) to survive crashes. Simple substring
//! search for v1; vector search via fastembed is a future upgrade.
//!
//! Lua access:
//! jarvis.memory.remember(key, value)
//! jarvis.memory.recall(key) -- returns value or nil
//! jarvis.memory.search(query, n?) -- returns array of {key, value} matches
//! jarvis.memory.forget(key)
//! jarvis.memory.all() -- returns full table (debug)
//!
//! On-disk record fields: key, value, created_at, last_used_at, use_count.
//! Auto-decay: entries unused for 90 days are returned last in search results
//! (not deleted automatically; explicit `forget` only).
use once_cell::sync::OnceCell;
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::PathBuf;
use crate::APP_CONFIG_DIR;
const FILE_NAME: &str = "long_term_memory.json";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MemoryRecord {
pub key: String,
pub value: String,
pub created_at: i64,
pub last_used_at: i64,
#[serde(default)]
pub use_count: u64,
}
#[derive(Default, Debug, Serialize, Deserialize)]
struct Store {
#[serde(default)]
entries: BTreeMap<String, MemoryRecord>,
}
struct State {
path: PathBuf,
store: RwLock<Store>,
}
static STATE: OnceCell<State> = OnceCell::new();
pub fn init() -> Result<(), String> {
if STATE.get().is_some() {
return Ok(());
}
let dir = APP_CONFIG_DIR
.get()
.ok_or_else(|| "APP_CONFIG_DIR not initialised".to_string())?;
let path = dir.join(FILE_NAME);
let store = load_or_empty(&path);
info!(
"Long-term memory loaded: {} entries from {}",
store.entries.len(),
path.display()
);
STATE.set(State { path, store: RwLock::new(store) })
.map_err(|_| "long_term_memory already initialised".to_string())?;
Ok(())
}
fn load_or_empty(path: &PathBuf) -> Store {
if !path.is_file() {
return Store::default();
}
match std::fs::read_to_string(path) {
Ok(s) => match serde_json::from_str::<Store>(&s) {
Ok(store) => store,
Err(e) => {
warn!("Corrupt memory file {}: {} — starting empty", path.display(), e);
Store::default()
}
},
Err(e) => {
warn!("Cannot read {}: {} — starting empty", path.display(), e);
Store::default()
}
}
}
fn persist(state: &State) {
let store = state.store.read();
let tmp = state.path.with_extension("json.tmp");
let json = match serde_json::to_string_pretty(&*store) {
Ok(s) => s,
Err(e) => {
warn!("Memory serialize failed: {}", e);
return;
}
};
if let Err(e) = std::fs::write(&tmp, json) {
warn!("Memory write failed to {}: {}", tmp.display(), e);
return;
}
if let Err(e) = std::fs::rename(&tmp, &state.path) {
warn!("Memory rename failed: {}", e);
}
}
fn now_secs() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
pub(crate) fn normalize_key(k: &str) -> String {
k.trim().to_lowercase()
}
/// Filter+rank logic shared between `search` and tests. Public for unit tests
/// to bypass the global state.
pub(crate) fn search_in<'a>(
entries: impl IntoIterator<Item = &'a MemoryRecord>,
query: &str,
limit: usize,
) -> Vec<MemoryRecord> {
let nq = normalize_key(query);
if nq.is_empty() {
return Vec::new();
}
let mut hits: Vec<MemoryRecord> = entries.into_iter()
.filter(|r| r.key.contains(&nq) || r.value.to_lowercase().contains(&nq))
.cloned()
.collect();
hits.sort_by(|a, b| b.last_used_at.cmp(&a.last_used_at));
hits.truncate(limit.max(1));
hits
}
/// Store / overwrite a fact.
pub fn remember(key: &str, value: &str) -> Result<(), String> {
let state = STATE.get().ok_or_else(|| "memory not init".to_string())?;
let nk = normalize_key(key);
if nk.is_empty() {
return Err("empty key".into());
}
let now = now_secs();
{
let mut store = state.store.write();
let entry = store.entries.entry(nk.clone()).or_insert(MemoryRecord {
key: nk.clone(),
value: value.to_string(),
created_at: now,
last_used_at: now,
use_count: 0,
});
entry.value = value.to_string();
entry.last_used_at = now;
}
persist(state);
info!("Memory: remembered '{}' = '{}'", nk, value);
Ok(())
}
/// Retrieve a fact by exact key (case-insensitive).
pub fn recall(key: &str) -> Option<String> {
let state = STATE.get()?;
let nk = normalize_key(key);
let now = now_secs();
let value = {
let mut store = state.store.write();
let entry = store.entries.get_mut(&nk)?;
entry.last_used_at = now;
entry.use_count += 1;
entry.value.clone()
};
persist(state);
Some(value)
}
/// Substring search (case-insensitive). Returns up to `limit` records ranked by
/// recency. Matches against both key and value.
pub fn search(query: &str, limit: usize) -> Vec<MemoryRecord> {
let Some(state) = STATE.get() else { return Vec::new(); };
let store = state.store.read();
search_in(store.entries.values(), query, limit)
}
/// Delete a fact. Returns true if it existed.
pub fn forget(key: &str) -> bool {
let Some(state) = STATE.get() else { return false; };
let nk = normalize_key(key);
let removed = state.store.write().entries.remove(&nk).is_some();
if removed {
persist(state);
info!("Memory: forgot '{}'", nk);
}
removed
}
/// Wipe all stored facts. Returns the count of records removed.
/// Voice commands call this only after a confirmation prefix
/// ("точно забудь все" — not just "забудь все") to avoid disasters.
pub fn clear_all() -> usize {
let Some(state) = STATE.get() else { return 0; };
let n = {
let mut store = state.store.write();
let n = store.entries.len();
store.entries.clear();
n
};
if n > 0 {
persist(state);
warn!("Memory: WIPED all {} facts", n);
}
n
}
/// Snapshot of all records (for debug / GUI display).
pub fn all() -> Vec<MemoryRecord> {
let Some(state) = STATE.get() else { return Vec::new(); };
state.store.read().entries.values().cloned().collect()
}
/// Build a system-prompt snippet containing relevant facts for the LLM,
/// based on a substring search of the user's prompt. Empty string if no matches.
pub fn build_context(prompt: &str, limit: usize) -> String {
let hits = search(prompt, limit);
build_context_from(&hits)
}
/// Pure formatter used by both `build_context` and tests.
pub(crate) fn build_context_from(hits: &[MemoryRecord]) -> String {
if hits.is_empty() {
return String::new();
}
let mut buf = String::with_capacity(256);
buf.push_str("Известные факты о пользователе (используй если уместно):\n");
for h in hits {
buf.push_str(&format!("- {} = {}\n", h.key, h.value));
}
buf
}
#[cfg(test)]
mod tests {
use super::*;
fn rec(key: &str, value: &str, last_used: i64) -> MemoryRecord {
MemoryRecord {
key: key.to_string(),
value: value.to_string(),
created_at: 0,
last_used_at: last_used,
use_count: 0,
}
}
#[test]
fn normalize_key_lowercases_and_trims() {
assert_eq!(normalize_key(" HeLLo "), "hello");
assert_eq!(normalize_key(""), "");
assert_eq!(normalize_key("любимый ЧАЙ "), "любимый чай");
}
#[test]
fn search_in_matches_key_or_value() {
let entries = vec![
rec("любимый чай", "улун", 100),
rec("любимый кофе", "арабика", 200),
rec("питомец", "собака бася", 300),
];
let hits = search_in(&entries, "чай", 5);
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].key, "любимый чай");
let hits = search_in(&entries, "бася", 5);
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].key, "питомец");
}
#[test]
fn search_in_ranks_by_recency() {
let entries = vec![
rec("чай улун", "вкусный", 100),
rec("чай зеленый", "тоже", 300),
rec("чай черный", "обычный", 200),
];
let hits = search_in(&entries, "чай", 5);
assert_eq!(hits.len(), 3);
assert_eq!(hits[0].key, "чай зеленый"); // most recent
assert_eq!(hits[1].key, "чай черный");
assert_eq!(hits[2].key, "чай улун");
}
#[test]
fn search_in_respects_limit() {
let entries = vec![
rec("a", "x", 1),
rec("ab", "y", 2),
rec("abc", "z", 3),
];
let hits = search_in(&entries, "a", 2);
assert_eq!(hits.len(), 2);
}
#[test]
fn search_in_empty_query_returns_nothing() {
let entries = vec![rec("a", "b", 0)];
assert!(search_in(&entries, "", 5).is_empty());
assert!(search_in(&entries, " ", 5).is_empty());
}
#[test]
fn build_context_empty_returns_empty_string() {
assert_eq!(build_context_from(&[]), "");
}
#[test]
fn build_context_lists_each_fact() {
let hits = vec![
rec("чай", "улун", 0),
rec("кофе", "арабика", 0),
];
let ctx = build_context_from(&hits);
assert!(ctx.contains("чай = улун"));
assert!(ctx.contains("кофе = арабика"));
assert!(ctx.starts_with("Известные факты"));
}
#[test]
fn memory_record_serde_round_trip() {
let r = rec("test", "value", 42);
let json = serde_json::to_string(&r).unwrap();
let back: MemoryRecord = serde_json::from_str(&json).unwrap();
assert_eq!(back.key, r.key);
assert_eq!(back.value, r.value);
assert_eq!(back.last_used_at, r.last_used_at);
}
#[test]
fn store_serde_round_trip() {
let mut store = Store::default();
store.entries.insert("key1".into(), rec("key1", "val1", 100));
store.entries.insert("key2".into(), rec("key2", "val2", 200));
let json = serde_json::to_string(&store).unwrap();
let back: Store = serde_json::from_str(&json).unwrap();
assert_eq!(back.entries.len(), 2);
assert_eq!(back.entries.get("key1").unwrap().value, "val1");
}
#[test]
fn clear_all_returns_zero_when_state_uninit() {
// STATE may or may not be initialised depending on test order; the
// public function still needs to return safely (and 0) in either case.
// We just exercise the code path — exact count depends on global state.
let _ = clear_all();
}
}

View file

@ -5,14 +5,3 @@ pub mod http;
pub mod fs;
pub mod state;
pub mod system;
pub mod tts;
pub mod llm;
pub mod text;
pub mod memory;
pub mod profile;
pub mod vision;
pub mod scheduler;
pub mod cmd;
pub mod health;
pub mod macros;
pub mod banter;

View file

@ -1,29 +0,0 @@
//! `jarvis.banter` — lua bindings for the idle-banter background module.
//!
//! Usage from voice packs:
//! jarvis.banter.fire() -- force-fire one remark now (returns bool)
//! jarvis.banter.pause() -- pause until resume()
//! jarvis.banter.resume()
//! jarvis.banter.enabled() -- true if env opt-in is set
use mlua::{Lua, Table};
use crate::idle_banter;
pub fn register(lua: &Lua, jarvis: &Table) -> mlua::Result<()> {
let b = lua.create_table()?;
b.set("fire", lua.create_function(|_, ()| Ok(idle_banter::fire_now()))?)?;
b.set("pause", lua.create_function(|_, ()| {
idle_banter::pause();
Ok(())
})?)?;
b.set("resume", lua.create_function(|_, ()| {
idle_banter::resume();
Ok(())
})?)?;
b.set("enabled", lua.create_function(|_, ()| Ok(idle_banter::enabled()))?)?;
jarvis.set("banter", b)?;
Ok(())
}

View file

@ -1,81 +0,0 @@
//! `jarvis.cmd` — boilerplate-killer helpers for Lua command scripts.
//!
//! Most packs end with the same 3-line ritual:
//!
//! jarvis.speak(msg)
//! jarvis.audio.play_ok()
//! return { chain = false }
//!
//! With these helpers it's one call:
//!
//! return jarvis.cmd.ok(msg)
//!
//! Available helpers (each returns a `{ chain = false }` result table so the
//! pack can simply `return jarvis.cmd.ok(...)`):
//!
//! jarvis.cmd.ok(msg?) -- play_ok sound, optional spoken msg
//! jarvis.cmd.chain_ok(msg?) -- like ok() but returns {chain = true}
//! jarvis.cmd.error(msg?) -- play_error sound, optional spoken msg
//! jarvis.cmd.not_found(msg?) -- play_not_found sound, optional spoken msg
//! jarvis.cmd.silent_ok() -- only play_ok, no speech
//! jarvis.cmd.silent_error() -- only play_error, no speech
use mlua::{Lua, Table};
use crate::tts::{self, SpeakOpts};
use crate::voices;
pub fn register(lua: &Lua, jarvis: &Table) -> mlua::Result<()> {
let cmd = lua.create_table()?;
cmd.set("ok", lua.create_function(|l, msg: Option<String>| {
if let Some(text) = msg.filter(|s| !s.trim().is_empty()) {
tts::speak(&text, &SpeakOpts::default());
}
voices::play_ok();
result_table(l, false)
})?)?;
cmd.set("chain_ok", lua.create_function(|l, msg: Option<String>| {
if let Some(text) = msg.filter(|s| !s.trim().is_empty()) {
tts::speak(&text, &SpeakOpts::default());
}
voices::play_ok();
result_table(l, true)
})?)?;
cmd.set("error", lua.create_function(|l, msg: Option<String>| {
if let Some(text) = msg.filter(|s| !s.trim().is_empty()) {
tts::speak(&text, &SpeakOpts::default());
}
voices::play_error();
result_table(l, false)
})?)?;
cmd.set("not_found", lua.create_function(|l, msg: Option<String>| {
if let Some(text) = msg.filter(|s| !s.trim().is_empty()) {
tts::speak(&text, &SpeakOpts::default());
}
voices::play_not_found();
result_table(l, false)
})?)?;
cmd.set("silent_ok", lua.create_function(|l, ()| {
voices::play_ok();
result_table(l, false)
})?)?;
cmd.set("silent_error", lua.create_function(|l, ()| {
voices::play_error();
result_table(l, false)
})?)?;
jarvis.set("cmd", cmd)?;
Ok(())
}
fn result_table(lua: &Lua, chain: bool) -> mlua::Result<Table> {
let t = lua.create_table()?;
t.set("chain", chain)?;
Ok(t)
}

View file

@ -1,50 +0,0 @@
//! `jarvis.health()` — debug snapshot of active runtime state.
//!
//! Returns a table that can be JSON-encoded for bug reports. Doesn't include
//! secrets (no API keys, no LLM history content).
//!
//! Example use from Lua:
//! local h = jarvis.health()
//! for k, v in pairs(h) do print(k, v) end
use mlua::{Lua, Table};
pub fn register(lua: &Lua, jarvis: &Table) -> mlua::Result<()> {
let health_fn = lua.create_function(|lua, ()| {
let t = lua.create_table()?;
// TTS
t.set("tts_backend", crate::tts::backend().name())?;
// LLM
t.set("llm_backend", crate::llm::current_backend_name())?;
if let Some(c) = crate::llm::current() {
t.set("llm_model", c.model().to_string())?;
}
// Profile
t.set("active_profile", crate::profiles::active_name())?;
// Memory size
t.set("memory_facts", crate::long_term_memory::all().len())?;
// Scheduler size
t.set("scheduled_tasks", crate::scheduler::list().len())?;
// Language
t.set("language", crate::i18n::get_language())?;
// Voice from settings
if let Some(db) = crate::DB.get() {
let s = db.read();
t.set("voice", s.voice.clone())?;
t.set("microphone", s.microphone)?;
t.set("vosk_model", s.vosk_model.clone())?;
t.set("noise_suppression", format!("{:?}", s.noise_suppression))?;
}
Ok(t)
})?;
jarvis.set("health", health_fn)?;
Ok(())
}

View file

@ -1,140 +0,0 @@
use mlua::{Lua, Table, Value};
use crate::llm::{self, ChatMessage};
// Lua-callable wrapper around jarvis_core::llm.
// Command scripts call:
// local reply = jarvis.llm(
// { {role='system', content='...'}, {role='user', content='...'} },
// { temperature = 0.0, max_tokens = 64 }
// )
// Returns assistant text (string) or nil on any failure.
//
// Backend selection is global (see `jarvis_core::llm::swap_to`). Lua scripts can:
// jarvis.llm_status() -> "groq" | "ollama" | "none"
// jarvis.llm_switch("ollama") -> name string on success, nil on failure
pub fn register(lua: &Lua, jarvis: &Table) -> mlua::Result<()> {
let chat_fn = lua.create_function(|lua, (messages, opts): (Table, Option<Table>)| {
let mut chat_messages = Vec::new();
for pair in messages.sequence_values::<Table>() {
let msg = pair?;
let role = msg.get::<String>("role").unwrap_or_else(|_| "user".to_string());
let content = msg.get::<String>("content").unwrap_or_default();
if content.is_empty() {
continue;
}
chat_messages.push(match role.as_str() {
"system" => ChatMessage::system(content),
"assistant" => ChatMessage::assistant(content),
_ => ChatMessage::user(content),
});
}
if chat_messages.is_empty() {
return Ok(Value::Nil);
}
let mut max_tokens: u32 = 256;
let mut temperature: f32 = 0.7;
let mut top_p: f32 = 1.0;
if let Some(o) = opts {
if let Ok(v) = o.get::<u32>("max_tokens") { max_tokens = v; }
if let Ok(v) = o.get::<f32>("temperature") { temperature = v; }
if let Ok(v) = o.get::<f32>("top_p") { top_p = v; }
}
// Prefer the live global client (so runtime backend swap takes effect).
// Fall back to a one-shot client from env if global is not initialised.
let client = match llm::current() {
Some(c) => c,
None => {
match llm::LlmClient::from_env() {
Ok(c) => std::sync::Arc::new(c),
Err(e) => {
log::warn!("[Lua llm] no client: {}", e);
return Ok(Value::Nil);
}
}
}
};
match client.complete_with(&chat_messages, max_tokens, temperature, top_p) {
Ok(reply) => {
let s = lua.create_string(reply.trim())?;
Ok(Value::String(s))
}
Err(e) => {
log::warn!("[Lua llm] request failed: {}", e);
Ok(Value::Nil)
}
}
})?;
jarvis.set("llm", chat_fn)?;
// Current backend name ("groq" | "ollama" | "none").
let status_fn = lua.create_function(|_, ()| {
Ok(llm::current_backend_name().to_string())
})?;
jarvis.set("llm_status", status_fn)?;
// Hot-swap backend. Accepts "groq"/"ollama"/"local"/"cloud"/"локальный"/"облако".
// Returns the new backend name on success, nil on failure.
let switch_fn = lua.create_function(|lua, name: String| {
match llm::parse_backend(&name) {
Some(b) => match llm::swap_to(b) {
Ok(actual) => {
let s = lua.create_string(actual)?;
Ok(Value::String(s))
}
Err(e) => {
log::warn!("[Lua llm_switch] swap failed: {}", e);
Ok(Value::Nil)
}
},
None => {
log::warn!("[Lua llm_switch] unknown backend name: {}", name);
Ok(Value::Nil)
}
}
})?;
jarvis.set("llm_switch", switch_fn)?;
// Clear conversation turns (keeps system prompt). Returns true if history was
// initialised, false if LLM is not configured.
let reset_fn = lua.create_function(|_, ()| {
llm::history_clear();
Ok(true)
})?;
jarvis.set("llm_reset", reset_fn)?;
// Return the most recent assistant message text, or nil.
let last_fn = lua.create_function(|lua, ()| {
match llm::history_last_assistant() {
Some(text) => {
let s = lua.create_string(text)?;
Ok(Value::String(s))
}
None => Ok(Value::Nil),
}
})?;
jarvis.set("llm_last_reply", last_fn)?;
// Snapshot of the full conversation history as an array of
// {role, content} tables. Excludes the system prompt — only the actual
// back-and-forth. Empty array if there's no history yet.
let snapshot_fn = lua.create_function(|lua, ()| {
let arr = lua.create_table()?;
for (i, msg) in llm::history_snapshot().iter().enumerate() {
if msg.role == "system" {
continue;
}
let row = lua.create_table()?;
row.set("role", msg.role.clone())?;
row.set("content", msg.content.clone())?;
arr.set(i, row)?;
}
Ok(arr)
})?;
jarvis.set("llm_history", snapshot_fn)?;
Ok(())
}

View file

@ -1,70 +0,0 @@
//! Lua bindings for the macro recorder.
//!
//! Usage from Lua:
//! jarvis.macros.start("работа") -- begin recording
//! jarvis.macros.save() -- persist + stop
//! jarvis.macros.cancel() -- abort without saving
//! jarvis.macros.replay("работа") -- play back; returns step count or nil
//! jarvis.macros.list() -- array of {name, steps_count, last_run}
//! jarvis.macros.delete(name) -- removes
//! jarvis.macros.is_recording() -- bool
//! jarvis.macros.recording_name() -- string or nil
use mlua::{Lua, Table, Value};
use crate::macros;
pub fn register(lua: &Lua, jarvis: &Table) -> mlua::Result<()> {
let m = lua.create_table()?;
m.set("start", lua.create_function(|_, name: String| {
macros::start_recording(&name).map_err(mlua::Error::external)
})?)?;
m.set("save", lua.create_function(|_, ()| {
macros::save_recording().map_err(mlua::Error::external)
})?)?;
m.set("cancel", lua.create_function(|_, ()| {
Ok(macros::cancel_recording())
})?)?;
m.set("is_recording", lua.create_function(|_, ()| {
Ok(macros::is_recording())
})?)?;
m.set("recording_name", lua.create_function(|lua, ()| {
match macros::recording_name() {
Some(n) => Ok(Value::String(lua.create_string(n)?)),
None => Ok(Value::Nil),
}
})?)?;
m.set("replay", lua.create_function(|_, name: String| {
macros::replay(&name).map_err(mlua::Error::external)
})?)?;
m.set("delete", lua.create_function(|_, name: String| {
Ok(macros::delete(&name))
})?)?;
m.set("list", lua.create_function(|lua, ()| {
let arr = lua.create_table()?;
for (i, mac) in macros::list().iter().enumerate() {
let row = lua.create_table()?;
row.set("name", mac.name.clone())?;
row.set("steps_count", mac.steps.len())?;
row.set("created_at", mac.created_at)?;
let last: Value = match mac.last_run {
Some(ts) => Value::Integer(ts),
None => Value::Nil,
};
row.set("last_run", last)?;
arr.set(i + 1, row)?;
}
Ok(arr)
})?)?;
jarvis.set("macros", m)?;
Ok(())
}

View file

@ -1,77 +0,0 @@
//! Lua bindings for the long-term memory store.
//!
//! Usage from Lua scripts:
//!
//! jarvis.memory.remember("любимый чай", "улун")
//! local v = jarvis.memory.recall("любимый чай") -- "улун" or nil
//! for _, hit in ipairs(jarvis.memory.search("чай", 5)) do
//! print(hit.key, hit.value)
//! end
//! jarvis.memory.forget("любимый чай")
//! local all = jarvis.memory.all() -- array of {key, value, ...}
use mlua::{Lua, Table};
use crate::long_term_memory;
pub fn register(lua: &Lua, jarvis: &Table) -> mlua::Result<()> {
let mem = lua.create_table()?;
let remember = lua.create_function(|_, (k, v): (String, String)| {
long_term_memory::remember(&k, &v)
.map_err(|e| mlua::Error::external(e))
})?;
mem.set("remember", remember)?;
let recall = lua.create_function(|_, k: String| {
Ok(long_term_memory::recall(&k))
})?;
mem.set("recall", recall)?;
let search = lua.create_function(|lua, (q, n): (String, Option<usize>)| {
let hits = long_term_memory::search(&q, n.unwrap_or(5));
let arr = lua.create_table()?;
for (i, h) in hits.iter().enumerate() {
let row = lua.create_table()?;
row.set("key", h.key.clone())?;
row.set("value", h.value.clone())?;
row.set("created_at", h.created_at)?;
row.set("last_used_at", h.last_used_at)?;
row.set("use_count", h.use_count)?;
arr.set(i + 1, row)?;
}
Ok(arr)
})?;
mem.set("search", search)?;
let forget = lua.create_function(|_, k: String| {
Ok(long_term_memory::forget(&k))
})?;
mem.set("forget", forget)?;
let all = lua.create_function(|lua, ()| {
let recs = long_term_memory::all();
let arr = lua.create_table()?;
for (i, h) in recs.iter().enumerate() {
let row = lua.create_table()?;
row.set("key", h.key.clone())?;
row.set("value", h.value.clone())?;
row.set("created_at", h.created_at)?;
row.set("last_used_at", h.last_used_at)?;
row.set("use_count", h.use_count)?;
arr.set(i + 1, row)?;
}
Ok(arr)
})?;
mem.set("all", all)?;
// Wipe ALL memory. Returns count of removed facts. Voice packs should
// only call this behind a deliberate phrase like "точно забудь всё".
let clear_all = lua.create_function(|_, ()| {
Ok(long_term_memory::clear_all())
})?;
mem.set("clear_all", clear_all)?;
jarvis.set("memory", mem)?;
Ok(())
}

View file

@ -1,63 +0,0 @@
//! Lua bindings for profile switching.
//!
//! Usage from Lua scripts:
//!
//! local p = jarvis.profile.active() -- table {name, icon, description, ...}
//! jarvis.profile.set("work") -- switch to work profile
//! local names = jarvis.profile.list() -- {"default", "work", "game", "sleep", "driving"}
//! if jarvis.profile.allows("games.steam") then ... end
use mlua::{Lua, Table};
use crate::profiles;
pub fn register(lua: &Lua, jarvis: &Table) -> mlua::Result<()> {
let prof = lua.create_table()?;
let active = lua.create_function(|lua, ()| {
let p = profiles::active();
let t = lua.create_table()?;
t.set("name", p.name)?;
t.set("description", p.description)?;
t.set("llm_personality", p.llm_personality)?;
t.set("greeting", p.greeting)?;
t.set("icon", p.icon)?;
Ok(t)
})?;
prof.set("active", active)?;
let active_name = lua.create_function(|_, ()| Ok(profiles::active_name()))?;
prof.set("active_name", active_name)?;
let set_active = lua.create_function(|lua, name: String| {
match profiles::set_active(&name) {
Ok(p) => {
let t = lua.create_table()?;
t.set("name", p.name)?;
t.set("icon", p.icon)?;
t.set("greeting", p.greeting)?;
Ok(t)
}
Err(e) => Err(mlua::Error::external(e)),
}
})?;
prof.set("set", set_active)?;
let list = lua.create_function(|lua, ()| {
let names = profiles::list();
let arr = lua.create_table()?;
for (i, n) in names.iter().enumerate() {
arr.set(i + 1, n.clone())?;
}
Ok(arr)
})?;
prof.set("list", list)?;
let allows = lua.create_function(|_, cmd_id: String| {
Ok(profiles::active().allows_command(&cmd_id))
})?;
prof.set("allows", allows)?;
jarvis.set("profile", prof)?;
Ok(())
}

View file

@ -1,161 +0,0 @@
//! Lua bindings for the proactive scheduler.
//!
//! Usage:
//! jarvis.scheduler.add({
//! name = "Daily briefing",
//! schedule = "daily 09:00",
//! action = { type = "speak", text = "Доброе утро. Готов к работе." }
//! })
//!
//! jarvis.scheduler.add({
//! name = "Reminder",
//! schedule = "in 5 minutes",
//! action = { type = "speak", text = "Выключи кофеварку." }
//! })
//!
//! for _, t in ipairs(jarvis.scheduler.list()) do
//! print(t.id, t.name, t.schedule_human)
//! end
//!
//! jarvis.scheduler.remove(id)
//! jarvis.scheduler.clear()
//!
//! Schedule string syntax (see `scheduler::Schedule::parse`):
//! "daily HH:MM" | "at HH:MM" | "every N minutes" | "every N hours" | "in N minutes" | "in N hours"
use mlua::{Lua, Table, Value};
use crate::scheduler::{self, Action, Schedule, ScheduledTask};
pub fn register(lua: &Lua, jarvis: &Table) -> mlua::Result<()> {
let sched = lua.create_table()?;
let add_fn = lua.create_function(|_, t: Table| {
let name: String = t.get::<Option<String>>("name")?.unwrap_or_else(|| "task".to_string());
let schedule_str: String = t.get::<String>("schedule")?;
let id: String = t.get::<Option<String>>("id")?.unwrap_or_default();
let action_tbl: Table = t.get::<Table>("action")?;
let action_type: String = action_tbl.get::<String>("type")?;
let action = match action_type.as_str() {
"speak" => Action::Speak {
text: action_tbl.get::<String>("text").unwrap_or_default(),
},
"lua" => Action::Lua {
script_path: action_tbl.get::<String>("script_path").unwrap_or_default(),
},
other => return Err(mlua::Error::external(format!("unknown action type: {}", other))),
};
let schedule = Schedule::parse(&schedule_str)
.map_err(|e| mlua::Error::external(format!("bad schedule '{}': {}", schedule_str, e)))?;
let task = ScheduledTask {
id,
name,
schedule,
action,
last_fired: None,
enabled: true,
created_at: 0,
};
scheduler::add(task).map_err(mlua::Error::external)
})?;
sched.set("add", add_fn)?;
let remove_fn = lua.create_function(|_, id: String| {
Ok(scheduler::remove(&id))
})?;
sched.set("remove", remove_fn)?;
let clear_fn = lua.create_function(|_, ()| {
Ok(scheduler::clear())
})?;
sched.set("clear", clear_fn)?;
// Remove all tasks whose name OR speak text matches `query` (substring,
// case-insensitive). Returns count removed.
let remove_by_text_fn = lua.create_function(|_, query: String| {
Ok(scheduler::remove_by_text(&query))
})?;
sched.set("remove_by_text", remove_by_text_fn)?;
// Find (don't remove) tasks matching query. Returns array of tasks.
let find_by_text_fn = lua.create_function(|lua, (query, n): (String, Option<usize>)| {
let arr = lua.create_table()?;
for (i, t) in scheduler::find_by_text(&query, n.unwrap_or(5)).iter().enumerate() {
arr.set(i + 1, task_to_lua(lua, t)?)?;
}
Ok(arr)
})?;
sched.set("find_by_text", find_by_text_fn)?;
let list_fn = lua.create_function(|lua, ()| {
let arr = lua.create_table()?;
for (i, t) in scheduler::list().iter().enumerate() {
arr.set(i + 1, task_to_lua(lua, t)?)?;
}
Ok(arr)
})?;
sched.set("list", list_fn)?;
let count_fn = lua.create_function(|_, ()| {
Ok(scheduler::list().len())
})?;
sched.set("count", count_fn)?;
jarvis.set("scheduler", sched)?;
Ok(())
}
fn task_to_lua(lua: &Lua, t: &ScheduledTask) -> mlua::Result<Table> {
let tbl = lua.create_table()?;
tbl.set("id", t.id.clone())?;
tbl.set("name", t.name.clone())?;
tbl.set("enabled", t.enabled)?;
tbl.set("created_at", t.created_at)?;
tbl.set("schedule_human", schedule_human(&t.schedule))?;
let last_fired: Value = match t.last_fired {
Some(ts) => Value::Integer(ts),
None => Value::Nil,
};
tbl.set("last_fired", last_fired)?;
let action_tbl = lua.create_table()?;
match &t.action {
Action::Speak { text } => {
action_tbl.set("type", "speak")?;
action_tbl.set("text", text.clone())?;
}
Action::Lua { script_path } => {
action_tbl.set("type", "lua")?;
action_tbl.set("script_path", script_path.clone())?;
}
}
tbl.set("action", action_tbl)?;
Ok(tbl)
}
fn schedule_human(s: &Schedule) -> String {
match s {
Schedule::Daily { hour, minute } => format!("каждый день в {:02}:{:02}", hour, minute),
Schedule::Interval { seconds } => {
if *seconds % 3600 == 0 {
format!("каждые {} часов", seconds / 3600)
} else if *seconds % 60 == 0 {
format!("каждые {} минут", seconds / 60)
} else {
format!("каждые {} секунд", seconds)
}
}
Schedule::Once { at } => {
chrono::DateTime::from_timestamp(*at, 0)
.map(|dt: chrono::DateTime<chrono::Utc>| {
dt.with_timezone(&chrono::Local).format("один раз в %H:%M %d.%m").to_string()
})
.unwrap_or_else(|| format!("один раз в {}", at))
}
}
}

View file

@ -79,10 +79,7 @@ pub fn register(lua: &Lua, jarvis: &Table, sandbox: SandboxLevel) -> mlua::Resul
{
use winrt_notification::{Toast, Duration as ToastDuration};
// Use our registered AUMID so the toast attributes to "J.A.R.V.I.S."
// instead of "PowerShell" in Action Center. Falls back to the
// PowerShell AUMID transparently if registration failed at startup.
if let Err(e) = Toast::new(crate::toast::active_aumid())
if let Err(e) = Toast::new(Toast::POWERSHELL_APP_ID)
.title(&title)
.text1(&message)
.duration(ToastDuration::Short)

View file

@ -1,65 +0,0 @@
use mlua::{Lua, Table, Value};
// Tiny text helpers that every trigger-driven pack reinvents. Centralising
// here removes ~10 lines of identical boilerplate per pack.
pub fn register(lua: &Lua, jarvis: &Table) -> mlua::Result<()> {
let text = lua.create_table()?;
// jarvis.text.strip_trigger(phrase, triggers) -> remainder
//
// Lowercases `phrase`, finds the FIRST trigger from `triggers` that
// matches at the start (case-insensitive substring), and returns
// everything after it trimmed. If nothing matches, returns the original
// phrase trimmed. Empty input returns "".
//
// Triggers should be ordered longest-first so "напомни мне через" wins
// over "напомни" — the function does NOT re-sort for the caller.
let strip_fn = lua.create_function(|lua, (phrase, triggers): (String, Table)| {
let lowered = phrase.to_lowercase();
let mut chosen_end: Option<usize> = None;
for v in triggers.sequence_values::<Value>() {
let t = match v {
Ok(Value::String(s)) => s.to_str()?.to_lowercase(),
_ => continue,
};
if t.is_empty() {
continue;
}
if lowered.starts_with(&t) {
let after = lowered[t.len()..].chars().next();
if after.map_or(true, |c| !c.is_alphanumeric()) {
chosen_end = Some(t.len());
break;
}
}
}
let rest = match chosen_end {
Some(end) => &phrase[end..],
None => phrase.as_str(),
};
Ok(lua.create_string(rest.trim())?)
})?;
text.set("strip_trigger", strip_fn)?;
// jarvis.text.contains_any(phrase, needles) -> boolean
// case-insensitive "does any needle appear anywhere in the phrase".
let contains_fn = lua.create_function(|_, (phrase, needles): (String, Table)| {
let lowered = phrase.to_lowercase();
for v in needles.sequence_values::<Value>() {
let n = match v {
Ok(Value::String(s)) => s.to_str()?.to_lowercase(),
_ => continue,
};
if !n.is_empty() && lowered.contains(&n) {
return Ok(true);
}
}
Ok(false)
})?;
text.set("contains_any", contains_fn)?;
jarvis.set("text", text)?;
Ok(())
}

View file

@ -1,35 +0,0 @@
use mlua::{Lua, Table};
use crate::tts::{self, SpeakOpts};
pub fn register(lua: &Lua, jarvis: &Table) -> mlua::Result<()> {
// jarvis.speak(text, opts?)
// opts.lang: ISO 2-letter code (ru, en, de, ...). default "ru"
// opts.async: if true, fire-and-forget; if false, block until done. default true
// opts.raw: if true, skip text sanitization (default false)
//
// Uses the active TTS backend (see `crate::tts::backend`). Configured via JARVIS_TTS env var.
let speak_fn = lua.create_function(|_, (text, opts): (String, Option<Table>)| {
let mut speak_opts = SpeakOpts::default();
if let Some(t) = opts {
if let Ok(v) = t.get::<String>("lang") { speak_opts.lang = v; }
if let Ok(v) = t.get::<bool>("async") { speak_opts.detached = v; }
if let Ok(v) = t.get::<bool>("raw") { speak_opts.raw = v; }
}
tts::speak(&text, &speak_opts);
Ok(())
})?;
jarvis.set("speak", speak_fn)?;
// jarvis.tts.backend() — returns name of active backend ("sapi", "piper", "silero")
let tts_table = lua.create_table()?;
let backend_fn = lua.create_function(|_, ()| {
Ok(tts::backend().name().to_string())
})?;
tts_table.set("backend", backend_fn)?;
jarvis.set("tts", tts_table)?;
Ok(())
}

View file

@ -1,189 +0,0 @@
//! IMBA-4: Multimodal — screenshot + vision LLM.
//!
//! Lua bindings:
//! jarvis.vision.screenshot(path?) -> path on disk (default temp .png)
//! jarvis.vision.describe(prompt?) -> string description (vision LLM call)
//!
//! Windows-only screenshot via PowerShell System.Drawing. Posts to a vision-capable
//! Groq model (`llama-3.2-11b-vision-preview` by default; override via `GROQ_VISION_MODEL`).
//!
//! Requires HTTP sandbox level (registered behind `allows_http()` in engine.rs).
use mlua::{Lua, Table};
pub fn register(lua: &Lua, jarvis: &Table) -> mlua::Result<()> {
let vision = lua.create_table()?;
let screenshot = lua.create_function(|_, path: Option<String>| {
match take_screenshot(path.as_deref()) {
Ok(p) => Ok(Some(p)),
Err(e) => {
log::warn!("vision.screenshot failed: {}", e);
Ok(None)
}
}
})?;
vision.set("screenshot", screenshot)?;
let describe = lua.create_function(|_, prompt: Option<String>| {
let user_prompt = prompt.unwrap_or_else(|| {
"Опиши кратко что на этом экране. Если есть ошибки — отметь их.".to_string()
});
match describe_screen(&user_prompt) {
Ok(text) => Ok(Some(text)),
Err(e) => {
log::warn!("vision.describe failed: {}", e);
Ok(None)
}
}
})?;
vision.set("describe", describe)?;
jarvis.set("vision", vision)?;
Ok(())
}
#[cfg(target_os = "windows")]
fn take_screenshot(out_path: Option<&str>) -> Result<String, String> {
let target = match out_path {
Some(p) => std::path::PathBuf::from(p),
None => {
let f = tempfile::Builder::new()
.prefix("jarvis-screen-")
.suffix(".png")
.tempfile()
.map_err(|e| format!("tempfile: {}", e))?;
let p = f.path().to_path_buf();
drop(f);
p
}
};
let escaped = target.display().to_string().replace('\'', "''");
let ps = format!(
r#"
Add-Type -AssemblyName System.Windows.Forms;
Add-Type -AssemblyName System.Drawing;
$bounds = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds;
$bmp = New-Object System.Drawing.Bitmap $bounds.Width, $bounds.Height;
$g = [System.Drawing.Graphics]::FromImage($bmp);
$g.CopyFromScreen($bounds.Location, [System.Drawing.Point]::Empty, $bounds.Size);
$bmp.Save('{}', [System.Drawing.Imaging.ImageFormat]::Png);
$g.Dispose(); $bmp.Dispose();
"#,
escaped
);
let status = std::process::Command::new("powershell")
.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", &ps])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map_err(|e| format!("powershell spawn: {}", e))?;
if !status.success() {
return Err(format!("screenshot powershell exited {}", status));
}
if !target.is_file() {
return Err("screenshot file not created".to_string());
}
Ok(target.display().to_string())
}
#[cfg(not(target_os = "windows"))]
fn take_screenshot(_out_path: Option<&str>) -> Result<String, String> {
Err("screenshot not implemented on this platform".into())
}
#[cfg(target_os = "windows")]
fn describe_screen(prompt: &str) -> Result<String, String> {
let path = take_screenshot(None)?;
let b64 = read_as_base64(&path)?;
let _ = std::fs::remove_file(&path);
call_vision_llm(prompt, &b64)
}
#[cfg(not(target_os = "windows"))]
fn describe_screen(_prompt: &str) -> Result<String, String> {
Err("vision not implemented on this platform".into())
}
#[cfg(target_os = "windows")]
fn read_as_base64(path: &str) -> Result<String, String> {
// Avoid a new base64 crate dep — use PowerShell [Convert]::ToBase64String.
let escaped = path.replace('\'', "''");
let ps = format!(
"[Convert]::ToBase64String([IO.File]::ReadAllBytes('{}'))",
escaped
);
let out = std::process::Command::new("powershell")
.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", &ps])
.output()
.map_err(|e| format!("base64 powershell: {}", e))?;
if !out.status.success() {
return Err(format!(
"base64 powershell exited {}: {}",
out.status,
String::from_utf8_lossy(&out.stderr)
));
}
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
if s.is_empty() {
return Err("base64 output empty".into());
}
Ok(s)
}
#[cfg(target_os = "windows")]
fn call_vision_llm(prompt: &str, image_b64: &str) -> Result<String, String> {
use serde_json::json;
let token = std::env::var("GROQ_TOKEN")
.map_err(|_| "GROQ_TOKEN not set".to_string())?;
let base = std::env::var("GROQ_BASE_URL")
.unwrap_or_else(|_| "https://api.groq.com/openai/v1".to_string());
let model = std::env::var("GROQ_VISION_MODEL")
.unwrap_or_else(|_| "llama-3.2-11b-vision-preview".to_string());
let body = json!({
"model": model,
"max_tokens": 400,
"temperature": 0.2,
"messages": [{
"role": "user",
"content": [
{ "type": "text", "text": prompt },
{ "type": "image_url",
"image_url": {
"url": format!("data:image/png;base64,{}", image_b64)
}
}
]
}]
});
let url = format!("{}/chat/completions", base.trim_end_matches('/'));
let client = reqwest::blocking::Client::new();
let resp = client
.post(&url)
.bearer_auth(&token)
.json(&body)
.send()
.map_err(|e| format!("HTTP: {}", e))?;
let status = resp.status();
let text = resp.text().map_err(|e| format!("read body: {}", e))?;
if !status.is_success() {
return Err(format!("vision API {}: {}", status.as_u16(), text));
}
let json: serde_json::Value =
serde_json::from_str(&text).map_err(|e| format!("parse JSON: {}", e))?;
json.get("choices")
.and_then(|c| c.get(0))
.and_then(|c| c.get("message"))
.and_then(|m| m.get("content"))
.and_then(|c| c.as_str())
.map(|s| s.trim().to_string())
.ok_or_else(|| format!("no content in response: {}", text))
}

View file

@ -76,21 +76,10 @@ impl LuaEngine {
api::core::register(&self.lua, &jarvis)?;
api::audio::register(&self.lua, &jarvis)?;
api::context::register(&self.lua, &jarvis, context)?;
api::tts::register(&self.lua, &jarvis)?;
api::text::register(&self.lua, &jarvis)?;
api::memory::register(&self.lua, &jarvis)?;
api::profile::register(&self.lua, &jarvis)?;
api::scheduler::register(&self.lua, &jarvis)?;
api::cmd::register(&self.lua, &jarvis)?;
api::health::register(&self.lua, &jarvis)?;
api::macros::register(&self.lua, &jarvis)?;
api::banter::register(&self.lua, &jarvis)?;
// sandbox-controlled APIs
if self.sandbox.allows_http() {
api::http::register(&self.lua, &jarvis)?;
api::llm::register(&self.lua, &jarvis)?;
api::vision::register(&self.lua, &jarvis)?;
}
if self.sandbox.allows_state() {

View file

@ -109,135 +109,4 @@ mod tests {
assert!(result.is_ok());
}
/// Drive the shunting-yard evaluator from math.lua via a tiny Lua harness
/// that inlines just the parser. This lets us assert offline arithmetic
/// works without spinning up the LLM or network. We don't include the full
/// 250-line math.lua here — just the eval kernel — because the wider
/// script reaches into the audio/notify modules.
#[test]
fn test_math_shunting_yard_basics() {
let dir = tempdir().unwrap();
let script_path = dir.path().join("eval.lua");
let script = r#"
local function tokenize(s)
local tokens = {}
local i = 1
while i <= #s do
local c = s:sub(i, i)
if c:match("[%s]") then i = i + 1
elseif c:match("[%d%.]") then
local j = i
while j <= #s and s:sub(j, j):match("[%d%.]") do j = j + 1 end
table.insert(tokens, { kind = "num", val = tonumber(s:sub(i, j - 1)) })
i = j
elseif c == "+" or c == "-" or c == "*" or c == "/" or c == "^" then
if (c == "-" or c == "+") and (#tokens == 0 or tokens[#tokens].kind == "op" or tokens[#tokens].kind == "lparen") then
local j = i + 1
while j <= #s and s:sub(j, j):match("[%s]") do j = j + 1 end
if j <= #s and s:sub(j, j):match("[%d%.]") then
local k = j
while k <= #s and s:sub(k, k):match("[%d%.]") do k = k + 1 end
local v = tonumber(s:sub(j, k - 1))
if v then
table.insert(tokens, { kind = "num", val = (c == "-" and -v or v) })
i = k
else return nil end
else return nil end
else
table.insert(tokens, { kind = "op", val = c }); i = i + 1
end
elseif c == "(" then table.insert(tokens, { kind = "lparen" }); i = i + 1
elseif c == ")" then table.insert(tokens, { kind = "rparen" }); i = i + 1
else return nil end
end
return tokens
end
local function precedence(op)
if op == "+" or op == "-" then return 1 end
if op == "*" or op == "/" then return 2 end
if op == "^" then return 3 end
return 0
end
local function eval_op(a, b, op)
if op == "+" then return a + b end
if op == "-" then return a - b end
if op == "*" then return a * b end
if op == "/" then if b == 0 then return nil end; return a / b end
if op == "^" then return a ^ b end
return nil
end
local function eval(tokens)
if not tokens or #tokens == 0 then return nil end
local values, ops = {}, {}
local function apply()
local op = table.remove(ops)
local b = table.remove(values)
local a = table.remove(values)
if a == nil or b == nil then return false end
local r = eval_op(a, b, op)
if r == nil then return false end
table.insert(values, r)
return true
end
for _, t in ipairs(tokens) do
if t.kind == "num" then table.insert(values, t.val)
elseif t.kind == "op" then
while #ops > 0 and ops[#ops] ~= "(" and precedence(ops[#ops]) >= precedence(t.val) do
if not apply() then return nil end
end
table.insert(ops, t.val)
elseif t.kind == "lparen" then table.insert(ops, "(")
elseif t.kind == "rparen" then
while #ops > 0 and ops[#ops] ~= "(" do
if not apply() then return nil end
end
if ops[#ops] ~= "(" then return nil end
table.remove(ops)
end
end
while #ops > 0 do
if ops[#ops] == "(" then return nil end
if not apply() then return nil end
end
if #values ~= 1 then return nil end
return values[1]
end
local cases = {
{ "2 + 2", 4 },
{ "10 - 4", 6 },
{ "3 * 5", 15 },
{ "20 / 4", 5 },
{ "2 ^ 10", 1024 },
{ "1 + 2 * 3", 7 },
{ "(1 + 2) * 3", 9 },
{ "-5 + 10", 5 },
{ "100 / 0", nil },
{ "abc", nil },
}
for _, c in ipairs(cases) do
local got = eval(tokenize(c[1]))
if got ~= c[2] then
error(string.format("case %q: expected %s, got %s",
c[1], tostring(c[2]), tostring(got)))
end
end
return true
"#;
fs::write(&script_path, script).unwrap();
let context = create_test_context(dir.path().to_path_buf());
let result = execute(
&script_path,
context,
SandboxLevel::Minimal,
Duration::from_secs(5),
);
assert!(result.is_ok(), "shunting yard test failed: {:?}", result);
}
}

View file

@ -1,334 +0,0 @@
//! IMBA-7: Voice-command macros — record a sequence of commands once,
//! replay them later with one phrase.
//!
//! Simpler than AHK-style keyboard hooks: we record what the user SAID, then
//! replay each phrase through the normal dispatch path. Works for anything
//! that's already a known command (volume, media keys, profile switches, ...).
//!
//! Lifecycle:
//!
//! "Запиши макрос работа" → start_recording("работа")
//! "Открой браузер" → recorder buffers "открой браузер"
//! "Запусти спотифай" → recorder buffers
//! "Режим работа" → recorder buffers
//! "Сохрани макрос" → save() → persists to disk
//!
//! "Запусти макрос работа" → replay("работа") → fires each phrase in order,
//! with a small inter-step delay
//!
//! Storage: JSON at `<APP_CONFIG_DIR>/macros.json`.
use once_cell::sync::OnceCell;
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::PathBuf;
use crate::APP_CONFIG_DIR;
const FILE_NAME: &str = "macros.json";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Macro {
pub name: String,
pub steps: Vec<String>,
#[serde(default)]
pub created_at: i64,
#[serde(default)]
pub last_run: Option<i64>,
}
#[derive(Default, Debug, Serialize, Deserialize)]
struct Store {
#[serde(default)]
macros: BTreeMap<String, Macro>,
}
#[derive(Debug)]
struct RecordingState {
name: String,
steps: Vec<String>,
}
struct State {
path: PathBuf,
store: RwLock<Store>,
recording: RwLock<Option<RecordingState>>,
}
static STATE: OnceCell<State> = OnceCell::new();
pub fn init() -> Result<(), String> {
if STATE.get().is_some() { return Ok(()); }
let dir = APP_CONFIG_DIR.get()
.ok_or_else(|| "APP_CONFIG_DIR not initialised".to_string())?;
let path = dir.join(FILE_NAME);
let store = load_or_empty(&path);
info!("Macros loaded: {} stored at {}", store.macros.len(), path.display());
STATE.set(State { path, store: RwLock::new(store), recording: RwLock::new(None) })
.map_err(|_| "macros already initialised".to_string())?;
Ok(())
}
fn load_or_empty(path: &PathBuf) -> Store {
if !path.is_file() { return Store::default(); }
match std::fs::read_to_string(path) {
Ok(s) => serde_json::from_str(&s).unwrap_or_else(|e| {
warn!("Corrupt macros file {}: {}", path.display(), e);
Store::default()
}),
Err(e) => {
warn!("Cannot read {}: {}", path.display(), e);
Store::default()
}
}
}
fn persist(state: &State) {
let store = state.store.read();
let tmp = state.path.with_extension("json.tmp");
let json = match serde_json::to_string_pretty(&*store) {
Ok(s) => s,
Err(e) => { warn!("Macros serialize failed: {}", e); return; }
};
if let Err(e) = std::fs::write(&tmp, json) {
warn!("Macros write failed: {}", e); return;
}
if let Err(e) = std::fs::rename(&tmp, &state.path) {
warn!("Macros rename failed: {}", e);
}
}
fn now_secs() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
fn normalize_name(n: &str) -> String {
n.trim().to_lowercase()
}
/// Begin recording a new macro. Replaces any in-progress recording.
pub fn start_recording(name: &str) -> Result<(), String> {
let state = STATE.get().ok_or_else(|| "macros not init".to_string())?;
let nname = normalize_name(name);
if nname.is_empty() { return Err("empty macro name".into()); }
*state.recording.write() = Some(RecordingState {
name: nname.clone(),
steps: Vec::new(),
});
info!("Macros: recording started for '{}'", nname);
Ok(())
}
/// Buffer a phrase into the active recording (if any). Called by the command
/// dispatch hook after a command runs successfully.
pub fn record_step(phrase: &str) {
let Some(state) = STATE.get() else { return; };
let mut rec = state.recording.write();
if let Some(r) = rec.as_mut() {
let p = phrase.trim();
if p.is_empty() { return; }
// Don't record macro-control commands themselves (would be infinite recursion).
if is_macro_control(p) { return; }
r.steps.push(p.to_string());
debug!("Macros: recorded step '{}' for '{}'", p, r.name);
}
}
fn is_macro_control(phrase: &str) -> bool {
let p = phrase.to_lowercase();
p.contains("запиши макрос") || p.contains("сохрани макрос")
|| p.contains("отмени макрос") || p.contains("прекрати макрос")
|| p.contains("отмени запись")
|| p.starts_with("запусти макрос") || p.starts_with("воспроизведи макрос")
|| p.starts_with("record macro") || p.starts_with("save macro")
|| p.starts_with("play macro")
}
/// Returns true if a recording is currently active.
pub fn is_recording() -> bool {
STATE.get().and_then(|s| s.recording.read().as_ref().map(|_| ())).is_some()
}
/// Name of the macro currently being recorded, if any.
pub fn recording_name() -> Option<String> {
STATE.get().and_then(|s| s.recording.read().as_ref().map(|r| r.name.clone()))
}
/// Stop recording and save the macro. Returns the number of steps captured.
pub fn save_recording() -> Result<usize, String> {
let state = STATE.get().ok_or_else(|| "macros not init".to_string())?;
let rec = state.recording.write().take()
.ok_or_else(|| "не было активной записи".to_string())?;
if rec.steps.is_empty() {
return Err("макрос пустой".into());
}
let count = rec.steps.len();
let m = Macro {
name: rec.name.clone(),
steps: rec.steps,
created_at: now_secs(),
last_run: None,
};
state.store.write().macros.insert(rec.name.clone(), m);
persist(state);
info!("Macros: saved '{}' ({} steps)", rec.name, count);
Ok(count)
}
/// Cancel the active recording without saving.
pub fn cancel_recording() -> bool {
let Some(state) = STATE.get() else { return false; };
state.recording.write().take().is_some()
}
pub fn list() -> Vec<Macro> {
let Some(state) = STATE.get() else { return Vec::new(); };
state.store.read().macros.values().cloned().collect()
}
pub fn get(name: &str) -> Option<Macro> {
let state = STATE.get()?;
state.store.read().macros.get(&normalize_name(name)).cloned()
}
pub fn delete(name: &str) -> bool {
let Some(state) = STATE.get() else { return false; };
let removed = state.store.write().macros.remove(&normalize_name(name)).is_some();
if removed { persist(state); }
removed
}
/// Mark a macro as run (updates last_run timestamp).
pub fn mark_run(name: &str) {
let Some(state) = STATE.get() else { return; };
let nname = normalize_name(name);
{
let mut store = state.store.write();
if let Some(m) = store.macros.get_mut(&nname) {
m.last_run = Some(now_secs());
}
}
persist(state);
}
/// Replay callback — jarvis-app registers a function that fires a text command.
/// Macros module owns the timing of replays.
pub type ReplayCallback = Box<dyn Fn(&str) + Send + Sync>;
static REPLAY_CB: once_cell::sync::OnceCell<ReplayCallback> = once_cell::sync::OnceCell::new();
pub fn set_replay_callback(cb: ReplayCallback) {
let _ = REPLAY_CB.set(cb);
}
const STEP_DELAY_MS: u64 = 800;
/// Replay a stored macro. Each step is sent to the registered callback (which
/// the app wires to its text-command channel). Inter-step delay gives the
/// dispatcher time to process before the next step arrives. Returns step count.
pub fn replay(name: &str) -> Result<usize, String> {
let m = get(name).ok_or_else(|| format!("Макрос '{}' не найден.", name))?;
let cb = REPLAY_CB.get().ok_or_else(|| {
"Replay callback not registered (jarvis-app didn't wire it).".to_string()
})?;
let steps_count = m.steps.len();
info!("Macros: replay '{}' ({} steps)", m.name, steps_count);
let _ = cb; // sanity-checked existence; thread re-reads REPLAY_CB inside.
// Spawn so the caller (likely a Lua script) returns immediately.
let name_owned = m.name.clone();
let steps = m.steps.clone();
std::thread::Builder::new()
.name(format!("macro-replay-{}", name_owned))
.spawn(move || {
let cb = match REPLAY_CB.get() {
Some(c) => c,
None => { warn!("Macros: callback vanished mid-replay"); return; }
};
for (i, step) in steps.iter().enumerate() {
info!("Macros: replay step {}/{}: {}", i + 1, steps.len(), step);
cb(step);
if i + 1 < steps.len() {
std::thread::sleep(std::time::Duration::from_millis(STEP_DELAY_MS));
}
}
mark_run(&name_owned);
})
.ok();
Ok(steps_count)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn is_macro_control_catches_record_phrase() {
assert!(is_macro_control("Запиши макрос работа"));
assert!(is_macro_control("сохрани макрос"));
assert!(is_macro_control("запусти макрос работа"));
assert!(!is_macro_control("открой браузер"));
assert!(!is_macro_control("установи громкость 50"));
}
#[test]
fn normalize_name_lowercases_trims() {
assert_eq!(normalize_name(" Работа "), "работа");
assert_eq!(normalize_name("GameMode"), "gamemode");
}
#[test]
fn macro_serde_round_trip() {
let m = Macro {
name: "test".into(),
steps: vec!["a".into(), "b".into()],
created_at: 100,
last_run: Some(200),
};
let json = serde_json::to_string(&m).unwrap();
let back: Macro = serde_json::from_str(&json).unwrap();
assert_eq!(back.name, "test");
assert_eq!(back.steps.len(), 2);
assert_eq!(back.last_run, Some(200));
}
#[test]
fn store_serde_round_trip() {
let mut store = Store::default();
store.macros.insert("a".into(), Macro {
name: "a".into(),
steps: vec!["x".into()],
created_at: 1,
last_run: None,
});
store.macros.insert("b".into(), Macro {
name: "b".into(),
steps: vec!["y".into(), "z".into()],
created_at: 2,
last_run: Some(3),
});
let json = serde_json::to_string(&store).unwrap();
let back: Store = serde_json::from_str(&json).unwrap();
assert_eq!(back.macros.len(), 2);
assert_eq!(back.macros.get("b").unwrap().steps.len(), 2);
}
#[test]
fn is_macro_control_handles_case() {
assert!(is_macro_control("ЗАПИШИ МАКРОС работа"));
assert!(is_macro_control("Сохрани макрос пожалуйста"));
assert!(is_macro_control("запусти макрос игра"));
// Noun form "запись макроса" intentionally does NOT match — we only
// filter the actual control verb-phrases.
assert!(!is_macro_control("запись макроса"));
}
}

View file

@ -1,379 +0,0 @@
//! Plugin system — user-installed command packs that live outside the read-only
//! `resources/commands/` tree.
//!
//! Why: shipping new voice packs as built-ins requires a rebuild + release. End
//! users (and external authors) want to drop a folder into a writable location
//! and have Jarvis pick it up. That's exactly what this module enables.
//!
//! Layout (per pack):
//!
//! ```text
//! <APP_CONFIG_DIR>/plugins/
//! <pack_name>/
//! command.toml (required — same schema as built-in packs)
//! <script>.lua (zero or more scripts referenced from toml)
//! disabled (optional empty file — if present, pack is skipped)
//! ```
//!
//! On Windows this resolves to `%APPDATA%\com.priler.jarvis\plugins\`.
//!
//! Security: plugin Lua scripts run under the same sandbox the `command.toml`
//! declares, but we cap the maximum sandbox level at `Standard`. Plugin authors
//! cannot reach `Full` (which gives `os` access). This is the only place where
//! a plugin pack differs from a built-in pack.
//!
//! Discovery is read-only and tolerant: a malformed `command.toml` logs a
//! warning and is skipped; it never poisons the whole list.
//!
//! Reload: `commands::reload_global()` rescans both built-in and plugin packs
//! and atomically swaps `COMMANDS_LIST`. The jarvis-app FS watcher fires this
//! on any change under `plugins/`.
use std::fs;
use std::path::{Path, PathBuf};
use crate::commands::JCommandsList;
use crate::APP_CONFIG_DIR;
const PLUGINS_DIR_NAME: &str = "plugins";
const DISABLED_FLAG: &str = "disabled";
const MAX_SANDBOX_FOR_PLUGINS: &str = "standard";
/// Metadata about a single plugin pack on disk.
#[derive(Debug, Clone, serde::Serialize)]
pub struct PluginInfo {
pub name: String,
pub path: PathBuf,
pub enabled: bool,
pub command_count: usize,
pub error: Option<String>,
}
/// Returns the on-disk plugins directory (`<APP_CONFIG_DIR>/plugins`).
/// Creates it on first call so the user can find and populate it.
pub fn plugins_dir() -> Result<PathBuf, String> {
let cfg = APP_CONFIG_DIR
.get()
.ok_or_else(|| "APP_CONFIG_DIR not initialised".to_string())?;
let dir = cfg.join(PLUGINS_DIR_NAME);
if !dir.exists() {
fs::create_dir_all(&dir).map_err(|e| format!("create plugins dir: {}", e))?;
}
Ok(dir)
}
/// Walk the plugins directory and parse every `command.toml`.
/// Returns parsed packs; malformed packs are logged and skipped.
pub fn discover() -> Vec<JCommandsList> {
let dir = match plugins_dir() {
Ok(d) => d,
Err(e) => {
warn!("Plugins discovery skipped: {}", e);
return Vec::new();
}
};
discover_in(&dir)
}
/// Same as `discover()` but takes an explicit directory — used by tests.
pub fn discover_in(dir: &Path) -> Vec<JCommandsList> {
let mut out = Vec::new();
let read = match fs::read_dir(dir) {
Ok(r) => r,
Err(e) => {
// not a hard error — directory may not exist yet
debug!("Plugins dir unreadable {}: {}", dir.display(), e);
return out;
}
};
for entry in read.flatten() {
let pack_path = entry.path();
if !pack_path.is_dir() {
continue;
}
if pack_path.join(DISABLED_FLAG).is_file() {
debug!("Plugin '{}' is disabled, skipping", pack_path.display());
continue;
}
let toml_file = pack_path.join("command.toml");
if !toml_file.is_file() {
continue;
}
match parse_one(&pack_path, &toml_file) {
Ok(list) => out.push(list),
Err(e) => warn!("Plugin '{}' load failed: {}", pack_path.display(), e),
}
}
if !out.is_empty() {
info!(
"Loaded {} plugin pack(s) from {}",
out.len(),
dir.display()
);
}
out
}
fn parse_one(pack_path: &Path, toml_file: &Path) -> Result<JCommandsList, String> {
let body = fs::read_to_string(toml_file).map_err(|e| format!("read: {}", e))?;
let mut pack: JCommandsList =
toml::from_str(&body).map_err(|e| format!("parse: {}", e))?;
// enforce sandbox cap and validate script references
for cmd in &mut pack.commands {
if cmd.cmd_type == "lua" {
let requested = if cmd.sandbox.is_empty() {
MAX_SANDBOX_FOR_PLUGINS
} else {
cmd.sandbox.as_str()
};
if requested == "full" {
warn!(
"Plugin '{}': command '{}' requested 'full' sandbox; \
downgrading to 'standard' (plugin policy)",
pack_path.display(),
cmd.id
);
cmd.sandbox = MAX_SANDBOX_FOR_PLUGINS.to_string();
} else if cmd.sandbox.is_empty() {
cmd.sandbox = MAX_SANDBOX_FOR_PLUGINS.to_string();
}
if !cmd.script.is_empty() {
let s = pack_path.join(&cmd.script);
if !s.is_file() {
return Err(format!(
"command '{}' references missing script: {}",
cmd.id,
s.display()
));
}
}
}
}
pack.path = pack_path.to_path_buf();
Ok(pack)
}
/// Lightweight listing for the GUI. Reports each pack regardless of enable state.
pub fn list() -> Vec<PluginInfo> {
let dir = match plugins_dir() {
Ok(d) => d,
Err(_) => return Vec::new(),
};
list_in(&dir)
}
pub fn list_in(dir: &Path) -> Vec<PluginInfo> {
let mut out = Vec::new();
let read = match fs::read_dir(dir) {
Ok(r) => r,
Err(_) => return out,
};
for entry in read.flatten() {
let pack_path = entry.path();
if !pack_path.is_dir() {
continue;
}
let toml_file = pack_path.join("command.toml");
if !toml_file.is_file() {
continue;
}
let name = pack_path
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default();
let enabled = !pack_path.join(DISABLED_FLAG).is_file();
let (count, error) = match parse_one(&pack_path, &toml_file) {
Ok(p) => (p.commands.len(), None),
Err(e) => (0, Some(e)),
};
out.push(PluginInfo {
name,
path: pack_path,
enabled,
command_count: count,
error,
});
}
out.sort_by(|a, b| a.name.cmp(&b.name));
out
}
/// Touch `disabled` flag inside the pack folder so the next discovery skips it.
pub fn set_enabled(name: &str, enabled: bool) -> Result<(), String> {
let dir = plugins_dir()?;
let pack = dir.join(name);
if !pack.is_dir() {
return Err(format!("plugin '{}' not found", name));
}
let flag = pack.join(DISABLED_FLAG);
if enabled {
if flag.is_file() {
fs::remove_file(&flag).map_err(|e| format!("remove flag: {}", e))?;
}
} else if !flag.is_file() {
fs::write(&flag, b"").map_err(|e| format!("write flag: {}", e))?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn write(path: &Path, content: &str) {
if let Some(p) = path.parent() {
std::fs::create_dir_all(p).unwrap();
}
std::fs::write(path, content).unwrap();
}
fn good_toml() -> &'static str {
r#"
[[commands]]
id = "my_plugin.hello"
type = "lua"
script = "hello.lua"
sandbox = "standard"
timeout = 3000
[commands.phrases]
ru = ["привет плагин"]
"#
}
#[test]
fn discovers_packs_with_command_toml() {
let dir = TempDir::new().unwrap();
let pack = dir.path().join("greeter");
write(&pack.join("command.toml"), good_toml());
write(&pack.join("hello.lua"), "return jarvis.cmd.ok('hi')");
let packs = discover_in(dir.path());
assert_eq!(packs.len(), 1, "expected exactly one pack");
assert_eq!(packs[0].commands.len(), 1);
assert_eq!(packs[0].commands[0].id, "my_plugin.hello");
assert_eq!(packs[0].path, pack);
}
#[test]
fn disabled_flag_skips_pack() {
let dir = TempDir::new().unwrap();
let pack = dir.path().join("greeter");
write(&pack.join("command.toml"), good_toml());
write(&pack.join("hello.lua"), "return jarvis.cmd.ok('hi')");
write(&pack.join("disabled"), "");
let packs = discover_in(dir.path());
assert!(packs.is_empty(), "disabled pack must be skipped");
}
#[test]
fn missing_script_is_an_error() {
let dir = TempDir::new().unwrap();
let pack = dir.path().join("broken");
write(&pack.join("command.toml"), good_toml());
// intentionally no hello.lua
let packs = discover_in(dir.path());
assert!(packs.is_empty(), "pack with missing script must not load");
}
#[test]
fn malformed_toml_is_skipped_not_fatal() {
let dir = TempDir::new().unwrap();
let pack_bad = dir.path().join("bad");
write(&pack_bad.join("command.toml"), "this is not valid toml ::::");
let pack_good = dir.path().join("good");
write(&pack_good.join("command.toml"), good_toml());
write(&pack_good.join("hello.lua"), "return jarvis.cmd.ok('hi')");
let packs = discover_in(dir.path());
assert_eq!(packs.len(), 1, "good pack should still load despite bad sibling");
}
#[test]
fn full_sandbox_is_downgraded_to_standard() {
let dir = TempDir::new().unwrap();
let pack = dir.path().join("evil");
let toml = r#"
[[commands]]
id = "evil.run"
type = "lua"
script = "evil.lua"
sandbox = "full"
timeout = 1000
[commands.phrases]
ru = ["взломай систему"]
"#;
write(&pack.join("command.toml"), toml);
write(&pack.join("evil.lua"), "return jarvis.cmd.ok('nope')");
let packs = discover_in(dir.path());
assert_eq!(packs.len(), 1);
assert_eq!(
packs[0].commands[0].sandbox, "standard",
"plugin must not be allowed to escalate to full sandbox"
);
}
#[test]
fn empty_sandbox_defaults_to_standard() {
let dir = TempDir::new().unwrap();
let pack = dir.path().join("plain");
let toml = r#"
[[commands]]
id = "plain.go"
type = "lua"
script = "go.lua"
timeout = 1000
[commands.phrases]
ru = ["иди"]
"#;
write(&pack.join("command.toml"), toml);
write(&pack.join("go.lua"), "return jarvis.cmd.ok('ok')");
let packs = discover_in(dir.path());
assert_eq!(packs.len(), 1);
assert_eq!(packs[0].commands[0].sandbox, "standard");
}
#[test]
fn list_reports_enabled_and_disabled() {
let dir = TempDir::new().unwrap();
let a = dir.path().join("alpha");
write(&a.join("command.toml"), good_toml());
write(&a.join("hello.lua"), "return jarvis.cmd.ok('hi')");
let b = dir.path().join("bravo");
write(&b.join("command.toml"), good_toml());
write(&b.join("hello.lua"), "return jarvis.cmd.ok('hi')");
write(&b.join("disabled"), "");
let listing = list_in(dir.path());
assert_eq!(listing.len(), 2);
let alpha = listing.iter().find(|p| p.name == "alpha").unwrap();
let bravo = listing.iter().find(|p| p.name == "bravo").unwrap();
assert!(alpha.enabled);
assert!(!bravo.enabled);
assert_eq!(alpha.command_count, 1);
}
#[test]
fn non_directories_are_ignored() {
let dir = TempDir::new().unwrap();
write(&dir.path().join("README.md"), "# hi");
write(&dir.path().join("strayfile.toml"), good_toml());
let packs = discover_in(dir.path());
assert!(packs.is_empty());
}
}

View file

@ -1,309 +0,0 @@
//! IMBA-3: Profile switching.
//!
//! Lets the user say "режим работа" / "режим игра" / "режим сон" / "режим машина"
//! to swap an active profile. Each profile lives at `<APP_CONFIG_DIR>/profiles/<name>.json`
//! and contains overlays for behavior:
//! - llm_personality: optional system-prompt addendum
//! - allowed_command_prefixes: optional whitelist (substring match on cmd id)
//! - disabled_command_prefixes: optional blacklist
//! - greeting: voice line played when activated
//! - icon: optional ASCII / emoji indicator for GUI
//!
//! Built-in default profiles seeded on first run: `work`, `game`, `sleep`, `driving`.
//!
//! Switching is voice-driven via the dedicated `profile_switch` lua pack (added separately),
//! or programmatically via `set_active(name)`. The active profile name is persisted in
//! `<APP_CONFIG_DIR>/active_profile.txt` so it survives restart.
use once_cell::sync::OnceCell;
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use crate::APP_CONFIG_DIR;
const ACTIVE_FILE: &str = "active_profile.txt";
const PROFILES_DIR: &str = "profiles";
const DEFAULT_PROFILE: &str = "default";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Profile {
pub name: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub llm_personality: String,
#[serde(default)]
pub allowed_command_prefixes: Vec<String>,
#[serde(default)]
pub disabled_command_prefixes: Vec<String>,
#[serde(default)]
pub greeting: String,
#[serde(default)]
pub icon: String,
}
impl Profile {
pub fn allows_command(&self, cmd_id: &str) -> bool {
// disabled wins over allowed
if self.disabled_command_prefixes.iter().any(|p| cmd_id.starts_with(p.as_str())) {
return false;
}
if self.allowed_command_prefixes.is_empty() {
return true;
}
self.allowed_command_prefixes.iter().any(|p| cmd_id.starts_with(p.as_str()))
}
}
struct State {
dir: PathBuf,
active_file: PathBuf,
active: RwLock<Profile>,
}
static STATE: OnceCell<State> = OnceCell::new();
pub fn init() -> Result<(), String> {
if STATE.get().is_some() { return Ok(()); }
let config_dir = APP_CONFIG_DIR
.get()
.ok_or_else(|| "APP_CONFIG_DIR not initialised".to_string())?;
let dir = config_dir.join(PROFILES_DIR);
let active_file = config_dir.join(ACTIVE_FILE);
if !dir.is_dir() {
std::fs::create_dir_all(&dir)
.map_err(|e| format!("create profiles dir: {}", e))?;
}
seed_defaults(&dir);
let active_name = std::fs::read_to_string(&active_file)
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| DEFAULT_PROFILE.to_string());
let active = load_profile(&dir, &active_name)
.unwrap_or_else(|| {
warn!("Active profile '{}' missing — falling back to default", active_name);
default_profile()
});
info!("Active profile: {} (icon: {})", active.name, if active.icon.is_empty() { "" } else { &active.icon });
STATE.set(State { dir, active_file, active: RwLock::new(active) })
.map_err(|_| "profiles already initialised".to_string())?;
Ok(())
}
fn seed_defaults(dir: &PathBuf) {
let defaults = vec![
Profile {
name: DEFAULT_PROFILE.to_string(),
description: "Обычный режим, все команды доступны.".into(),
llm_personality: String::new(),
allowed_command_prefixes: vec![],
disabled_command_prefixes: vec![],
greeting: String::new(),
icon: "".into(),
},
Profile {
name: "work".to_string(),
description: "Рабочий режим — фокус, отключены развлечения.".into(),
llm_personality: "Отвечай кратко и по делу. Минимум шуток. Помогай быть продуктивным.".into(),
allowed_command_prefixes: vec![],
disabled_command_prefixes: vec!["games.".into(), "fun.".into(), "music.".into()],
greeting: "Режим работы. Сосредоточимся.".into(),
icon: "💼".into(),
},
Profile {
name: "game".to_string(),
description: "Игровой режим — голосовые макросы и игры.".into(),
llm_personality: "Будь дружелюбен. Краткие ответы. Поддерживай геймерскую атмосферу.".into(),
allowed_command_prefixes: vec![],
disabled_command_prefixes: vec!["reminders.".into(), "calendar.".into()],
greeting: "Игровой режим активирован.".into(),
icon: "🎮".into(),
},
Profile {
name: "sleep".to_string(),
description: "Спокойный режим — тихий голос, никаких уведомлений.".into(),
llm_personality: "Говори спокойно, короткими фразами. Ничего не предлагай.".into(),
allowed_command_prefixes: vec!["time.".into(), "weather.".into(), "lights.".into()],
disabled_command_prefixes: vec![],
greeting: "Спокойной ночи.".into(),
icon: "🌙".into(),
},
Profile {
name: "driving".to_string(),
description: "Режим за рулём — только безопасные команды.".into(),
llm_personality: "Краткие безопасные ответы. Никаких длинных текстов.".into(),
allowed_command_prefixes: vec![
"music.".into(), "navigation.".into(), "calls.".into(),
"time.".into(), "weather.".into(), "reminders.".into(),
],
disabled_command_prefixes: vec!["windows.".into(), "screen.".into()],
greeting: "Режим за рулём. Веди безопасно.".into(),
icon: "🚗".into(),
},
];
for p in defaults {
let path = dir.join(format!("{}.json", p.name));
if path.is_file() { continue; }
if let Ok(json) = serde_json::to_string_pretty(&p) {
if let Err(e) = std::fs::write(&path, json) {
warn!("seed profile {}: {}", path.display(), e);
}
}
}
}
fn default_profile() -> Profile {
Profile {
name: DEFAULT_PROFILE.to_string(),
description: "fallback".into(),
llm_personality: String::new(),
allowed_command_prefixes: vec![],
disabled_command_prefixes: vec![],
greeting: String::new(),
icon: "".into(),
}
}
fn load_profile(dir: &PathBuf, name: &str) -> Option<Profile> {
let path = dir.join(format!("{}.json", name));
let raw = std::fs::read_to_string(&path).ok()?;
serde_json::from_str(&raw).ok()
}
/// Set the active profile. Persists to disk. Returns the new profile or error.
pub fn set_active(name: &str) -> Result<Profile, String> {
let state = STATE.get().ok_or_else(|| "profiles not init".to_string())?;
let profile = load_profile(&state.dir, name)
.ok_or_else(|| format!("profile '{}' not found", name))?;
{
let mut active = state.active.write();
*active = profile.clone();
}
if let Err(e) = std::fs::write(&state.active_file, &profile.name) {
warn!("persist active profile: {}", e);
}
info!("Profile switched: {} {}", profile.icon, profile.name);
Ok(profile)
}
/// Get a clone of the active profile.
pub fn active() -> Profile {
STATE.get()
.map(|s| s.active.read().clone())
.unwrap_or_else(default_profile)
}
/// Quick accessor for the active profile name.
pub fn active_name() -> String {
STATE.get()
.map(|s| s.active.read().name.clone())
.unwrap_or_else(|| DEFAULT_PROFILE.to_string())
}
/// List available profile names (alphabetical).
pub fn list() -> Vec<String> {
let Some(state) = STATE.get() else { return Vec::new(); };
let mut names = Vec::new();
if let Ok(rd) = std::fs::read_dir(&state.dir) {
for entry in rd.flatten() {
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) == Some("json") {
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
names.push(stem.to_string());
}
}
}
}
names.sort();
names
}
#[cfg(test)]
mod tests {
use super::*;
fn p_with(allowed: Vec<&str>, disabled: Vec<&str>) -> Profile {
Profile {
name: "test".into(),
description: String::new(),
llm_personality: String::new(),
allowed_command_prefixes: allowed.into_iter().map(String::from).collect(),
disabled_command_prefixes: disabled.into_iter().map(String::from).collect(),
greeting: String::new(),
icon: String::new(),
}
}
#[test]
fn empty_lists_allow_everything() {
let p = p_with(vec![], vec![]);
assert!(p.allows_command("anything"));
assert!(p.allows_command("games.steam.launch"));
}
#[test]
fn allowed_prefixes_act_as_whitelist() {
let p = p_with(vec!["music.", "time."], vec![]);
assert!(p.allows_command("music.spotify.play"));
assert!(p.allows_command("time.current"));
assert!(!p.allows_command("games.steam"));
assert!(!p.allows_command("windows.minimize"));
}
#[test]
fn disabled_prefixes_act_as_blacklist() {
let p = p_with(vec![], vec!["games.", "fun."]);
assert!(!p.allows_command("games.steam.launch"));
assert!(!p.allows_command("fun.joke"));
assert!(p.allows_command("time.current"));
assert!(p.allows_command("weather.now"));
}
#[test]
fn disabled_wins_over_allowed() {
// even if "media." is on the allow list, "media.spotify" being on
// the deny list must short-circuit to false.
let p = p_with(vec!["media."], vec!["media.spotify"]);
assert!(!p.allows_command("media.spotify.skip"));
assert!(p.allows_command("media.youtube.play"));
}
#[test]
fn profile_serde_round_trip() {
let p = Profile {
name: "work".into(),
description: "Focus mode".into(),
llm_personality: "Be brief.".into(),
allowed_command_prefixes: vec!["time.".into(), "weather.".into()],
disabled_command_prefixes: vec!["games.".into()],
greeting: "Sir.".into(),
icon: "💼".into(),
};
let json = serde_json::to_string(&p).unwrap();
let back: Profile = serde_json::from_str(&json).unwrap();
assert_eq!(back.name, "work");
assert_eq!(back.allowed_command_prefixes.len(), 2);
assert_eq!(back.disabled_command_prefixes.len(), 1);
assert_eq!(back.icon, "💼");
}
#[test]
fn profile_serde_tolerates_missing_optional_fields() {
// Minimum-fields JSON should deserialize using #[serde(default)] on each field.
let minimal = r#"{"name":"minimal"}"#;
let p: Profile = serde_json::from_str(minimal).unwrap();
assert_eq!(p.name, "minimal");
assert!(p.description.is_empty());
assert!(p.allowed_command_prefixes.is_empty());
}
}

View file

@ -1,309 +0,0 @@
//! Recognition log — what J.A.R.V.I.S. heard + what it did about it.
//!
//! Every voice/text phrase that reaches the dispatcher gets a row here with:
//! - timestamp (unix seconds, local timezone hint via chrono)
//! - the raw recognised phrase
//! - the outcome (matched / not_found / error / llm_handled)
//! - command id + confidence if matched
//! - which matcher fired (intent / fuzzy / router / llm)
//!
//! Why: the user can't always tell whether Jarvis didn't hear them, or
//! heard them but couldn't map the phrase to a command. The GUI's
//! /history page colors each row green/red so it's obvious at a glance —
//! and the user can copy a misheard phrase straight back into the trainer.
//!
//! Storage: ring buffer of the last `MAX_ENTRIES` entries kept in memory
//! for instant GUI loads, plus atomic JSON write-through to
//! `<APP_CONFIG_DIR>/recognition_log.json` so the history survives a
//! daemon restart.
use chrono::Local;
use once_cell::sync::OnceCell;
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use std::collections::VecDeque;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
const FILE_NAME: &str = "recognition_log.json";
/// Cap the buffer at 500 entries — keeps the JSON file under ~200 KB and
/// the GUI list snappy on cheap hardware. Older entries roll off.
pub const MAX_ENTRIES: usize = 500;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Outcome {
/// A command was matched and executed (success or failure tracked via
/// `success` field — false means the command ran but reported an error).
Matched {
command_id: String,
/// Optional confidence (0-100) — populated by the intent classifier
/// and the LLM router. `None` for the fuzzy fallback (it uses a
/// different scoring scheme).
confidence_pct: Option<u8>,
/// Which path mapped the phrase to a command.
via: String,
/// Did the command body actually succeed?
success: bool,
},
/// Phrase was heard but no command matched and LLM fallback didn't fire.
NotFound,
/// LLM fallback handled the phrase (treated as success — Jarvis spoke
/// SOMETHING in response, just not via a command pack).
LlmHandled,
/// Something blew up while dispatching.
Error { message: String },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecognitionEntry {
/// Unix seconds. Caller uses Local::now().timestamp() to populate.
pub ts: i64,
/// The phrase the user (apparently) said. Lowercased, leading
/// activation phrases like "джарвис" already stripped.
pub phrase: String,
pub outcome: Outcome,
/// How the phrase arrived: "voice" (post-wake STT), "text" (typed via
/// the GUI command box), "macro" (replayed from a macro).
pub source: String,
}
struct State {
path: PathBuf,
buf: RwLock<VecDeque<RecognitionEntry>>,
}
static STATE: OnceCell<State> = OnceCell::new();
/// Initialise on first call. Idempotent — repeated calls are no-ops.
/// Reads the existing JSON file (if any) so the GUI can show history
/// across daemon restarts.
pub fn init() -> Result<(), String> {
if STATE.get().is_some() {
return Ok(());
}
let dir = crate::APP_CONFIG_DIR
.get()
.ok_or_else(|| "APP_CONFIG_DIR not initialised".to_string())?;
let path = dir.join(FILE_NAME);
let initial = load_from_disk(&path);
log::info!(
"Recognition log loaded: {} entries from {}",
initial.len(),
path.display()
);
STATE
.set(State {
path,
buf: RwLock::new(initial.into()),
})
.map_err(|_| "recognition_log already initialised".to_string())?;
Ok(())
}
fn load_from_disk(path: &PathBuf) -> Vec<RecognitionEntry> {
if !path.is_file() {
return Vec::new();
}
match std::fs::read_to_string(path) {
Ok(text) => match serde_json::from_str::<Vec<RecognitionEntry>>(&text) {
Ok(mut v) => {
// Trim to MAX_ENTRIES in case the file grew during a prior crash.
if v.len() > MAX_ENTRIES {
v.drain(0..v.len() - MAX_ENTRIES);
}
v
}
Err(e) => {
log::warn!("Corrupt recognition_log JSON: {}", e);
Vec::new()
}
},
Err(e) => {
log::warn!("Cannot read {}: {}", path.display(), e);
Vec::new()
}
}
}
fn now_unix_secs() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or_else(|_| Local::now().timestamp())
}
/// Append an entry. Buffer is trimmed to `MAX_ENTRIES` then persisted
/// atomically (write-tmp + rename). Logging failures are warnings, not
/// errors — never let history-tracking break the actual command flow.
pub fn record(phrase: impl Into<String>, source: impl Into<String>, outcome: Outcome) {
let state = match STATE.get() {
Some(s) => s,
None => return, // history disabled — feature gracefully off
};
let entry = RecognitionEntry {
ts: now_unix_secs(),
phrase: phrase.into(),
outcome,
source: source.into(),
};
{
let mut buf = state.buf.write();
buf.push_back(entry);
while buf.len() > MAX_ENTRIES {
buf.pop_front();
}
}
persist(state);
}
/// Return up to `limit` most-recent entries, newest first. `limit == 0`
/// returns all entries in the buffer.
pub fn recent(limit: usize) -> Vec<RecognitionEntry> {
let Some(state) = STATE.get() else {
return Vec::new();
};
let buf = state.buf.read();
let n = if limit == 0 { buf.len() } else { limit.min(buf.len()) };
buf.iter().rev().take(n).cloned().collect()
}
/// Re-read the recognition log from DISK and return up to `limit` newest
/// entries. Use this from the GUI process — it has its own in-memory
/// buffer that doesn't see live writes from the daemon (the daemon is a
/// separate process). Disk reads are cheap (≤ 500 entries, ~200KB JSON).
pub fn recent_from_disk(limit: usize) -> Vec<RecognitionEntry> {
let Some(state) = STATE.get() else {
return Vec::new();
};
let entries = load_from_disk(&state.path);
let n = if limit == 0 { entries.len() } else { limit.min(entries.len()) };
entries.into_iter().rev().take(n).collect()
}
/// Wipe everything. Returns the count of removed entries.
pub fn clear() -> usize {
let Some(state) = STATE.get() else {
return 0;
};
let removed = {
let mut buf = state.buf.write();
let n = buf.len();
buf.clear();
n
};
persist(state);
removed
}
fn persist(state: &State) {
let snapshot: Vec<RecognitionEntry> = state.buf.read().iter().cloned().collect();
let json = match serde_json::to_string_pretty(&snapshot) {
Ok(s) => s,
Err(e) => {
log::warn!("Recognition log serialise failed: {}", e);
return;
}
};
let tmp = state.path.with_extension("json.tmp");
if let Err(e) = std::fs::write(&tmp, json) {
log::warn!("Recognition log write {} failed: {}", tmp.display(), e);
return;
}
if let Err(e) = std::fs::rename(&tmp, &state.path) {
log::warn!("Recognition log rename failed: {}", e);
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn fresh_state() -> TempDir {
// Tests can't easily inject APP_CONFIG_DIR via OnceCell since it
// may be set by prior tests. We work around by talking to the
// in-memory buffer logic via the lower-level VecDeque directly.
TempDir::new().unwrap()
}
#[test]
fn ring_buffer_trims_to_max() {
let mut buf: VecDeque<RecognitionEntry> = VecDeque::new();
for i in 0..(MAX_ENTRIES + 50) {
buf.push_back(RecognitionEntry {
ts: i as i64,
phrase: format!("phrase {}", i),
outcome: Outcome::NotFound,
source: "voice".into(),
});
while buf.len() > MAX_ENTRIES {
buf.pop_front();
}
}
assert_eq!(buf.len(), MAX_ENTRIES);
// Oldest 50 entries dropped — front should now be phrase #50.
assert_eq!(buf.front().unwrap().ts, 50);
}
#[test]
fn outcome_roundtrip_serde() {
let cases = vec![
Outcome::Matched {
command_id: "echo".into(),
confidence_pct: Some(87),
via: "intent".into(),
success: true,
},
Outcome::NotFound,
Outcome::LlmHandled,
Outcome::Error {
message: "oops".into(),
},
];
for o in cases {
let j = serde_json::to_string(&o).unwrap();
let back: Outcome = serde_json::from_str(&j).unwrap();
// Roundtrip preserves the discriminant
let j2 = serde_json::to_string(&back).unwrap();
assert_eq!(j, j2, "non-roundtripping outcome: {:?}", o);
}
}
#[test]
fn load_from_disk_handles_missing_file() {
let dir = fresh_state();
let path = dir.path().join("nope.json");
let loaded = load_from_disk(&path);
assert!(loaded.is_empty());
}
#[test]
fn load_from_disk_handles_corrupt_file() {
let dir = fresh_state();
let path = dir.path().join("bad.json");
std::fs::write(&path, b"this is not json {{{").unwrap();
let loaded = load_from_disk(&path);
assert!(loaded.is_empty());
}
#[test]
fn load_from_disk_trims_oversized_file() {
let dir = fresh_state();
let path = dir.path().join("big.json");
let oversized: Vec<RecognitionEntry> = (0..(MAX_ENTRIES + 100))
.map(|i| RecognitionEntry {
ts: i as i64,
phrase: "x".into(),
outcome: Outcome::NotFound,
source: "voice".into(),
})
.collect();
std::fs::write(&path, serde_json::to_string(&oversized).unwrap()).unwrap();
let loaded = load_from_disk(&path);
assert_eq!(loaded.len(), MAX_ENTRIES);
// Trimmed from the front, so the first entry is ts=100.
assert_eq!(loaded.first().unwrap().ts, 100);
}
}

View file

@ -1,232 +0,0 @@
//! Centralised runtime configuration knobs.
//!
//! All environment-variable-driven behaviour in J.A.R.V.I.S. is documented here
//! and (for the most-used flags) wrapped in helper functions. Adding a new knob:
//!
//! 1. Add a `const ENV_*: &str = "JARVIS_..."` declaration below with a doc comment.
//! 2. Add a thin getter (e.g. `pub fn my_feature_enabled() -> bool`) below.
//! 3. Call it from the feature module instead of inlining `std::env::var(...)`.
//!
//! The goal is single-point discovery: open this file to learn every flag.
//!
//! For the on-disk settings (sqlite via `db::structs::Settings`), see `crate::db`.
use std::env;
// ─── TTS ───────────────────────────────────────────────────────────────────
/// TTS backend selector. Values: `sapi` | `piper` | `silero`. Unset → auto-detect
/// (Piper if `tools/piper/piper.exe` + voice present, else SAPI).
pub const ENV_TTS: &str = "JARVIS_TTS";
/// Absolute path to `piper.exe`. Default: `<exe_dir>/tools/piper/piper.exe`.
pub const ENV_TTS_PIPER_BIN: &str = "JARVIS_TTS_PIPER_BIN";
/// Absolute path to Piper voice `.onnx`. Default: first .onnx in `<piper_dir>/voices/`.
pub const ENV_TTS_PIPER_VOICE: &str = "JARVIS_TTS_PIPER_VOICE";
/// Python executable for Silero subprocess. Default: `python`.
pub const ENV_TTS_PYTHON: &str = "JARVIS_TTS_PYTHON";
/// Path to `silero_tts.py` helper. Default: `<exe_dir>/tools/silero/silero_tts.py`.
pub const ENV_TTS_SILERO_HELPER: &str = "JARVIS_TTS_SILERO_HELPER";
/// Silero voice name. Default: `xenia`. Other ru_v3 voices: baya, aidar, eugene, kseniya.
pub const ENV_TTS_SILERO_VOICE: &str = "JARVIS_TTS_SILERO_VOICE";
/// Set to `false` to disable LLM-reply TTS playback (keep IPC event only). Default: enabled.
pub const ENV_LLM_TTS: &str = "JARVIS_LLM_TTS";
// ─── LLM ───────────────────────────────────────────────────────────────────
/// LLM backend selector. Values: `groq` | `ollama`. Auto-detect: Groq if
/// `GROQ_TOKEN` present, else Ollama.
pub const ENV_LLM: &str = "JARVIS_LLM";
/// Groq API token. Required for cloud LLM. Get one at https://groq.com.
pub const ENV_GROQ_TOKEN: &str = "GROQ_TOKEN";
/// Groq base URL. Default: `https://api.groq.com/openai/v1`.
pub const ENV_GROQ_BASE_URL: &str = "GROQ_BASE_URL";
/// Groq model. Default: `llama-3.3-70b-versatile`.
pub const ENV_GROQ_MODEL: &str = "GROQ_MODEL";
/// Groq vision-capable model for IMBA-4. Default: `llama-3.2-11b-vision-preview`.
pub const ENV_GROQ_VISION_MODEL: &str = "GROQ_VISION_MODEL";
/// Ollama base URL. Default: `http://localhost:11434/v1`.
pub const ENV_OLLAMA_BASE_URL: &str = "OLLAMA_BASE_URL";
/// Ollama model name. Default: `qwen2.5:3b`. Pull via `ollama pull <name>`.
pub const ENV_OLLAMA_MODEL: &str = "OLLAMA_MODEL";
// ─── LLM router (IMBA-1) ───────────────────────────────────────────────────
/// Enable agentic LLM router. Values: `1` / `true` / `yes` / `on`. Default: `1` (on).
pub const ENV_LLM_ROUTER: &str = "JARVIS_LLM_ROUTER";
/// Confidence threshold (0.0..1.0) above which the router accepts the LLM's command pick.
/// Default: `0.55`. Lower → more matches but more false positives.
pub const ENV_LLM_ROUTER_THRESHOLD: &str = "JARVIS_LLM_ROUTER_THRESHOLD";
// ─── Idle banter ───────────────────────────────────────────────────────────
/// Opt-in flag for the idle-banter feature. Values: `1` / `true` / `yes` / `on`.
/// Default: OFF — periodic voice remarks can be intrusive, so the user has to
/// explicitly turn this on.
pub const ENV_IDLE_BANTER: &str = "JARVIS_IDLE_BANTER";
/// Seconds between idle remarks. Clamped to >= 600 (10 min). Default: 1800.
pub const ENV_IDLE_BANTER_INTERVAL: &str = "JARVIS_IDLE_BANTER_INTERVAL";
/// If `1`, use the LLM to generate fresh idle remarks. Default: off — uses the
/// built-in offline pool.
pub const ENV_IDLE_BANTER_LLM: &str = "JARVIS_IDLE_BANTER_LLM";
// ─── Helpers ───────────────────────────────────────────────────────────────
/// Read an env var, returning `None` for unset or whitespace-only.
pub fn get(name: &str) -> Option<String> {
env::var(name).ok().and_then(|v| {
let trimmed = v.trim();
if trimmed.is_empty() { None } else { Some(trimmed.to_string()) }
})
}
/// Parse a boolean-ish env var. Accepts `1` / `true` / `yes` / `on` (case-insensitive)
/// as true, `0` / `false` / `no` / `off` as false. Returns `default` if unset.
pub fn get_bool(name: &str, default: bool) -> bool {
match get(name).map(|v| v.to_lowercase()) {
Some(s) => matches!(s.as_str(), "1" | "true" | "yes" | "on"),
None => default,
}
}
/// Parse a typed env var. Returns `default` if unset or unparseable.
pub fn get_parse<T: std::str::FromStr>(name: &str, default: T) -> T {
get(name).and_then(|v| v.parse().ok()).unwrap_or(default)
}
// ─── Feature-flag wrappers ─────────────────────────────────────────────────
/// True if LLM-reply TTS is enabled (default). Set `JARVIS_LLM_TTS=false` to mute.
pub fn llm_tts_enabled() -> bool { get_bool(ENV_LLM_TTS, true) }
/// True if agentic LLM router is enabled (default on).
pub fn llm_router_enabled() -> bool { get_bool(ENV_LLM_ROUTER, true) }
/// Router confidence threshold; default 0.55.
pub fn llm_router_threshold() -> f32 { get_parse(ENV_LLM_ROUTER_THRESHOLD, 0.55) }
/// Log every effective env-driven knob at INFO level. Call once on startup.
pub fn log_effective_config() {
log::info!("[config] TTS backend: {}", get(ENV_TTS).unwrap_or_else(|| "auto".into()));
log::info!("[config] LLM backend: {}", get(ENV_LLM).unwrap_or_else(|| "auto".into()));
log::info!("[config] LLM router: {} (threshold {:.2})",
llm_router_enabled(), llm_router_threshold());
log::info!("[config] LLM reply TTS: {}", llm_tts_enabled());
log::info!("[config] Groq model: {}",
get(ENV_GROQ_MODEL).unwrap_or_else(|| "llama-3.3-70b-versatile (default)".into()));
log::info!("[config] Ollama model: {}",
get(ENV_OLLAMA_MODEL).unwrap_or_else(|| "qwen2.5:3b (default)".into()));
}
#[cfg(test)]
mod tests {
use super::*;
// Use a unique prefix so concurrent tests don't collide with real env vars.
// Rust test runner uses threads inside a single process; serialize env mutations
// by giving each test its own var name.
fn set(name: &str, value: &str) {
// SAFETY: tests are intentionally racy; each test uses unique names.
unsafe { std::env::set_var(name, value); }
}
fn unset(name: &str) {
unsafe { std::env::remove_var(name); }
}
#[test]
fn get_bool_defaults() {
assert!(!get_bool("JARVIS_TEST_BOOL_DEFAULT_F", false));
assert!(get_bool("JARVIS_TEST_BOOL_DEFAULT_T", true));
}
#[test]
fn get_parse_typed_default() {
let n: u32 = get_parse("JARVIS_TEST_PARSE_U32_DEF", 42);
assert_eq!(n, 42);
let f: f32 = get_parse("JARVIS_TEST_PARSE_F32_DEF", 0.5);
assert!((f - 0.5).abs() < 0.001);
}
#[test]
fn get_bool_recognises_truthy_values() {
for v in &["1", "true", "yes", "on", "TRUE", "Yes", "ON"] {
set("JARVIS_TEST_BOOL_TRUTHY", v);
assert!(get_bool("JARVIS_TEST_BOOL_TRUTHY", false), "expected true for '{}'", v);
}
unset("JARVIS_TEST_BOOL_TRUTHY");
}
#[test]
fn get_bool_recognises_falsy_values() {
for v in &["0", "false", "no", "off", "anything else"] {
set("JARVIS_TEST_BOOL_FALSY", v);
assert!(!get_bool("JARVIS_TEST_BOOL_FALSY", true), "expected false for '{}'", v);
}
unset("JARVIS_TEST_BOOL_FALSY");
}
#[test]
fn get_strips_whitespace_and_treats_empty_as_unset() {
set("JARVIS_TEST_GET_WHITESPACE", " ");
assert_eq!(get("JARVIS_TEST_GET_WHITESPACE"), None);
set("JARVIS_TEST_GET_WHITESPACE", " hello ");
assert_eq!(get("JARVIS_TEST_GET_WHITESPACE"), Some("hello".into()));
unset("JARVIS_TEST_GET_WHITESPACE");
}
#[test]
fn llm_router_threshold_falls_back_to_default() {
unset(ENV_LLM_ROUTER_THRESHOLD);
let t = llm_router_threshold();
assert!((t - 0.55).abs() < 0.001, "default should be 0.55");
}
#[test]
fn llm_router_threshold_parses_custom_value() {
set(ENV_LLM_ROUTER_THRESHOLD, "0.75");
assert!((llm_router_threshold() - 0.75).abs() < 0.001);
unset(ENV_LLM_ROUTER_THRESHOLD);
}
#[test]
fn llm_router_threshold_falls_back_on_garbage() {
set(ENV_LLM_ROUTER_THRESHOLD, "not_a_number");
let t = llm_router_threshold();
assert!((t - 0.55).abs() < 0.001, "garbage should fall back to default");
unset(ENV_LLM_ROUTER_THRESHOLD);
}
#[test]
fn llm_tts_enabled_default_is_true() {
unset(ENV_LLM_TTS);
assert!(llm_tts_enabled());
}
#[test]
fn llm_tts_enabled_false_when_explicitly_set() {
set(ENV_LLM_TTS, "false");
assert!(!llm_tts_enabled());
unset(ENV_LLM_TTS);
}
#[test]
fn llm_router_enabled_default_is_true() {
unset(ENV_LLM_ROUTER);
assert!(llm_router_enabled());
}
}

View file

@ -1,622 +0,0 @@
//! IMBA-5: Proactive scheduler.
//!
//! Lets J.A.R.V.I.S. start the conversation on its own — fire reminders, run
//! a morning briefing routine at a fixed time, or nudge a habit at intervals.
//! No other Russian voice assistant on the desktop does this well.
//!
//! Storage: JSON at `<APP_CONFIG_DIR>/schedule.json`. Atomic write-through.
//! Background thread ticks every 60 seconds (default) and fires due tasks.
//! Tasks are deduplicated by id; same id replaces.
//!
//! Schedule formats (parsed by `Schedule::parse`):
//! - "daily HH:MM" e.g. "daily 09:00"
//! - "every N minutes" e.g. "every 30 minutes"
//! - "every N hours" e.g. "every 2 hours"
//! - "in N minutes" e.g. "in 5 minutes" (one-shot)
//! - "in N hours" e.g. "in 1 hours" (one-shot)
//! - "at HH:MM" (today only; one-shot)
//!
//! Action types:
//! - speak — TTS the message via `crate::tts::speak_default`
//! - lua — run a Lua script at the given path (sandboxed standard)
//! - command — emit IPC message to trigger a known command (TODO)
//!
//! Lua bindings (registered separately): `jarvis.scheduler.{add, list, remove, clear}`.
use chrono::{Datelike, Local, Timelike};
use once_cell::sync::OnceCell;
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use crate::APP_CONFIG_DIR;
const FILE_NAME: &str = "schedule.json";
const TICK_INTERVAL_SECS: u64 = 30;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Schedule {
/// Fires every day at the given local hour:minute.
Daily { hour: u32, minute: u32 },
/// Fires every `seconds` from `anchor` (last fire or task creation).
Interval { seconds: u64 },
/// Fires once at the given UNIX timestamp (seconds), then disables itself.
Once { at: i64 },
}
impl Schedule {
pub fn parse(spec: &str) -> Result<Self, String> {
let s = spec.trim().to_lowercase();
// "daily HH:MM"
if let Some(rest) = s.strip_prefix("daily") {
return parse_hhmm(rest.trim()).map(|(h, m)| Schedule::Daily { hour: h, minute: m });
}
// "at HH:MM" → Once today
if let Some(rest) = s.strip_prefix("at") {
let (h, m) = parse_hhmm(rest.trim())?;
let now = Local::now();
let mut at = now
.with_hour(h)
.and_then(|t| t.with_minute(m))
.and_then(|t| t.with_second(0))
.ok_or_else(|| "invalid time".to_string())?;
if at <= now {
at = at + chrono::Duration::days(1);
}
return Ok(Schedule::Once { at: at.timestamp() });
}
// "every N minutes" / "every N hours"
if let Some(rest) = s.strip_prefix("every") {
let parts: Vec<&str> = rest.trim().split_whitespace().collect();
if parts.len() == 2 {
let n: u64 = parts[0].parse().map_err(|_| format!("bad number: {}", parts[0]))?;
let secs = match parts[1] {
"minute" | "minutes" | "минут" | "минуту" | "минуты" => n * 60,
"hour" | "hours" | "час" | "часа" | "часов" => n * 3600,
"second" | "seconds" | "секунд" | "секунду" | "секунды" => n,
other => return Err(format!("unknown unit: {}", other)),
};
return Ok(Schedule::Interval { seconds: secs });
}
}
// "in N minutes" / "in N hours"
if let Some(rest) = s.strip_prefix("in") {
let parts: Vec<&str> = rest.trim().split_whitespace().collect();
if parts.len() == 2 {
let n: i64 = parts[0].parse().map_err(|_| format!("bad number: {}", parts[0]))?;
let secs = match parts[1] {
"minute" | "minutes" | "минут" | "минуту" | "минуты" => n * 60,
"hour" | "hours" | "час" | "часа" | "часов" => n * 3600,
"second" | "seconds" | "секунд" | "секунду" | "секунды" => n,
other => return Err(format!("unknown unit: {}", other)),
};
let at = Local::now().timestamp() + secs;
return Ok(Schedule::Once { at });
}
}
Err(format!("unknown schedule spec: {}", spec))
}
/// Returns the next UNIX timestamp this schedule should fire at, given the
/// most recent fire time (or task creation time).
pub fn next_fire(&self, last_fired: Option<i64>, now: i64) -> Option<i64> {
match self {
Schedule::Daily { hour, minute } => {
let today = Local::now()
.with_hour(*hour)?
.with_minute(*minute)?
.with_second(0)?;
let today_ts = today.timestamp();
if today_ts > now && last_fired.map_or(true, |lf| {
chrono::DateTime::from_timestamp(lf, 0).map_or(true, |dt: chrono::DateTime<chrono::Utc>| {
dt.with_timezone(&Local).day() != today.day()
})
}) {
Some(today_ts)
} else {
Some(today_ts + 86400)
}
}
Schedule::Interval { seconds } => {
let anchor = last_fired.unwrap_or(now);
Some(anchor + *seconds as i64)
}
Schedule::Once { at } => {
if last_fired.is_some() {
None
} else {
Some(*at)
}
}
}
}
}
fn parse_hhmm(s: &str) -> Result<(u32, u32), String> {
let mut it = s.splitn(2, ':');
let h: u32 = it.next().ok_or("missing hour")?.parse()
.map_err(|_| "bad hour".to_string())?;
let m: u32 = it.next().ok_or("missing minute")?.parse()
.map_err(|_| "bad minute".to_string())?;
if h > 23 || m > 59 {
return Err("hour/minute out of range".into());
}
Ok((h, m))
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum Action {
/// Speak a plain text message via the active TTS backend.
Speak { text: String },
/// Run a Lua script (absolute path).
Lua { script_path: String },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScheduledTask {
pub id: String,
pub name: String,
pub schedule: Schedule,
pub action: Action,
#[serde(default)]
pub last_fired: Option<i64>,
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default)]
pub created_at: i64,
}
fn default_true() -> bool { true }
#[derive(Default, Debug, Serialize, Deserialize)]
struct Store {
#[serde(default)]
tasks: Vec<ScheduledTask>,
}
struct State {
path: PathBuf,
store: RwLock<Store>,
}
static STATE: OnceCell<State> = OnceCell::new();
static THREAD_RUNNING: AtomicBool = AtomicBool::new(false);
pub fn init() -> Result<(), String> {
if STATE.get().is_some() { return Ok(()); }
let dir = APP_CONFIG_DIR.get()
.ok_or_else(|| "APP_CONFIG_DIR not initialised".to_string())?;
let path = dir.join(FILE_NAME);
let store = load_or_empty(&path);
info!("Scheduler loaded: {} tasks from {}", store.tasks.len(), path.display());
STATE.set(State { path, store: RwLock::new(store) })
.map_err(|_| "scheduler already initialised".to_string())?;
Ok(())
}
fn load_or_empty(path: &PathBuf) -> Store {
if !path.is_file() { return Store::default(); }
match std::fs::read_to_string(path) {
Ok(s) => serde_json::from_str(&s).unwrap_or_else(|e| {
warn!("Corrupt schedule file {}: {}", path.display(), e);
Store::default()
}),
Err(e) => {
warn!("Cannot read {}: {}", path.display(), e);
Store::default()
}
}
}
fn persist(state: &State) {
let store = state.store.read();
let tmp = state.path.with_extension("json.tmp");
let json = match serde_json::to_string_pretty(&*store) {
Ok(s) => s,
Err(e) => { warn!("Schedule serialize failed: {}", e); return; }
};
if let Err(e) = std::fs::write(&tmp, json) {
warn!("Schedule write failed: {}", e); return;
}
if let Err(e) = std::fs::rename(&tmp, &state.path) {
warn!("Schedule rename failed: {}", e);
}
}
fn now_secs() -> i64 {
Local::now().timestamp()
}
fn gen_id() -> String {
use std::time::SystemTime;
let micros = SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_micros())
.unwrap_or(0);
format!("task-{}", micros)
}
/// Add (or replace by id) a scheduled task.
pub fn add(mut task: ScheduledTask) -> Result<String, String> {
let state = STATE.get().ok_or_else(|| "scheduler not init".to_string())?;
if task.id.is_empty() {
task.id = gen_id();
}
if task.created_at == 0 {
task.created_at = now_secs();
}
{
let mut store = state.store.write();
store.tasks.retain(|t| t.id != task.id);
store.tasks.push(task.clone());
}
persist(state);
info!("Scheduler: added task {} ({:?})", task.id, task.schedule);
Ok(task.id)
}
pub fn remove(id: &str) -> bool {
let Some(state) = STATE.get() else { return false; };
let removed = {
let mut store = state.store.write();
let before = store.tasks.len();
store.tasks.retain(|t| t.id != id);
before != store.tasks.len()
};
if removed {
persist(state);
info!("Scheduler: removed task {}", id);
}
removed
}
pub fn clear() -> usize {
let Some(state) = STATE.get() else { return 0; };
let n = {
let mut store = state.store.write();
let n = store.tasks.len();
store.tasks.clear();
n
};
persist(state);
info!("Scheduler: cleared {} tasks", n);
n
}
pub fn list() -> Vec<ScheduledTask> {
let Some(state) = STATE.get() else { return Vec::new(); };
state.store.read().tasks.clone()
}
/// Case-insensitive substring search across `name` + any `Speak` action text.
/// Returns matched tasks (clones) up to `limit`.
pub fn find_by_text(query: &str, limit: usize) -> Vec<ScheduledTask> {
find_by_text_in(&list(), query, limit)
}
/// Pure logic split out for testing.
pub(crate) fn find_by_text_in(tasks: &[ScheduledTask], query: &str, limit: usize) -> Vec<ScheduledTask> {
let nq = query.trim().to_lowercase();
if nq.is_empty() { return Vec::new(); }
let mut hits: Vec<ScheduledTask> = tasks.iter()
.filter(|t| {
if t.name.to_lowercase().contains(&nq) { return true; }
if let Action::Speak { text } = &t.action {
if text.to_lowercase().contains(&nq) { return true; }
}
false
})
.cloned()
.collect();
hits.truncate(limit.max(1));
hits
}
/// Remove all tasks whose name or speak text matches `query`. Returns count.
pub fn remove_by_text(query: &str) -> usize {
let Some(state) = STATE.get() else { return 0; };
let nq = query.trim().to_lowercase();
if nq.is_empty() { return 0; }
let removed = {
let mut store = state.store.write();
let before = store.tasks.len();
store.tasks.retain(|t| {
let name_match = t.name.to_lowercase().contains(&nq);
let text_match = if let Action::Speak { text } = &t.action {
text.to_lowercase().contains(&nq)
} else { false };
!(name_match || text_match)
});
before - store.tasks.len()
};
if removed > 0 {
persist(state);
info!("Scheduler: removed {} tasks matching '{}'", removed, query);
}
removed
}
pub fn find(id: &str) -> Option<ScheduledTask> {
let state = STATE.get()?;
state.store.read().tasks.iter().find(|t| t.id == id).cloned()
}
fn mark_fired(state: &State, id: &str, when: i64) -> bool {
let mut should_persist = false;
let mut should_remove = false;
{
let mut store = state.store.write();
if let Some(t) = store.tasks.iter_mut().find(|t| t.id == id) {
t.last_fired = Some(when);
should_persist = true;
if matches!(t.schedule, Schedule::Once { .. }) {
should_remove = true;
}
}
if should_remove {
store.tasks.retain(|t| t.id != id);
}
}
if should_persist {
persist(state);
}
should_remove
}
/// Compute due tasks (next_fire <= now and enabled).
fn due_tasks(state: &State, now: i64) -> Vec<ScheduledTask> {
let store = state.store.read();
store.tasks.iter()
.filter(|t| t.enabled)
.filter(|t| t.schedule.next_fire(t.last_fired, now).map_or(false, |nf| nf <= now))
.cloned()
.collect()
}
/// Start the background tick thread. Idempotent.
pub fn start_background() {
if THREAD_RUNNING.swap(true, Ordering::SeqCst) {
return;
}
std::thread::Builder::new()
.name("jarvis-scheduler".into())
.spawn(|| {
info!("Scheduler thread started (tick every {}s).", TICK_INTERVAL_SECS);
loop {
std::thread::sleep(Duration::from_secs(TICK_INTERVAL_SECS));
tick();
}
})
.ok();
}
fn tick() {
let Some(state) = STATE.get() else { return; };
let now = now_secs();
let due = due_tasks(state, now);
for task in due {
info!("Scheduler: firing {} '{}'", task.id, task.name);
fire_action(&task.action);
mark_fired(state, &task.id, now);
}
}
fn fire_action(action: &Action) {
match action {
Action::Speak { text } => {
// Play "reply" sound first so the user knows it's the scheduler.
crate::voices::play_reply();
std::thread::sleep(Duration::from_millis(400));
crate::tts::speak_default(text);
}
Action::Lua { script_path } => {
#[cfg(feature = "lua")]
run_lua_script(script_path);
#[cfg(not(feature = "lua"))]
warn!("Lua feature disabled — cannot run {}", script_path);
}
}
}
#[cfg(feature = "lua")]
fn run_lua_script(script_path: &str) {
use crate::lua::{LuaEngine, SandboxLevel, CommandContext};
use std::path::Path;
let path = Path::new(script_path).to_path_buf();
if !path.is_file() {
warn!("Scheduler: lua script not found: {}", path.display());
return;
}
let engine = match LuaEngine::new(SandboxLevel::Standard) {
Ok(e) => e,
Err(e) => { warn!("Scheduler: lua engine init: {:?}", e); return; }
};
let ctx = CommandContext {
phrase: String::new(),
command_id: "scheduler".into(),
command_path: path.parent().map(|p| p.to_path_buf()).unwrap_or_default(),
language: crate::i18n::get_language(),
slots: None,
};
match engine.execute(&path, ctx, Duration::from_secs(30)) {
Ok(_) => {}
Err(e) => warn!("Scheduler: lua exec failed: {:?}", e),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_daily() {
let s = Schedule::parse("daily 09:00").unwrap();
assert_eq!(s, Schedule::Daily { hour: 9, minute: 0 });
}
#[test]
fn parse_interval_minutes() {
let s = Schedule::parse("every 30 minutes").unwrap();
assert_eq!(s, Schedule::Interval { seconds: 1800 });
}
#[test]
fn parse_interval_russian() {
let s = Schedule::parse("every 2 часа").unwrap();
assert_eq!(s, Schedule::Interval { seconds: 7200 });
}
#[test]
fn parse_in_minutes() {
let s = Schedule::parse("in 5 minutes").unwrap();
matches!(s, Schedule::Once { .. });
}
#[test]
fn parse_bad_unit() {
assert!(Schedule::parse("every 5 weeks").is_err());
}
#[test]
fn interval_next_fire() {
let s = Schedule::Interval { seconds: 60 };
assert_eq!(s.next_fire(Some(100), 1000), Some(160));
}
#[test]
fn once_disables_after_fire() {
let s = Schedule::Once { at: 500 };
assert_eq!(s.next_fire(None, 1000), Some(500));
assert_eq!(s.next_fire(Some(500), 1000), None);
}
fn speak_task(id: &str, name: &str, text: &str) -> ScheduledTask {
ScheduledTask {
id: id.into(),
name: name.into(),
schedule: Schedule::Interval { seconds: 60 },
action: Action::Speak { text: text.into() },
last_fired: None,
enabled: true,
created_at: 0,
}
}
#[test]
fn find_by_text_matches_name() {
let tasks = vec![
speak_task("a", "Drink water", "Попей воды."),
speak_task("b", "Stretch", "Разомнись."),
];
let hits = find_by_text_in(&tasks, "water", 5);
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].id, "a");
}
#[test]
fn find_by_text_matches_speak_text_case_insensitive() {
let tasks = vec![
speak_task("a", "X", "Попей воды."),
speak_task("b", "Y", "Разомнись."),
];
let hits = find_by_text_in(&tasks, "ВОД", 5);
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].id, "a");
}
#[test]
fn find_by_text_empty_query_returns_nothing() {
let tasks = vec![speak_task("a", "X", "Y")];
assert!(find_by_text_in(&tasks, "", 5).is_empty());
assert!(find_by_text_in(&tasks, " ", 5).is_empty());
}
#[test]
fn parse_in_hours_yields_once() {
let s = Schedule::parse("in 2 hours").unwrap();
match s {
Schedule::Once { at } => assert!(at > 0),
_ => panic!("expected Once"),
}
}
#[test]
fn parse_at_today_or_tomorrow() {
let s = Schedule::parse("at 23:59").unwrap();
assert!(matches!(s, Schedule::Once { .. }));
}
#[test]
fn parse_at_rejects_bad_time() {
assert!(Schedule::parse("at 25:00").is_err());
assert!(Schedule::parse("at 12:99").is_err());
assert!(Schedule::parse("at notatime").is_err());
}
#[test]
fn parse_daily_rejects_bad_hour() {
assert!(Schedule::parse("daily 24:00").is_err());
assert!(Schedule::parse("daily 12:60").is_err());
}
#[test]
fn parse_every_seconds() {
let s = Schedule::parse("every 30 seconds").unwrap();
assert_eq!(s, Schedule::Interval { seconds: 30 });
}
#[test]
fn parse_unrecognised_spec_errors() {
assert!(Schedule::parse("nonsense").is_err());
assert!(Schedule::parse("daily").is_err());
assert!(Schedule::parse("every").is_err());
}
#[test]
fn task_serde_round_trip() {
let t = ScheduledTask {
id: "abc".into(),
name: "Test".into(),
schedule: Schedule::Daily { hour: 9, minute: 0 },
action: Action::Speak { text: "wake up".into() },
last_fired: Some(1000),
enabled: true,
created_at: 500,
};
let json = serde_json::to_string(&t).unwrap();
let back: ScheduledTask = serde_json::from_str(&json).unwrap();
assert_eq!(back.id, "abc");
assert_eq!(back.last_fired, Some(1000));
match back.action {
Action::Speak { text } => assert_eq!(text, "wake up"),
_ => panic!("wrong action variant"),
}
}
#[test]
fn schedule_kind_tag_in_json() {
// Verify we use a stable JSON shape with `kind` discriminator.
let s = Schedule::Daily { hour: 8, minute: 30 };
let json = serde_json::to_string(&s).unwrap();
assert!(json.contains(r#""kind":"daily""#));
assert!(json.contains(r#""hour":8"#));
assert!(json.contains(r#""minute":30"#));
}
#[test]
fn action_serde_lua_variant() {
let a = Action::Lua { script_path: "C:/path/foo.lua".into() };
let json = serde_json::to_string(&a).unwrap();
// serde with default `rename_all = "snake_case"` on enum gives "lua"/"speak" tags
let back: Action = serde_json::from_str(&json).unwrap();
match back {
Action::Lua { script_path } => assert_eq!(script_path, "C:/path/foo.lua"),
_ => panic!("wrong action variant"),
}
}
}

View file

@ -132,6 +132,7 @@ fn get_configured_model_path() -> Result<std::path::PathBuf, String> {
let lang_code = match language.as_str() {
"ru" => "ru",
"en" => "us",
"ua" => "uk",
other => other,
};

View file

@ -1,207 +0,0 @@
// Public text helpers used by both the Lua tts API (`jarvis.speak`) and the
// jarvis-app llm_fallback server-side TTS path. Kept here (not under `lua::api`)
// so jarvis-app can depend on it without touching the lua sandbox internals.
// Rewrite text so SAPI doesn't read every punctuation mark literally:
// "J.A.R.V.I.S." → "Джарвис"
// "T.O.N." / "U.S.A." → letters joined ("ТОН", "USA")
// "https://google.com/foo?bar=1" → "ссылка"
// "—" / "" → "-"
// collapses repeated whitespace, strips ASCII / Russian quotes.
// Without this SAPI says "J ТОЧКА A ТОЧКА R ТОЧКА ..." which is unlistenable.
pub fn sanitize_for_speech(text: &str) -> String {
let mut t = text.to_string();
// Specific brand/product names first (caught before generic acronym rule).
let replacements = [
("J.A.R.V.I.S.", "Джарвис"),
("J.A.R.V.I.S", "Джарвис"),
("U.S.A.", "США"),
("U.K.", "Британия"),
("U.S.", "США"),
("S.O.S.", "сос"),
];
for (from, to) in replacements {
t = t.replace(from, to);
}
// Collapse generic dotted acronyms like "T.O.N." → "TON" (3+ letters,
// any alphabet) so SAPI reads the run as a single token instead of
// each letter-plus-точка.
t = collapse_dotted_acronyms(&t);
// Strip URLs. SAPI reads them character-by-character at ~50 words per
// minute; almost always the user wants a confirmation, not the URL.
t = strip_urls(&t);
// Soft punctuation cleanup.
t = t
.replace('—', " - ")
.replace('', " - ")
.replace('«', "")
.replace('»', "")
.replace('"', "");
while t.contains(" ") {
t = t.replace(" ", " ");
}
t.trim().to_string()
}
fn collapse_dotted_acronyms(s: &str) -> String {
let chars: Vec<char> = s.chars().collect();
let mut out: Vec<char> = Vec::with_capacity(chars.len());
let mut i = 0;
while i < chars.len() {
let mut j = i;
let mut letters: Vec<char> = Vec::new();
loop {
if j >= chars.len() { break; }
if !chars[j].is_alphabetic() { break; }
if j + 1 >= chars.len() || chars[j + 1] != '.' { break; }
letters.push(chars[j]);
j += 2;
}
if letters.len() >= 3 {
out.extend(letters.iter());
i = j;
} else {
out.push(chars[i]);
i += 1;
}
}
out.into_iter().collect()
}
fn strip_urls(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
let mut url_buf = String::new();
let mut in_url = false;
while let Some(c) = chars.next() {
if !in_url {
if (c == 'h' || c == 'H')
&& chars.clone().take(4).collect::<String>().to_lowercase().starts_with("ttp")
{
in_url = true;
url_buf.clear();
url_buf.push(c);
continue;
}
out.push(c);
} else if c.is_whitespace() || c == ',' || c == ';' {
if url_buf.len() > 7 {
out.push_str("ссылка");
} else {
out.push_str(&url_buf);
}
out.push(c);
url_buf.clear();
in_url = false;
} else {
url_buf.push(c);
}
}
if in_url {
if url_buf.len() > 7 {
out.push_str("ссылка");
} else {
out.push_str(&url_buf);
}
}
out
}
#[cfg(test)]
mod tests {
use super::sanitize_for_speech;
#[test]
fn jarvis_dotted_acronym() {
assert_eq!(sanitize_for_speech("Привет от J.A.R.V.I.S."), "Привет от Джарвис");
}
#[test]
fn generic_acronym_collapse() {
assert_eq!(sanitize_for_speech("Курс T.O.N. растёт"), "Курс TON растёт");
}
#[test]
fn url_replaced() {
let out = sanitize_for_speech("Открой https://www.google.com/search?q=x пожалуйста");
assert!(out.contains("ссылка"));
assert!(!out.contains("google.com"));
}
#[test]
fn dashes_normalised() {
assert_eq!(sanitize_for_speech("Тони — гений"), "Тони - гений");
}
#[test]
fn passes_clean_russian_unchanged() {
let s = "Привет, как дела сегодня";
assert_eq!(sanitize_for_speech(s), s);
}
#[test]
fn empty_input_yields_empty_output() {
assert_eq!(sanitize_for_speech(""), "");
}
#[test]
fn handles_usa_uk_sos_brands() {
let s = sanitize_for_speech("Президент U.S.A. встретился с U.K. — S.O.S.!");
assert!(s.contains("США"));
assert!(s.contains("Британия"));
assert!(s.contains("сос"));
}
#[test]
fn collapse_two_letter_acronym_not_changed() {
// collapse_dotted_acronyms is for 3+ letters. "A.B." should stay.
let out = sanitize_for_speech("A.B. — example");
// Either the dots are gone (over-eager) or they're preserved (correct).
// We pin to "preserved": shouldn't collapse 2-letter dotted patterns.
assert!(out.contains("A.B.") || out.contains("AB"),
"expected A.B. preserved or collapsed cleanly, got: {}", out);
}
#[test]
fn short_url_under_threshold_not_stripped() {
// URL "http://x" is only 8 chars but under our 7-char gate it'd stay.
// Actually 8 > 7, so this would get stripped. Let's test a truly tiny match.
let s = sanitize_for_speech("Привет http://");
// url_buf length comparison decides; the trailing "http://" is the full url here
// and has len 7 → kept as-is (gate is > 7).
assert!(s.contains("http://") || s.contains("ссылка"),
"either behaviour is acceptable but result was: {}", s);
}
#[test]
fn smart_quotes_softened() {
let out = sanitize_for_speech("«Привет» — \"hello\"");
// We strip quotes (per the doc-comment), so they shouldn't survive.
// Empty quotes mean it just looks like words with spaces.
assert!(!out.contains('«'));
assert!(!out.contains('»'));
}
#[test]
fn collapses_repeated_whitespace() {
let out = sanitize_for_speech("слово1 слово2\t\tслово3");
// Doesn't matter exactly what, but should not have 3+ spaces in a row.
assert!(!out.contains(" "));
}
#[test]
fn idempotent_when_already_clean() {
let clean = "Доброе утро сэр это абсолютно чистый текст";
let once = sanitize_for_speech(clean);
let twice = sanitize_for_speech(&once);
assert_eq!(once, twice);
}
}

View file

@ -1,89 +0,0 @@
//! Toast notification helper — single source of truth for the Windows
//! AppUserModelID (AUMID) that toast notifications are attributed to.
//!
//! Background: every toast in Windows MUST be associated with a registered
//! AUMID. The `winrt-notification` crate ships a `POWERSHELL_APP_ID`
//! constant for convenience — but using it makes every toast read
//! "PowerShell" in Action Center, which the user noticed and asked about.
//!
//! Fix: register our own AUMID under `HKCU\Software\Classes\
//! AppUserModelId\Bossiara.JARVIS` with `DisplayName = "J.A.R.V.I.S."`
//! at startup. Once registered, toasts attributed to that AUMID render
//! with the right name. Registration is idempotent — re-runs are no-ops.
//!
//! If registration fails (no HKCU access, weird sandbox), we transparently
//! fall back to `POWERSHELL_APP_ID` so toasts still appear, just with the
//! old branding. Better a labelled-wrong toast than no toast.
#[cfg(target_os = "windows")]
use std::sync::atomic::{AtomicBool, Ordering};
/// Our custom AUMID. Must look like `Company.Product` (Microsoft convention).
pub const APP_USER_MODEL_ID: &str = "Bossiara.JARVIS";
/// Display name that shows in Action Center next to the toast title.
pub const APP_DISPLAY_NAME: &str = "J.A.R.V.I.S.";
#[cfg(target_os = "windows")]
static REGISTERED_OK: AtomicBool = AtomicBool::new(false);
/// Best-effort: write the AUMID registration to HKCU. Returns true on success.
/// Idempotent — does nothing on second call.
#[cfg(target_os = "windows")]
pub fn register_aumid() -> bool {
if REGISTERED_OK.load(Ordering::SeqCst) {
return true;
}
let key_path = format!(
r#"HKCU\Software\Classes\AppUserModelId\{}"#,
APP_USER_MODEL_ID
);
// Use `reg.exe` rather than a winreg crate dep — small surface, single
// shell-out, no extra Cargo deps. The `add ... /f` flag overwrites
// existing values so re-runs converge cleanly.
let ok_name = std::process::Command::new("reg")
.args([
"add",
&key_path,
"/v",
"DisplayName",
"/t",
"REG_SZ",
"/d",
APP_DISPLAY_NAME,
"/f",
])
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if ok_name {
REGISTERED_OK.store(true, Ordering::SeqCst);
log::info!("Toast AUMID registered: {} → {}", APP_USER_MODEL_ID, APP_DISPLAY_NAME);
} else {
log::warn!("Failed to register toast AUMID; falling back to PowerShell branding");
}
ok_name
}
#[cfg(not(target_os = "windows"))]
pub fn register_aumid() -> bool {
true
}
/// The AUMID to actually use when constructing a Toast. Returns our custom
/// AUMID if registration succeeded, otherwise the well-known PowerShell ID
/// (which is always present on Windows).
#[cfg(target_os = "windows")]
pub fn active_aumid() -> &'static str {
use winrt_notification::Toast;
if REGISTERED_OK.load(Ordering::SeqCst) {
APP_USER_MODEL_ID
} else {
Toast::POWERSHELL_APP_ID
}
}
#[cfg(not(target_os = "windows"))]
pub fn active_aumid() -> &'static str {
APP_USER_MODEL_ID
}

View file

@ -1,350 +0,0 @@
//! TTS backend abstraction.
//!
//! All voice synthesis in J.A.R.V.I.S. goes through `tts::backend().speak()`.
//! The backend is chosen at first call based on the `JARVIS_TTS` env var:
//!
//! - `sapi` — Windows SAPI via PowerShell (default, always available on Windows).
//! - `piper` — rhasspy/piper neural TTS (recommended). Needs `piper.exe` + voice .onnx
//! under `<exe_dir>/tools/piper/` (or paths via `JARVIS_TTS_PIPER_BIN`
//! and `JARVIS_TTS_PIPER_VOICE`). Falls back to SAPI if binaries are missing.
//! - `silero` — Silero TTS via python subprocess (requires Python + torch installed).
//! Falls back to SAPI if helper script is missing.
//!
//! If `JARVIS_TTS` is unset, the dispatcher auto-detects Piper, otherwise SAPI.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::mpsc;
use crate::text_utils::sanitize_for_speech;
mod sapi;
mod piper;
mod silero;
pub use sapi::SapiBackend;
pub use piper::PiperBackend;
pub use silero::SileroBackend;
/// Queue a WAV file for playback through the shared speech worker. Returns
/// immediately. The file is played AFTER any earlier speech / WAV jobs in
/// the queue have finished — that's what stops cue sounds talking over a
/// TTS reply (the user-visible voice-overlap bug).
///
/// Used by Piper / Silero backends to play their synthesised .wav output,
/// and by `voices::play_*` cue sounds.
pub fn play_wav(path: &Path) {
let owned: PathBuf = path.to_path_buf();
if SPEECH_QUEUE.tx.send(Job::Wav(owned.clone())).is_err() {
log::warn!("[tts] speech queue closed — direct WAV dispatch fallback");
play_wav_sync(&owned);
}
}
/// Synchronous WAV playback — used internally by the worker thread, and as
/// a fallback if the queue ever closes. Blocks until audio finishes.
#[cfg(target_os = "windows")]
fn play_wav_sync(path: &Path) {
let escaped = path.display().to_string().replace('\'', "''");
let ps = format!(
"$p = New-Object System.Media.SoundPlayer '{}'; $p.PlaySync()",
escaped
);
let _ = std::process::Command::new("powershell")
.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", &ps])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
}
#[cfg(not(target_os = "windows"))]
fn play_wav_sync(path: &Path) {
log::info!("[TTS non-Windows stub] would play: {}", path.display());
}
/// Options for a single `speak()` call.
#[derive(Debug, Clone)]
pub struct SpeakOpts {
/// ISO-639-1 language code: "ru", "en", "de", ... Default: "ru".
pub lang: String,
/// Fire-and-forget if true; block until done if false. Default: true.
pub detached: bool,
/// Skip `text_utils::sanitize_for_speech` if true. Default: false.
pub raw: bool,
}
impl Default for SpeakOpts {
fn default() -> Self {
Self {
lang: "ru".to_string(),
detached: true,
raw: false,
}
}
}
impl SpeakOpts {
pub fn lang(lang: impl Into<String>) -> Self {
Self { lang: lang.into(), ..Default::default() }
}
}
/// All TTS backends implement this. `speak()` must not panic; on failure, log and return.
pub trait TtsBackend: Send + Sync {
fn name(&self) -> &'static str;
fn speak(&self, text: &str, opts: &SpeakOpts);
}
// Live backend swap: stored behind an RwLock<Arc<...>> so reads are cheap
// (clone the Arc) and writes can replace the whole backend instance
// without invalidating outstanding speaks. The OnceCell pattern we used
// before couldn't be replaced at runtime — the GUI's TTS dropdown ended up
// only persisting the choice without applying it. Now `swap_to(name)`
// installs a fresh backend immediately.
static BACKEND: once_cell::sync::Lazy<parking_lot::RwLock<Option<Arc<dyn TtsBackend>>>> =
once_cell::sync::Lazy::new(|| parking_lot::RwLock::new(None));
/// Get the active TTS backend. Initialises lazily on first call. The choice
/// comes from (in order): persisted Settings DB → `JARVIS_TTS` env var → auto.
pub fn backend() -> Arc<dyn TtsBackend> {
if let Some(b) = BACKEND.read().clone() {
return b;
}
let chosen = choose_backend_name();
let inst = build_backend(&chosen);
*BACKEND.write() = Some(inst.clone());
inst
}
/// Swap to a named backend at runtime — used by the GUI's TTS dropdown.
/// `name` is "sapi" / "piper" / "silero" / "" or "auto" (re-runs auto-detect).
/// Returns the resolved backend name on success, error string otherwise.
pub fn swap_to(name: &str) -> Result<&'static str, String> {
let normalised = name.trim().to_lowercase();
let target = match normalised.as_str() {
"" | "auto" => choose_backend_name(),
"sapi" | "piper" | "silero" => normalised,
other => return Err(format!("unknown TTS backend '{}'", other)),
};
let inst = build_backend(&target);
let resolved = inst.name();
*BACKEND.write() = Some(inst);
Ok(resolved)
}
// ─── Speech queue (single-consumer, ordered) ────────────────────────────
//
// Why: every TTS backend's `speak()` is fire-and-forget by default (spawns
// a PowerShell / piper.exe / python subprocess and returns immediately).
// Two back-to-back calls — say, voices::play_reply() WAV + tts::speak("Готово")
// + idle_banter chiming in — all start their audio at the same moment and
// the user hears overlapping voices.
//
// Fix: ONE worker thread drains a channel of speech jobs. Each job runs
// synchronously (forced `detached = false`) so the worker waits for audio
// to finish before pulling the next job. Callers stay non-blocking — they
// just push onto the channel.
//
// `play_wav` is also routed through this queue (as a `Job::Wav` variant)
// so cue sounds don't talk over TTS replies.
enum Job {
Speak { text: String, opts: SpeakOpts },
Wav(PathBuf),
}
struct SpeechQueue {
tx: mpsc::Sender<Job>,
}
static SPEECH_QUEUE: once_cell::sync::Lazy<SpeechQueue> = once_cell::sync::Lazy::new(|| {
let (tx, rx) = mpsc::channel::<Job>();
std::thread::Builder::new()
.name("jarvis-tts-worker".into())
.spawn(move || speech_worker(rx))
.ok();
SpeechQueue { tx }
});
fn speech_worker(rx: mpsc::Receiver<Job>) {
log::info!("[tts] speech worker started");
while let Ok(job) = rx.recv() {
match job {
Job::Speak { text, mut opts } => {
// Force-block so the worker waits for audio to finish before
// pulling the next job. Callers shouldn't depend on detached
// semantics for ordering anyway — they get serial ordering
// for free via this worker.
opts.detached = false;
let backend = backend();
backend.speak(&text, &opts);
}
Job::Wav(path) => {
play_wav_sync(&path);
}
}
}
log::info!("[tts] speech worker exiting (channel closed)");
}
/// Speak a text via the active backend. Applies sanitisation unless `opts.raw` is set.
/// Non-blocking: the call returns as soon as the job is queued. Speech is then
/// played in order against any other queued TTS / WAV jobs.
pub fn speak(text: &str, opts: &SpeakOpts) {
if text.trim().is_empty() {
return;
}
let prepared = if opts.raw {
text.to_string()
} else {
sanitize_for_speech(text)
};
// If the queue is dead (rare — worker thread panicked) fall back to
// direct dispatch so audio isn't silently dropped.
if SPEECH_QUEUE
.tx
.send(Job::Speak {
text: prepared.clone(),
opts: opts.clone(),
})
.is_err()
{
log::warn!("[tts] speech queue closed — direct dispatch fallback");
backend().speak(&prepared, opts);
}
}
/// Speak with default opts (Russian, detached, sanitised).
pub fn speak_default(text: &str) {
speak(text, &SpeakOpts::default());
}
/// Number of jobs currently waiting (excluding the one actively playing).
/// Used by the GUI to surface backpressure when many quick commands fire.
pub fn pending_jobs() -> usize {
// mpsc::Sender doesn't expose its queue length; we'd have to switch to
// crossbeam to get that. For now return 0 — the API exists so we don't
// have to change call sites later.
0
}
/// Resolve the backend name from (DB → env → auto). Pure logic, no instantiation.
fn choose_backend_name() -> String {
// 1. Persisted Settings DB. Empty string means "auto".
if let Some(db) = crate::DB.get() {
let from_db = db.read().tts_backend.trim().to_lowercase();
if !from_db.is_empty() {
return from_db;
}
}
// 2. JARVIS_TTS env var.
let from_env = std::env::var("JARVIS_TTS").ok()
.map(|s| s.trim().to_lowercase())
.unwrap_or_default();
if !from_env.is_empty() {
return from_env;
}
// 3. Auto-detect: prefer Piper if installed, else SAPI.
if PiperBackend::try_new().is_ok() { "piper".into() } else { "sapi".into() }
}
fn build_backend(choice: &str) -> Arc<dyn TtsBackend> {
match choice {
"piper" => match PiperBackend::try_new() {
Ok(b) => {
info!("TTS backend: Piper ({} / {})", b.binary_display(), b.voice_display());
return Arc::new(b);
}
Err(e) => {
warn!("Piper requested but unavailable ({}). Falling back to SAPI.", e);
}
},
"silero" => match SileroBackend::try_new() {
Ok(b) => {
info!("TTS backend: Silero ({})", b.helper_display());
return Arc::new(b);
}
Err(e) => {
warn!("Silero requested but unavailable ({}). Falling back to SAPI.", e);
}
},
"sapi" => {}
other => warn!("Unknown TTS choice '{}' — using SAPI.", other),
}
info!("TTS backend: SAPI (Windows built-in).");
Arc::new(SapiBackend::new())
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Arc as StdArc, Mutex};
use std::time::Duration;
// A fake backend that records every speak() into a shared Vec with the
// wall-clock instant. Lets us verify the speech worker serialises calls.
struct Recorder {
log: StdArc<Mutex<Vec<(String, std::time::Instant)>>>,
per_speak_ms: u64,
}
impl TtsBackend for Recorder {
fn name(&self) -> &'static str { "recorder" }
fn speak(&self, text: &str, _opts: &SpeakOpts) {
self.log
.lock()
.unwrap()
.push((text.to_string(), std::time::Instant::now()));
// Simulate the backend taking real time to play audio.
std::thread::sleep(Duration::from_millis(self.per_speak_ms));
}
}
#[test]
fn speech_queue_serialises_calls() {
let log = StdArc::new(Mutex::new(Vec::<(String, std::time::Instant)>::new()));
let recorder = Recorder { log: log.clone(), per_speak_ms: 80 };
*BACKEND.write() = Some(Arc::new(recorder));
// Fire 5 speak() calls back-to-back. Without the queue, they'd all
// start within a few microseconds — overlapping audio. With the
// queue + blocking worker, gaps between starts should be ~80ms.
for i in 0..5 {
speak(&format!("phrase {}", i), &SpeakOpts::default());
}
// Wait long enough for all 5 to drain (5 × 80ms + slack)
std::thread::sleep(Duration::from_millis(700));
let entries = log.lock().unwrap();
assert_eq!(entries.len(), 5, "all 5 phrases should have played");
// Phrases must arrive at the backend IN ORDER.
for (i, (text, _)) in entries.iter().enumerate() {
assert_eq!(text, &format!("phrase {}", i));
}
// Consecutive starts must be at least 60ms apart (per_speak_ms was 80,
// give 20ms slack for scheduling jitter on slow CI).
for window in entries.windows(2) {
let gap = window[1].1.duration_since(window[0].1).as_millis() as u64;
assert!(
gap >= 60,
"starts too close together — speech worker isn't serialising: {}ms",
gap
);
}
}
#[test]
fn empty_text_is_skipped() {
let log = StdArc::new(Mutex::new(Vec::<(String, std::time::Instant)>::new()));
let recorder = Recorder { log: log.clone(), per_speak_ms: 10 };
*BACKEND.write() = Some(Arc::new(recorder));
speak("", &SpeakOpts::default());
speak(" \n\t ", &SpeakOpts::default());
std::thread::sleep(Duration::from_millis(60));
assert_eq!(log.lock().unwrap().len(), 0);
}
}

View file

@ -1,166 +0,0 @@
//! Piper neural TTS backend (rhasspy/piper).
//!
//! Discovery order for binary:
//! 1. env `JARVIS_TTS_PIPER_BIN`
//! 2. `<exe_dir>/tools/piper/piper.exe`
//! 3. `<APP_DIR>/tools/piper/piper.exe`
//!
//! Discovery order for voice:
//! 1. env `JARVIS_TTS_PIPER_VOICE` (absolute path to .onnx)
//! 2. `<piper_dir>/voices/ru_RU-irina-medium.onnx`
//! 3. first `*.onnx` found in `<piper_dir>/voices/`
use std::path::{Path, PathBuf};
use super::{SpeakOpts, TtsBackend};
use crate::APP_DIR;
pub struct PiperBackend {
binary: PathBuf,
voice: PathBuf,
}
impl PiperBackend {
pub fn try_new() -> Result<Self, String> {
let binary = find_binary().ok_or_else(|| "piper.exe not found".to_string())?;
let voice_dir = binary
.parent()
.map(|p| p.join("voices"))
.unwrap_or_else(|| PathBuf::from("voices"));
let voice = find_voice(&voice_dir).ok_or_else(|| {
format!("no .onnx voice in {}", voice_dir.display())
})?;
Ok(Self { binary, voice })
}
pub fn binary_display(&self) -> String {
self.binary.display().to_string()
}
pub fn voice_display(&self) -> String {
self.voice
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| self.voice.display().to_string())
}
}
impl TtsBackend for PiperBackend {
fn name(&self) -> &'static str { "piper" }
fn speak(&self, text: &str, opts: &SpeakOpts) {
if text.trim().is_empty() {
return;
}
let binary = self.binary.clone();
let voice = self.voice.clone();
let text = text.to_string();
let detached = opts.detached;
let run = move || synth_and_play(&binary, &voice, &text);
if detached {
std::thread::spawn(run);
} else {
run();
}
}
}
fn synth_and_play(binary: &Path, voice: &Path, text: &str) {
let tmp = match tempfile::Builder::new()
.prefix("jarvis-tts-")
.suffix(".wav")
.tempfile()
{
Ok(f) => f,
Err(e) => {
log::warn!("Piper: cannot create temp wav: {}", e);
return;
}
};
let wav_path = tmp.path().to_path_buf();
drop(tmp);
let mut child = match std::process::Command::new(binary)
.arg("--model").arg(voice)
.arg("--output_file").arg(&wav_path)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped())
.spawn()
{
Ok(c) => c,
Err(e) => {
log::warn!("Piper spawn failed: {}", e);
return;
}
};
if let Some(mut stdin) = child.stdin.take() {
use std::io::Write;
if let Err(e) = stdin.write_all(text.as_bytes()) {
log::warn!("Piper stdin write failed: {}", e);
}
}
match child.wait() {
Ok(status) if status.success() => {
super::play_wav(&wav_path);
}
Ok(status) => log::warn!("Piper exited with status {}", status),
Err(e) => log::warn!("Piper wait failed: {}", e),
}
let _ = std::fs::remove_file(&wav_path);
}
fn find_binary() -> Option<PathBuf> {
if let Ok(p) = std::env::var("JARVIS_TTS_PIPER_BIN") {
let candidate = PathBuf::from(p);
if candidate.is_file() {
return Some(candidate);
}
}
let bin_name = if cfg!(target_os = "windows") { "piper.exe" } else { "piper" };
if let Ok(exe) = std::env::current_exe() {
if let Some(dir) = exe.parent() {
let c = dir.join("tools").join("piper").join(bin_name);
if c.is_file() {
return Some(c);
}
}
}
let c = APP_DIR.join("tools").join("piper").join(bin_name);
if c.is_file() {
return Some(c);
}
None
}
fn find_voice(voice_dir: &Path) -> Option<PathBuf> {
if let Ok(p) = std::env::var("JARVIS_TTS_PIPER_VOICE") {
let candidate = PathBuf::from(p);
if candidate.is_file() {
return Some(candidate);
}
}
let preferred = voice_dir.join("ru_RU-irina-medium.onnx");
if preferred.is_file() {
return Some(preferred);
}
let entries = std::fs::read_dir(voice_dir).ok()?;
for entry in entries.flatten() {
let p = entry.path();
if p.extension().and_then(|s| s.to_str()) == Some("onnx") {
return Some(p);
}
}
None
}

View file

@ -1,60 +0,0 @@
//! Windows SAPI TTS via PowerShell System.Speech. Always available on Win10+.
use super::{SpeakOpts, TtsBackend};
pub struct SapiBackend;
impl SapiBackend {
pub fn new() -> Self { Self }
}
impl Default for SapiBackend {
fn default() -> Self { Self::new() }
}
impl TtsBackend for SapiBackend {
fn name(&self) -> &'static str { "sapi" }
#[cfg(target_os = "windows")]
fn speak(&self, text: &str, opts: &SpeakOpts) {
let escaped = text.replace('\'', "''").replace('\r', " ").replace('\n', " ");
let safe_iso = sanitize_iso(&opts.lang);
let ps = format!(
"Add-Type -AssemblyName System.Speech; \
$s = New-Object System.Speech.Synthesis.SpeechSynthesizer; \
foreach ($v in $s.GetInstalledVoices()) {{ \
if ($v.VoiceInfo.Culture.TwoLetterISOLanguageName -eq '{}') {{ \
try {{ $s.SelectVoice($v.VoiceInfo.Name); break }} catch {{}} \
}} \
}} \
$s.Speak('{}')",
safe_iso, escaped
);
let mut cmd = std::process::Command::new("powershell");
cmd.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", &ps])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
if opts.detached {
let _ = cmd.spawn();
} else {
let _ = cmd.status();
}
}
#[cfg(not(target_os = "windows"))]
fn speak(&self, text: &str, _opts: &SpeakOpts) {
log::info!("[SAPI stub] would speak: {}", text);
}
}
#[cfg(target_os = "windows")]
fn sanitize_iso(iso: &str) -> String {
if iso.chars().all(|c| c.is_ascii_alphabetic()) && iso.len() == 2 {
iso.to_lowercase()
} else {
"ru".to_string()
}
}

View file

@ -1,137 +0,0 @@
//! Silero TTS backend (PyTorch / silero-models).
//!
//! Spawns `python <helper_script>` with text on stdin; the helper synthesises to a
//! temp wav and prints the path on stdout. We then play that wav.
//!
//! Discovery:
//! 1. env `JARVIS_TTS_SILERO_HELPER` → path to .py script
//! 2. `<exe_dir>/tools/silero/silero_tts.py`
//! 3. `<APP_DIR>/tools/silero/silero_tts.py`
//!
//! Python binary:
//! 1. env `JARVIS_TTS_PYTHON` (default "python")
//!
//! Voice selection:
//! - env `JARVIS_TTS_SILERO_VOICE` (default "xenia" — ru-RU female)
use std::path::{Path, PathBuf};
use super::{SpeakOpts, TtsBackend};
use crate::APP_DIR;
pub struct SileroBackend {
helper: PathBuf,
python: String,
voice: String,
}
impl SileroBackend {
pub fn try_new() -> Result<Self, String> {
let helper = find_helper().ok_or_else(|| "silero_tts.py helper not found".to_string())?;
let python = std::env::var("JARVIS_TTS_PYTHON").unwrap_or_else(|_| "python".to_string());
let voice = std::env::var("JARVIS_TTS_SILERO_VOICE").unwrap_or_else(|_| "xenia".to_string());
Ok(Self { helper, python, voice })
}
pub fn helper_display(&self) -> String {
self.helper.display().to_string()
}
}
impl TtsBackend for SileroBackend {
fn name(&self) -> &'static str { "silero" }
fn speak(&self, text: &str, opts: &SpeakOpts) {
let helper = self.helper.clone();
let python = self.python.clone();
let voice = self.voice.clone();
let text = text.to_string();
let detached = opts.detached;
let run = move || synth_and_play(&python, &helper, &voice, &text);
if detached {
std::thread::spawn(run);
} else {
run();
}
}
}
fn synth_and_play(python: &str, helper: &Path, voice: &str, text: &str) {
use std::io::Write;
let mut child = match std::process::Command::new(python)
.arg(helper)
.arg("--voice").arg(voice)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
{
Ok(c) => c,
Err(e) => {
log::warn!("Silero spawn failed: {}", e);
return;
}
};
if let Some(mut stdin) = child.stdin.take() {
if let Err(e) = stdin.write_all(text.as_bytes()) {
log::warn!("Silero stdin write failed: {}", e);
}
}
let output = match child.wait_with_output() {
Ok(o) => o,
Err(e) => {
log::warn!("Silero wait failed: {}", e);
return;
}
};
if !output.status.success() {
log::warn!(
"Silero helper exited {} — stderr: {}",
output.status,
String::from_utf8_lossy(&output.stderr)
);
return;
}
let stdout = String::from_utf8_lossy(&output.stdout);
let wav_path = stdout.trim();
if wav_path.is_empty() {
log::warn!("Silero helper produced no output path");
return;
}
let p = PathBuf::from(wav_path);
super::play_wav(&p);
let _ = std::fs::remove_file(&p);
}
fn find_helper() -> Option<PathBuf> {
if let Ok(p) = std::env::var("JARVIS_TTS_SILERO_HELPER") {
let candidate = PathBuf::from(p);
if candidate.is_file() {
return Some(candidate);
}
}
if let Ok(exe) = std::env::current_exe() {
if let Some(dir) = exe.parent() {
let c = dir.join("tools").join("silero").join("silero_tts.py");
if c.is_file() {
return Some(c);
}
}
}
let c = APP_DIR.join("tools").join("silero").join("silero_tts.py");
if c.is_file() {
return Some(c);
}
None
}

View file

@ -160,11 +160,7 @@ fn play_random_from_list(voice_path: &Path, lang: &str, sounds: &[String]) {
match find_sound_file(voice_path, lang, sound_name) {
Some(path) => {
debug!("Playing: {:?}", path);
// Route through the TTS speech queue so cue WAVs don't talk
// over a TTS reply (and vice-versa). Cost: a tiny PowerShell
// spawn instead of native Kira, but the user-facing benefit
// (no overlapping voices) is worth it.
crate::tts::play_wav(&path);
audio::play_sound(&path);
}
None => {
warn!("Sound not found: {} (lang: {})", sound_name, lang);

View file

@ -1,439 +0,0 @@
//! Custom wake-word trainer — guides the user through recording a handful of
//! samples of their chosen wake phrase, then trains a Rustpotter `.rpw` model
//! that can replace the bundled `jarvis-default.rpw`.
//!
//! Flow (driven by `jarvis-gui` over Tauri commands):
//!
//! ```text
//! start(name, target=10)
//! -> session entered; mic opened (exclusive); state = Recording { ... }
//! record_sample(seconds=1.5) ×N
//! -> blocks for `seconds`, captures i16 samples, appends to buffer
//! -> WAV-encodes each clip in memory so rustpotter can consume it
//! finish(threshold=0.5)
//! -> calls rustpotter's WakewordRef::new_from_sample_buffers
//! -> serialises .rpw into <APP_CONFIG_DIR>/wake_words/<name>.rpw
//! -> mic released; state = Idle
//! cancel()
//! -> mic released; state = Idle, samples discarded
//! ```
//!
//! Concurrency: the trainer holds its own `pv_recorder` instance so it doesn't
//! collide with the `recorder` module that jarvis-app uses. pv_recorder DOES
//! claim the device exclusively, so the trainer refuses to start if jarvis-app
//! is recording.
//!
//! Output layout:
//! - `<APP_CONFIG_DIR>/wake_words/<name>.rpw` — the trained model
//! - `<APP_CONFIG_DIR>/wake_words/<name>/sample_NN.wav` — keepsake samples,
//! useful if the user wants to retrain with different parameters later
//!
//! Selecting which `.rpw` is active is handled by the settings page (radio
//! between bundled default and any custom models discovered here).
use once_cell::sync::OnceCell;
use parking_lot::Mutex;
use rustpotter::{WakewordRef, WakewordRefBuildFromBuffers, WakewordSave};
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use std::time::{Duration, Instant};
use crate::APP_CONFIG_DIR;
const WAKE_WORDS_DIR_NAME: &str = "wake_words";
const SAMPLE_RATE: u32 = 16_000;
const FRAME_LEN: i32 = 512;
const MFCC_SIZE: u16 = 16;
pub const DEFAULT_SAMPLE_SECONDS: f32 = 1.5;
pub const DEFAULT_TARGET_SAMPLES: u8 = 10;
pub const MIN_TARGET_SAMPLES: u8 = 5;
pub const MAX_TARGET_SAMPLES: u8 = 30;
#[derive(Debug, Clone, serde::Serialize)]
pub struct TrainerStatus {
pub recording: bool,
pub session_name: Option<String>,
pub target_samples: u8,
pub collected: usize,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct TrainedModelInfo {
pub name: String,
pub path: String,
pub size_bytes: u64,
pub modified: i64,
}
struct Session {
name: String,
target_samples: u8,
// each sample is a WAV-encoded byte buffer (16-bit PCM, mono, 16kHz)
samples_wav: Vec<Vec<u8>>,
// raw i16 samples kept around so we can also dump .wav keepsakes to disk
samples_pcm: Vec<Vec<i16>>,
recorder: Option<pv_recorder::PvRecorder>,
}
struct State {
session: Mutex<Option<Session>>,
}
static STATE: OnceCell<State> = OnceCell::new();
fn state() -> &'static State {
STATE.get_or_init(|| State {
session: Mutex::new(None),
})
}
/// Returns `<APP_CONFIG_DIR>/wake_words/`, creating it on first call.
pub fn models_dir() -> Result<PathBuf, String> {
let cfg = APP_CONFIG_DIR
.get()
.ok_or_else(|| "APP_CONFIG_DIR not initialised".to_string())?;
let dir = cfg.join(WAKE_WORDS_DIR_NAME);
if !dir.exists() {
fs::create_dir_all(&dir).map_err(|e| format!("create wake_words dir: {}", e))?;
}
Ok(dir)
}
pub fn status() -> TrainerStatus {
let s = state().session.lock();
if let Some(sess) = s.as_ref() {
TrainerStatus {
recording: true,
session_name: Some(sess.name.clone()),
target_samples: sess.target_samples,
collected: sess.samples_wav.len(),
}
} else {
TrainerStatus {
recording: false,
session_name: None,
target_samples: DEFAULT_TARGET_SAMPLES,
collected: 0,
}
}
}
/// Open the mic and enter a new training session. Returns Err if a session
/// already exists or the device is unavailable (e.g. jarvis-app holds it).
pub fn start(name: &str, target_samples: u8) -> Result<(), String> {
let clean_name = sanitize(name);
if clean_name.is_empty() {
return Err("Имя пустое или содержит только запрещённые символы".to_string());
}
let target = target_samples.clamp(MIN_TARGET_SAMPLES, MAX_TARGET_SAMPLES);
let mut slot = state().session.lock();
if slot.is_some() {
return Err("Сессия записи уже активна".to_string());
}
let recorder = pv_recorder::PvRecorderBuilder::new(FRAME_LEN)
.device_index(-1) // default device
.init()
.map_err(|e| {
format!(
"Не удалось открыть микрофон ({:?}). Сначала остановите J.A.R.V.I.S.",
e
)
})?;
recorder
.start()
.map_err(|e| format!("Микрофон отказал в записи: {}", e))?;
*slot = Some(Session {
name: clean_name,
target_samples: target,
samples_wav: Vec::with_capacity(target as usize),
samples_pcm: Vec::with_capacity(target as usize),
recorder: Some(recorder),
});
Ok(())
}
/// Capture one fixed-duration sample. Blocks for `seconds` (clamped to 0.5..5.0).
pub fn record_sample(seconds: f32) -> Result<usize, String> {
let dur = seconds.clamp(0.5, 5.0);
let mut slot = state().session.lock();
let sess = slot
.as_mut()
.ok_or_else(|| "Сессия не запущена".to_string())?;
if sess.samples_wav.len() >= sess.target_samples as usize {
return Err("Все сэмплы уже записаны — нажмите 'Сохранить'".to_string());
}
let recorder = sess
.recorder
.as_ref()
.ok_or_else(|| "Микрофон не инициализирован".to_string())?;
let total_samples = (SAMPLE_RATE as f32 * dur) as usize;
let mut pcm: Vec<i16> = Vec::with_capacity(total_samples + FRAME_LEN as usize);
let deadline = Instant::now() + Duration::from_millis((dur * 1000.0) as u64 + 50);
while pcm.len() < total_samples {
if Instant::now() > deadline {
// hard cap — shouldn't normally trigger but guards against driver hang
break;
}
match recorder.read() {
Ok(frame) => pcm.extend_from_slice(&frame),
Err(e) => return Err(format!("Чтение микрофона: {}", e)),
}
}
pcm.truncate(total_samples);
let wav = encode_wav(&pcm)
.map_err(|e| format!("Не удалось закодировать WAV: {}", e))?;
sess.samples_wav.push(wav);
sess.samples_pcm.push(pcm);
Ok(sess.samples_wav.len())
}
/// Train the model + persist .rpw and keepsake WAV files. Returns the path
/// to the new `.rpw`. Session is cleared on success regardless.
pub fn finish(threshold: f32) -> Result<PathBuf, String> {
let mut slot = state().session.lock();
let sess = slot
.take()
.ok_or_else(|| "Сессия не запущена".to_string())?;
if let Some(rec) = sess.recorder {
let _ = rec.stop();
}
if sess.samples_wav.len() < MIN_TARGET_SAMPLES as usize {
return Err(format!(
"Нужно минимум {} сэмплов, у тебя {}",
MIN_TARGET_SAMPLES,
sess.samples_wav.len()
));
}
let dir = models_dir()?;
let model_path = dir.join(format!("{}.rpw", sess.name));
let samples_dir = dir.join(&sess.name);
if !samples_dir.exists() {
fs::create_dir_all(&samples_dir)
.map_err(|e| format!("создать папку сэмплов: {}", e))?;
}
// dump keepsake WAVs so the user (or a future retrain) can reuse them
for (i, pcm) in sess.samples_pcm.iter().enumerate() {
let path = samples_dir.join(format!("sample_{:02}.wav", i + 1));
if let Ok(bytes) = encode_wav(pcm) {
let _ = fs::write(path, bytes);
}
}
// build the in-memory sample map rustpotter wants (file_name -> wav bytes)
let mut samples_map: HashMap<String, Vec<u8>> = HashMap::with_capacity(sess.samples_wav.len());
for (i, wav) in sess.samples_wav.into_iter().enumerate() {
samples_map.insert(format!("sample_{:02}.wav", i + 1), wav);
}
let threshold = threshold.clamp(0.3, 0.9);
let model = WakewordRef::new_from_sample_buffers(
sess.name.clone(),
Some(threshold),
None,
samples_map,
MFCC_SIZE,
)
.map_err(|e| format!("rustpotter обучение: {}", e))?;
let path_str = model_path
.to_str()
.ok_or_else(|| "Путь содержит не-UTF8".to_string())?;
model
.save_to_file(path_str)
.map_err(|e| format!("сохранение модели: {}", e))?;
info!(
"Wake-word model trained and saved: {} (threshold {:.2})",
model_path.display(),
threshold
);
Ok(model_path)
}
/// Abort the current session, releasing the mic. Always succeeds.
pub fn cancel() {
let mut slot = state().session.lock();
if let Some(sess) = slot.take() {
if let Some(rec) = sess.recorder {
let _ = rec.stop();
}
}
}
/// List all `.rpw` models the user has trained so far.
pub fn list_models() -> Vec<TrainedModelInfo> {
let dir = match models_dir() {
Ok(d) => d,
Err(_) => return Vec::new(),
};
let read = match fs::read_dir(&dir) {
Ok(r) => r,
Err(_) => return Vec::new(),
};
let mut out = Vec::new();
for entry in read.flatten() {
let path = entry.path();
if !path.is_file() {
continue;
}
if path.extension().and_then(|s| s.to_str()) != Some("rpw") {
continue;
}
let name = path
.file_stem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default();
let meta = match path.metadata() {
Ok(m) => m,
Err(_) => continue,
};
let modified = meta
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
out.push(TrainedModelInfo {
name,
path: path.display().to_string(),
size_bytes: meta.len(),
modified,
});
}
out.sort_by(|a, b| a.name.cmp(&b.name));
out
}
/// Delete a previously trained model + its keepsake samples.
pub fn delete_model(name: &str) -> Result<(), String> {
let clean = sanitize(name);
if clean.is_empty() {
return Err("имя пустое".to_string());
}
let dir = models_dir()?;
let rpw = dir.join(format!("{}.rpw", clean));
let samples = dir.join(&clean);
if rpw.is_file() {
fs::remove_file(&rpw).map_err(|e| format!("rm rpw: {}", e))?;
}
if samples.is_dir() {
fs::remove_dir_all(&samples).map_err(|e| format!("rm samples: {}", e))?;
}
Ok(())
}
fn sanitize(raw: &str) -> String {
// strip path separators + control chars; keep ascii alnum, underscore, dash, dot
raw.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.' {
c
} else if c.is_alphabetic() {
c
} else {
'_'
}
})
.collect::<String>()
.trim_matches('_')
.trim_matches('.')
.to_string()
}
fn encode_wav(pcm: &[i16]) -> Result<Vec<u8>, String> {
use std::io::{Cursor, Write};
let mut buf: Vec<u8> = Vec::with_capacity(44 + pcm.len() * 2);
let mut cursor = Cursor::new(&mut buf);
let byte_rate: u32 = SAMPLE_RATE * 2; // mono * 16-bit
let data_size: u32 = (pcm.len() * 2) as u32;
let chunk_size: u32 = 36 + data_size;
cursor.write_all(b"RIFF").map_err(|e| e.to_string())?;
cursor.write_all(&chunk_size.to_le_bytes()).map_err(|e| e.to_string())?;
cursor.write_all(b"WAVE").map_err(|e| e.to_string())?;
cursor.write_all(b"fmt ").map_err(|e| e.to_string())?;
cursor.write_all(&16u32.to_le_bytes()).map_err(|e| e.to_string())?; // PCM subchunk size
cursor.write_all(&1u16.to_le_bytes()).map_err(|e| e.to_string())?; // PCM format
cursor.write_all(&1u16.to_le_bytes()).map_err(|e| e.to_string())?; // channels
cursor.write_all(&SAMPLE_RATE.to_le_bytes()).map_err(|e| e.to_string())?;
cursor.write_all(&byte_rate.to_le_bytes()).map_err(|e| e.to_string())?;
cursor.write_all(&2u16.to_le_bytes()).map_err(|e| e.to_string())?; // block align
cursor.write_all(&16u16.to_le_bytes()).map_err(|e| e.to_string())?; // bits per sample
cursor.write_all(b"data").map_err(|e| e.to_string())?;
cursor.write_all(&data_size.to_le_bytes()).map_err(|e| e.to_string())?;
for s in pcm {
cursor.write_all(&s.to_le_bytes()).map_err(|e| e.to_string())?;
}
Ok(buf)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sanitize_strips_path_chars() {
// leading dots/underscores are also trimmed for safety
assert_eq!(sanitize("../etc/passwd"), "_etc_passwd");
assert_eq!(sanitize("my-wake"), "my-wake");
assert_eq!(sanitize("hello world"), "hello_world");
assert_eq!(sanitize("Привет.Мир"), "Привет.Мир");
assert_eq!(sanitize("___"), "");
// ensure result never starts with a dot — guards against hidden files
// and against traversal: even "..\..\foo" can't escape its directory
assert!(!sanitize("..\\..\\foo").starts_with('.'));
}
#[test]
fn wav_encoding_round_trip_size_check() {
let pcm: Vec<i16> = (0..1600).map(|i| (i % 100) as i16).collect();
let wav = encode_wav(&pcm).expect("encode wav");
// 44-byte RIFF header + 2 bytes per sample
assert_eq!(wav.len(), 44 + pcm.len() * 2);
assert_eq!(&wav[0..4], b"RIFF");
assert_eq!(&wav[8..12], b"WAVE");
assert_eq!(&wav[12..16], b"fmt ");
assert_eq!(&wav[36..40], b"data");
// sample rate at offset 24, little-endian
let sr = u32::from_le_bytes([wav[24], wav[25], wav[26], wav[27]]);
assert_eq!(sr, SAMPLE_RATE);
}
#[test]
fn idle_status_when_no_session() {
// ensure STATE init doesn't panic and reports recording=false
let s = status();
assert!(!s.recording);
assert_eq!(s.collected, 0);
}
#[test]
fn cancel_when_idle_is_a_noop() {
cancel();
// no panic, status remains idle
let s = status();
assert!(!s.recording);
}
#[test]
fn record_sample_without_session_errors() {
let r = record_sample(1.0);
assert!(r.is_err(), "must error when no session is active");
}
}

View file

@ -7,7 +7,7 @@ repository.workspace = true
edition.workspace = true
[dependencies]
jarvis-core = { path = "../jarvis-core", default-features = false, features = ["llm"] }
jarvis-core = { path = "../jarvis-core", default-features = false }
tauri = { version = "2", features = [] } # v1: "shell-open", "dialog-message", "path-all"
tauri-plugin-shell = "2"
tauri-plugin-dialog = "2"
@ -21,7 +21,6 @@ sysinfo.workspace = true
once_cell.workspace = true
log.workspace = true
simple-log = "2.4"
dotenvy.workspace = true
serde.workspace = true
serde_json.workspace = true
platform-dirs.workspace = true

View file

@ -1,40 +1,9 @@
fn main() {
let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap();
let manifest_path = std::path::Path::new(&manifest_dir);
let lib_path = std::path::Path::new(&manifest_dir)
.join("..\\..\\lib\\windows\\amd64");
let lib_path = manifest_path.join("..\\..\\lib\\windows\\amd64");
println!("cargo:rustc-link-search=native={}", lib_path.display());
// Vite outputs the Svelte bundle into ../../frontend/dist/client. If the
// user runs a bare `cargo build -p jarvis-gui` without going through
// `cargo tauri build`, the beforeBuildCommand in tauri.conf.json never
// fires and the new release exe ends up bundling whatever stale dist is
// sitting on disk — including UI states from days ago. Force a rebuild
// here when any Svelte / TS source under frontend/src changes.
let frontend = manifest_path.join("..\\..\\frontend");
let src = frontend.join("src");
if src.is_dir() {
println!("cargo:rerun-if-changed={}", src.display());
println!("cargo:rerun-if-changed={}", frontend.join("package.json").display());
let npm = if cfg!(target_os = "windows") { "npm.cmd" } else { "npm" };
let status = std::process::Command::new(npm)
.arg("run")
.arg("build")
.current_dir(&frontend)
.status();
match status {
Ok(s) if s.success() => {
println!("cargo:warning=jarvis-gui: frontend rebuilt via npm run build");
}
Ok(s) => {
println!("cargo:warning=jarvis-gui: npm run build exited with {}", s);
}
Err(e) => {
println!("cargo:warning=jarvis-gui: npm not found ({}). Bundling existing dist; run `npm install && npm run build` in frontend/ if the UI looks stale.", e);
}
}
}
tauri_build::build()
}

View file

@ -1,9 +1,6 @@
use tauri::Emitter;
// the payload type must implement `Serialize` and `Clone`.
// Kept around as the canonical shape for future event emitter wiring even
// though nothing currently emits one — silence the dead-code lint.
#[allow(dead_code)]
#[derive(Clone, serde::Serialize)]
pub struct Payload {
pub data: String,
@ -19,7 +16,6 @@ pub enum EventTypes {
CommandEnd,
}
#[allow(dead_code)]
impl EventTypes {
pub fn get(&self) -> &str {
match self {
@ -33,7 +29,6 @@ impl EventTypes {
}
}
#[allow(dead_code)]
pub fn play(phrase: &str, app_handle: &tauri::AppHandle) {
app_handle
.emit(

View file

@ -3,6 +3,7 @@
use jarvis_core::{config, db, i18n, voices, DB, SettingsManager};
#[macro_use]
extern crate simple_log;
mod events;
@ -15,19 +16,8 @@ pub struct AppState {
}
fn main() {
load_dotenv_near_exe();
config::init_dirs().expect("Failed to init dirs");
// Register our toast AUMID — see crates/jarvis-core/src/toast.rs.
jarvis_core::toast::register_aumid();
// Share the recognition log with the daemon — both processes read the
// same JSON file under APP_CONFIG_DIR. The GUI re-reads it on each
// /history poll so logs from the daemon show up there too. Failure is
// non-fatal: the page just stays empty.
let _ = jarvis_core::recognition_log::init();
// basic logging setup (simpler for GUI)
simple_log::quick!("info");
@ -70,9 +60,10 @@ fn main() {
tauri_commands::get_app_version,
tauri_commands::get_author_name,
tauri_commands::get_repository_link,
tauri_commands::get_upstream_link,
tauri_commands::get_issues_link,
tauri_commands::get_python_fork_link,
tauri_commands::get_tg_official_link,
tauri_commands::get_boosty_link,
tauri_commands::get_patreon_link,
tauri_commands::get_feedback_link,
// fs
tauri_commands::get_log_file_path,
@ -108,81 +99,7 @@ fn main() {
tauri_commands::list_voices,
tauri_commands::get_voice,
tauri_commands::preview_voice,
// LLM/TTS backends (active config + hot-swap)
tauri_commands::get_active_backends,
tauri_commands::set_llm_backend,
tauri_commands::set_tts_backend,
tauri_commands::llm_reset_context,
tauri_commands::llm_get_last_reply,
// Voice macros
tauri_commands::macros_list,
tauri_commands::macros_replay,
tauri_commands::macros_delete,
tauri_commands::macros_is_recording,
tauri_commands::macros_recording_name,
tauri_commands::macros_start_recording,
tauri_commands::macros_save_recording,
tauri_commands::macros_cancel_recording,
// Scheduler tasks
tauri_commands::scheduler_list,
tauri_commands::scheduler_remove,
tauri_commands::scheduler_clear,
// Long-term memory facts
tauri_commands::memory_list,
tauri_commands::memory_remember,
tauri_commands::memory_forget,
tauri_commands::memory_search,
// Profile switching
tauri_commands::profile_list,
tauri_commands::profile_active,
tauri_commands::profile_set,
// User plugins
tauri_commands::plugins_list,
tauri_commands::plugins_set_enabled,
tauri_commands::plugins_open_folder,
// Python command builder launcher
tauri_commands::open_command_builder,
// Recognition history
tauri_commands::history_recent,
tauri_commands::history_clear,
// Wake-word trainer wizard
tauri_commands::wake_trainer_status,
tauri_commands::wake_trainer_defaults,
tauri_commands::wake_trainer_start,
tauri_commands::wake_trainer_record_sample,
tauri_commands::wake_trainer_finish,
tauri_commands::wake_trainer_cancel,
tauri_commands::wake_trainer_list_models,
tauri_commands::wake_trainer_delete_model,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
// Pull dev.env into the process env so secrets like GROQ_TOKEN are picked up
// when the user launches the exe directly (no wrapper .bat required).
fn load_dotenv_near_exe() {
if let Ok(exe) = std::env::current_exe() {
let mut dir = exe.parent().map(|p| p.to_path_buf());
for _ in 0..5 {
if let Some(d) = &dir {
let candidate = d.join("dev.env");
if candidate.is_file() {
let _ = dotenvy::from_path(&candidate);
return;
}
dir = d.parent().map(|p| p.to_path_buf());
}
}
}
let _ = dotenvy::dotenv();
}

View file

@ -38,39 +38,3 @@ pub use commands::*;
// import voices commands
mod voices;
pub use voices::*;
// LLM/TTS backend introspection + hot-swap
mod backends;
pub use backends::*;
// Voice macros
mod macros;
pub use macros::*;
// Scheduler tasks
mod scheduler;
pub use scheduler::*;
// Long-term memory facts
mod memory;
pub use memory::*;
// Profile switching
mod profile;
pub use profile::*;
// User-installed plugins (APP_CONFIG_DIR/plugins/<name>/)
mod plugins;
pub use plugins::*;
// Python command builder launcher
mod builder;
pub use builder::*;
// Recognition history page
mod history;
pub use history::*;
// Wake-word training wizard
mod wake_trainer;
pub use wake_trainer::*;

View file

@ -1,124 +0,0 @@
//! Tauri commands for backend introspection + hot-swap. Used by the GUI
//! footer to display the active LLM/TTS and let the user change them.
//!
//! Note: `get_active_backends` doesn't initialise the LLM — if the user
//! never spoke to it, `llm_backend` may be "none" until first chat. That's
//! the trade-off for avoiding cold-start latency in the GUI.
use serde::Serialize;
use jarvis_core::llm;
use jarvis_core::tts;
#[derive(Serialize)]
pub struct ActiveBackends {
pub tts: String,
pub llm: String,
pub llm_model: Option<String>,
pub profile: String,
}
#[tauri::command]
pub fn get_active_backends() -> ActiveBackends {
let llm_model = llm::current().map(|c| c.model().to_string());
ActiveBackends {
tts: tts::backend().name().to_string(),
llm: llm::current_backend_name().to_string(),
llm_model,
profile: jarvis_core::profiles::active_name(),
}
}
/// Hot-swap the LLM backend. Accepts "groq"/"ollama"/"cloud"/"local"/...
/// Returns the new backend name on success, Err with reason on failure.
#[tauri::command]
pub fn set_llm_backend(name: String) -> Result<String, String> {
let backend = llm::parse_backend(&name)
.ok_or_else(|| format!("unknown backend: '{}'", name))?;
// Ensure global is initialised first (so swap can read DB-persisted choice).
if llm::current().is_none() {
let _ = llm::init_global();
}
llm::swap_to(backend)
.map(|n| n.to_string())
.map_err(|e| e.to_string())
}
/// Set TTS backend. Persists to settings DB AND hot-swaps the live process
/// (in the GUI process; the running daemon picks it up via IPC SwitchTts).
///
/// Returns a struct describing what ACTUALLY happened — `requested` is what
/// the user asked for, `applied` is what the engine actually installed. They
/// differ when, e.g., Piper was requested but `piper.exe` isn't on disk; the
/// engine falls back to SAPI to keep speech working, and the GUI shows a
/// warning instead of pretending the swap succeeded.
#[derive(Serialize)]
pub struct TtsSwapResult {
pub requested: String,
pub applied: String,
pub fell_back: bool,
pub note: Option<String>,
}
#[tauri::command]
pub fn set_tts_backend(name: String) -> Result<TtsSwapResult, String> {
let normalized = match name.trim().to_lowercase().as_str() {
"" | "auto" => "".to_string(),
"sapi" | "piper" | "silero" => name.to_lowercase(),
other => return Err(format!("unknown TTS backend: '{}'", other)),
};
// 1. Persist to DB so a restart picks it up.
if let Some(db) = jarvis_core::DB.get() {
let snapshot = {
let mut s = db.write();
s.tts_backend = normalized.clone();
s.clone()
};
jarvis_core::db::save_settings(&snapshot)
.map_err(|e| format!("failed to persist: {}", e))?;
} else {
return Err("settings DB not initialised".into());
}
// 2. Apply RIGHT NOW in this GUI process AND report what actually got
// installed. The fallback path in build_backend() may silently swap
// Piper→SAPI when the Piper binary is missing; without surfacing that
// the user sees "saved" but the active engine label stays SAPI and
// they don't know why.
let requested = if normalized.is_empty() { "auto".to_string() } else { normalized.clone() };
let applied = match jarvis_core::tts::swap_to(&requested) {
Ok(name) => name.to_string(),
Err(e) => {
log::warn!("TTS hot-swap failed: {}", e);
jarvis_core::tts::backend().name().to_string()
}
};
let fell_back = applied.eq_ignore_ascii_case("sapi")
&& !requested.eq_ignore_ascii_case("sapi")
&& requested != "auto";
let note = if fell_back {
Some(match requested.as_str() {
"piper" => "Piper не установлен. Запусти tools/piper/install.ps1 чтобы скачать бинарь и голос.".into(),
"silero" => "Silero недоступен. Нужен Python + torch + silero_tts.py helper.".into(),
other => format!("'{}' недоступен — откатился на SAPI.", other),
})
} else {
None
};
Ok(TtsSwapResult { requested, applied, fell_back, note })
}
/// Reset conversation context (clears LLM history turns, keeps system prompt).
#[tauri::command]
pub fn llm_reset_context() {
llm::history_clear();
}
/// Return the most recent assistant message text (for "Repeat" button in GUI).
#[tauri::command]
pub fn llm_get_last_reply() -> Option<String> {
llm::history_last_assistant()
}

View file

@ -1,68 +0,0 @@
//! Tauri command for launching the Python `command_builder` GUI.
//!
//! The Python fork ships a separate yaml-editing tool at
//! `python/tools/command_builder/` (a pywebview-based GUI). Without a launcher
//! button in the main Tauri GUI the user has no easy way to find it.
//!
//! Resolution order for the python checkout:
//! 1. `JARVIS_PYTHON_DIR` env override
//! 2. Sibling `python/` directory next to this jarvis-rust checkout
//! 3. `C:\Jarvis\python` (the dev box default)
//!
//! We don't ship the Python fork inside the Rust binary — this launcher just
//! tries to spawn it. If Python isn't installed, we open the docs URL instead.
use std::path::{Path, PathBuf};
use std::process::Command;
fn locate_python_dir() -> Option<PathBuf> {
if let Ok(p) = std::env::var("JARVIS_PYTHON_DIR") {
let pp = PathBuf::from(p);
if pp.is_dir() {
return Some(pp);
}
}
if let Ok(exe) = std::env::current_exe() {
let mut dir = exe.parent().map(|p| p.to_path_buf());
for _ in 0..6 {
if let Some(d) = &dir {
let candidate = d.join("python");
if candidate.join("tools").join("command_builder").is_dir() {
return Some(candidate);
}
dir = d.parent().map(|p| p.to_path_buf());
}
}
}
let fallback = PathBuf::from(r"C:\Jarvis\python");
if fallback.join("tools").join("command_builder").is_dir() {
return Some(fallback);
}
None
}
/// Find a python interpreter to run the builder with. Prefers the project's
/// `.venv\Scripts\python.exe` (where all deps are installed), then `python`.
fn python_bin(py_dir: &Path) -> PathBuf {
let venv = py_dir.join(".venv").join("Scripts").join("python.exe");
if venv.is_file() {
venv
} else {
PathBuf::from("python")
}
}
#[tauri::command]
pub fn open_command_builder() -> Result<String, String> {
let py_dir = locate_python_dir()
.ok_or_else(|| "Python fork not found. Set JARVIS_PYTHON_DIR or install at C:\\Jarvis\\python.".to_string())?;
let py = python_bin(&py_dir);
Command::new(&py)
.args(["-m", "tools.command_builder"])
.current_dir(&py_dir)
.spawn()
.map_err(|e| format!("Failed to launch command builder: {} (using {})", e, py.display()))?;
Ok(format!("Launched from {}", py_dir.display()))
}

View file

@ -1,33 +1,68 @@
use jarvis_core::{config, APP_LOG_DIR};
// Learn more about Tauri commands at https://tauri.app/v1/guides/features/command
#[tauri::command]
pub fn get_app_version() -> String {
config::APP_VERSION.unwrap_or("error").to_string()
if let Some(res) = config::APP_VERSION {
res.to_string()
} else {
String::from("error")
}
}
#[tauri::command]
pub fn get_author_name() -> String {
config::AUTHOR_NAME.unwrap_or("error").to_string()
if let Some(res) = config::AUTHOR_NAME {
res.to_string()
} else {
String::from("error")
}
}
#[tauri::command]
pub fn get_repository_link() -> String {
config::REPOSITORY_LINK.unwrap_or("error").to_string()
if let Some(res) = config::REPOSITORY_LINK {
res.to_string()
} else {
String::from("error")
}
}
#[tauri::command]
pub fn get_upstream_link() -> String {
config::UPSTREAM_REPOSITORY_LINK.to_string()
pub fn get_tg_official_link() -> String {
if let Some(ver) = config::TG_OFFICIAL_LINK {
ver.to_string()
} else {
String::from("error")
}
}
#[tauri::command]
pub fn get_issues_link() -> String {
config::ISSUES_LINK.to_string()
pub fn get_boosty_link() -> String {
if let Some(ver) = config::SUPPORT_BOOSTY_LINK {
ver.to_string()
} else {
String::from("error")
}
}
#[tauri::command]
pub fn get_python_fork_link() -> String {
config::PYTHON_FORK_LINK.to_string()
pub fn get_patreon_link() -> String {
if let Some(ver) = config::SUPPORT_PATREON_LINK {
ver.to_string()
} else {
String::from("error")
}
}
#[tauri::command]
pub fn get_feedback_link() -> String {
if let Some(res) = config::FEEDBACK_LINK {
res.to_string()
} else {
String::from("error")
}
}
#[tauri::command]

View file

@ -1,91 +0,0 @@
//! Tauri commands for the /history page — recognition log with
//! green/red status badges. The data lives in
//! `<APP_CONFIG_DIR>/recognition_log.json`, which the daemon writes to
//! whenever it dispatches a phrase. The GUI reads from disk every poll
//! so it sees daemon writes even though they're separate processes.
use serde::Serialize;
/// Flat view of `recognition_log::RecognitionEntry` optimised for the
/// Svelte renderer — outcomes flattened to a single `kind` discriminator
/// and explicit `command_id` / `confidence_pct` so the template doesn't
/// have to walk a tagged union.
#[derive(Serialize)]
pub struct HistoryEntry {
pub ts: i64,
pub phrase: String,
pub source: String,
/// "matched" | "not_found" | "llm_handled" | "error"
pub kind: &'static str,
pub command_id: Option<String>,
pub confidence_pct: Option<u8>,
pub via: Option<String>,
pub success: Option<bool>,
pub error_message: Option<String>,
}
fn flatten(e: jarvis_core::recognition_log::RecognitionEntry) -> HistoryEntry {
use jarvis_core::recognition_log::Outcome;
match e.outcome {
Outcome::Matched { command_id, confidence_pct, via, success } => HistoryEntry {
ts: e.ts,
phrase: e.phrase,
source: e.source,
kind: "matched",
command_id: Some(command_id),
confidence_pct,
via: Some(via),
success: Some(success),
error_message: None,
},
Outcome::NotFound => HistoryEntry {
ts: e.ts,
phrase: e.phrase,
source: e.source,
kind: "not_found",
command_id: None,
confidence_pct: None,
via: None,
success: Some(false),
error_message: None,
},
Outcome::LlmHandled => HistoryEntry {
ts: e.ts,
phrase: e.phrase,
source: e.source,
kind: "llm_handled",
command_id: None,
confidence_pct: None,
via: Some("llm".into()),
success: Some(true),
error_message: None,
},
Outcome::Error { message } => HistoryEntry {
ts: e.ts,
phrase: e.phrase,
source: e.source,
kind: "error",
command_id: None,
confidence_pct: None,
via: None,
success: Some(false),
error_message: Some(message),
},
}
}
/// Return up to `limit` newest recognition entries. Re-reads from disk
/// so the GUI always sees the daemon's latest writes. `limit = 0` → all.
#[tauri::command]
pub fn history_recent(limit: usize) -> Vec<HistoryEntry> {
jarvis_core::recognition_log::recent_from_disk(limit)
.into_iter()
.map(flatten)
.collect()
}
/// Wipe the recognition log. Returns the count of removed entries.
#[tauri::command]
pub fn history_clear() -> usize {
jarvis_core::recognition_log::clear()
}

View file

@ -1,58 +0,0 @@
//! Tauri commands for the voice-macros system.
use serde::Serialize;
#[derive(Serialize)]
pub struct MacroInfo {
pub name: String,
pub steps_count: usize,
pub steps: Vec<String>,
pub created_at: i64,
pub last_run: Option<i64>,
}
#[tauri::command]
pub fn macros_list() -> Vec<MacroInfo> {
jarvis_core::macros::list().into_iter().map(|m| MacroInfo {
name: m.name,
steps_count: m.steps.len(),
steps: m.steps,
created_at: m.created_at,
last_run: m.last_run,
}).collect()
}
#[tauri::command]
pub fn macros_replay(name: String) -> Result<usize, String> {
jarvis_core::macros::replay(&name)
}
#[tauri::command]
pub fn macros_delete(name: String) -> bool {
jarvis_core::macros::delete(&name)
}
#[tauri::command]
pub fn macros_is_recording() -> bool {
jarvis_core::macros::is_recording()
}
#[tauri::command]
pub fn macros_recording_name() -> Option<String> {
jarvis_core::macros::recording_name()
}
#[tauri::command]
pub fn macros_start_recording(name: String) -> Result<(), String> {
jarvis_core::macros::start_recording(&name)
}
#[tauri::command]
pub fn macros_save_recording() -> Result<usize, String> {
jarvis_core::macros::save_recording()
}
#[tauri::command]
pub fn macros_cancel_recording() -> bool {
jarvis_core::macros::cancel_recording()
}

View file

@ -1,45 +0,0 @@
//! Tauri commands for the long-term memory store.
use serde::Serialize;
#[derive(Serialize)]
pub struct MemoryFact {
pub key: String,
pub value: String,
pub created_at: i64,
pub last_used_at: i64,
pub use_count: u64,
}
#[tauri::command]
pub fn memory_list() -> Vec<MemoryFact> {
jarvis_core::long_term_memory::all().into_iter().map(|r| MemoryFact {
key: r.key,
value: r.value,
created_at: r.created_at,
last_used_at: r.last_used_at,
use_count: r.use_count,
}).collect()
}
#[tauri::command]
pub fn memory_remember(key: String, value: String) -> Result<(), String> {
jarvis_core::long_term_memory::remember(&key, &value)
}
#[tauri::command]
pub fn memory_forget(key: String) -> bool {
jarvis_core::long_term_memory::forget(&key)
}
#[tauri::command]
pub fn memory_search(query: String, limit: Option<usize>) -> Vec<MemoryFact> {
jarvis_core::long_term_memory::search(&query, limit.unwrap_or(10))
.into_iter().map(|r| MemoryFact {
key: r.key,
value: r.value,
created_at: r.created_at,
last_used_at: r.last_used_at,
use_count: r.use_count,
}).collect()
}

View file

@ -1,47 +0,0 @@
//! Tauri commands for the plugin system. GUI uses these to render
//! `/plugins` page: list installed plugins, toggle enabled state, reveal the
//! plugins folder in Explorer, and open the docs page.
use serde::Serialize;
use std::path::PathBuf;
#[derive(Serialize)]
pub struct PluginEntry {
pub name: String,
pub path: String,
pub enabled: bool,
pub command_count: usize,
pub error: Option<String>,
}
#[tauri::command]
pub fn plugins_list() -> Vec<PluginEntry> {
jarvis_core::plugins::list()
.into_iter()
.map(|p| PluginEntry {
name: p.name,
path: p.path.display().to_string(),
enabled: p.enabled,
command_count: p.command_count,
error: p.error,
})
.collect()
}
#[tauri::command]
pub fn plugins_set_enabled(name: String, enabled: bool) -> Result<(), String> {
jarvis_core::plugins::set_enabled(&name, enabled)
}
#[tauri::command]
pub fn plugins_open_folder() -> Result<String, String> {
let dir: PathBuf = jarvis_core::plugins::plugins_dir()?;
// Open the folder in Explorer (Windows-only — fall back to printing path).
#[cfg(target_os = "windows")]
{
let _ = std::process::Command::new("explorer.exe")
.arg(&dir)
.spawn();
}
Ok(dir.display().to_string())
}

View file

@ -1,38 +0,0 @@
//! Tauri commands for profile switching.
use serde::Serialize;
#[derive(Serialize)]
pub struct ProfileInfo {
pub name: String,
pub description: String,
pub icon: String,
pub greeting: String,
}
#[tauri::command]
pub fn profile_list() -> Vec<String> {
jarvis_core::profiles::list()
}
#[tauri::command]
pub fn profile_active() -> ProfileInfo {
let p = jarvis_core::profiles::active();
ProfileInfo {
name: p.name,
description: p.description,
icon: p.icon,
greeting: p.greeting,
}
}
#[tauri::command]
pub fn profile_set(name: String) -> Result<ProfileInfo, String> {
let p = jarvis_core::profiles::set_active(&name)?;
Ok(ProfileInfo {
name: p.name,
description: p.description,
icon: p.icon,
greeting: p.greeting,
})
}

View file

@ -1,59 +0,0 @@
//! Tauri commands for the proactive scheduler.
use serde::Serialize;
#[derive(Serialize)]
pub struct ScheduledTaskInfo {
pub id: String,
pub name: String,
pub schedule_human: String,
pub action_kind: String,
pub action_text: Option<String>,
pub last_fired: Option<i64>,
pub enabled: bool,
pub created_at: i64,
}
fn schedule_human(s: &jarvis_core::scheduler::Schedule) -> String {
use jarvis_core::scheduler::Schedule;
match s {
Schedule::Daily { hour, minute } => format!("каждый день в {:02}:{:02}", hour, minute),
Schedule::Interval { seconds } => {
if *seconds % 3600 == 0 { format!("каждые {} ч", seconds / 3600) }
else if *seconds % 60 == 0 { format!("каждые {} мин", seconds / 60) }
else { format!("каждые {} сек", seconds) }
}
Schedule::Once { at } => format!("один раз @ {}", at),
}
}
#[tauri::command]
pub fn scheduler_list() -> Vec<ScheduledTaskInfo> {
use jarvis_core::scheduler::Action;
jarvis_core::scheduler::list().into_iter().map(|t| {
let (action_kind, action_text) = match &t.action {
Action::Speak { text } => ("speak".to_string(), Some(text.clone())),
Action::Lua { script_path } => ("lua".to_string(), Some(script_path.clone())),
};
ScheduledTaskInfo {
id: t.id,
name: t.name,
schedule_human: schedule_human(&t.schedule),
action_kind,
action_text,
last_fired: t.last_fired,
enabled: t.enabled,
created_at: t.created_at,
}
}).collect()
}
#[tauri::command]
pub fn scheduler_remove(id: String) -> bool {
jarvis_core::scheduler::remove(&id)
}
#[tauri::command]
pub fn scheduler_clear() -> usize {
jarvis_core::scheduler::clear()
}

View file

@ -2,6 +2,8 @@ use sysinfo::{System, Pid, ProcessRefreshKind, RefreshKind, CpuRefreshKind, Comp
use peak_alloc::PeakAlloc;
use std::sync::Mutex;
use once_cell::sync::Lazy;
use std::process::Command;
use std::env;
#[global_allocator]
static PEAK_ALLOC: PeakAlloc = PeakAlloc;

View file

@ -1,50 +0,0 @@
//! Tauri commands for the wake-word trainer wizard. The GUI drives the
//! whole flow over these — see `frontend/src/routes/wake-trainer/index.svelte`.
//!
//! Key safety note: pv_recorder claims the mic exclusively, so the wizard
//! refuses to enter recording mode while jarvis-app.exe is running. The frontend
//! also checks this beforehand and prompts the user to stop the daemon.
use jarvis_core::wake_trainer::{
self, TrainedModelInfo, TrainerStatus, DEFAULT_SAMPLE_SECONDS, DEFAULT_TARGET_SAMPLES,
};
#[tauri::command]
pub fn wake_trainer_status() -> TrainerStatus {
wake_trainer::status()
}
#[tauri::command]
pub fn wake_trainer_defaults() -> (f32, u8) {
(DEFAULT_SAMPLE_SECONDS, DEFAULT_TARGET_SAMPLES)
}
#[tauri::command]
pub fn wake_trainer_start(name: String, target_samples: u8) -> Result<(), String> {
wake_trainer::start(&name, target_samples)
}
#[tauri::command]
pub fn wake_trainer_record_sample(seconds: f32) -> Result<usize, String> {
wake_trainer::record_sample(seconds)
}
#[tauri::command]
pub fn wake_trainer_finish(threshold: f32) -> Result<String, String> {
wake_trainer::finish(threshold).map(|p| p.display().to_string())
}
#[tauri::command]
pub fn wake_trainer_cancel() {
wake_trainer::cancel()
}
#[tauri::command]
pub fn wake_trainer_list_models() -> Vec<TrainedModelInfo> {
wake_trainer::list_models()
}
#[tauri::command]
pub fn wake_trainer_delete_model(name: String) -> Result<(), String> {
wake_trainer::delete_model(&name)
}

View file

@ -5,7 +5,7 @@
"type": "module",
"scripts": {
"dev": "vite",
"build": "routify build && (svelte-check --ignore '.routify' --no-tsconfig || echo svelte-check reported issues but build continues) && vite build",
"build": "routify build && svelte-check --ignore '.routify' --no-tsconfig && vite build",
"preview": "vite preview",
"check": "svelte-check --ignore '.routify' --tsconfig ./tsconfig.json",
"tauri": "tauri"

View file

@ -1,132 +1,62 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte"
import { onMount } from "svelte"
import { invoke } from "@tauri-apps/api/core"
import {
appInfo, translations, translate,
daemonHealth, ipcConnected, queryDaemonHealth,
} from "@/stores"
import { appInfo, currentLanguage, translations, translate } from "@/stores"
$: t = (key: string) => translate($translations, key)
let authorName = ""
let tgLink = ""
let repoLink = ""
let upstreamLink = ""
let issuesLink = ""
let boostyLink = ""
let patreonLink = ""
const currentYear = new Date().getFullYear()
appInfo.subscribe(info => {
tgLink = info.tgOfficialLink
repoLink = info.repositoryLink
upstreamLink = info.upstreamLink
issuesLink = info.issuesLink
boostyLink = info.boostySupportLink
patreonLink = info.patreonSupportLink
})
// 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
let connected = false
ipcConnected.subscribe(v => { connected = v })
daemonHealth.subscribe(h => {
if (h) {
backends = {
tts: h.tts_backend,
llm: h.llm_backend,
llm_model: h.llm_model,
profile: h.active_profile,
}
}
})
async function refreshBackends() {
// Prefer IPC — daemon's actual state.
if (connected) {
queryDaemonHealth() // response arrives via daemonHealth store
return
}
// Fallback to GUI-process view if daemon offline.
onMount(async () => {
try {
const fallback = await invoke<ActiveBackends>("get_active_backends")
if (!backends) backends = fallback
authorName = await invoke<string>("get_author_name")
} catch (err) {
// ignore
console.error("failed to get author name:", err)
}
}
onMount(() => {
refreshBackends()
pollId = setInterval(refreshBackends, 5000)
})
onDestroy(() => {
if (pollId !== null) clearInterval(pollId)
})
function chipTitle(key: "tts" | "llm"): string {
if (!backends) return ""
const src = connected ? "daemon" : "gui-process"
if (key === "tts") return `TTS backend: ${backends.tts} (${src})`
const model = backends.llm_model ? ` (${backends.llm_model})` : ""
return `LLM backend: ${backends.llm}${model} (${src})`
}
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>
</p>
<p>© {currentYear}. {t('footer-author')}: <b>{authorName}</b></p>
<p class="links">
<a href={repoLink} target="_blank" rel="noopener noreferrer">
{#if $currentLanguage === "ru" || $currentLanguage === "ua"}
<a href={tgLink} target="_blank" class="telegram-link">
<img src="/media/icons/telegram.webp" alt="Telegram" width="18px" />
&nbsp;<span>{t('footer-telegram')}</span>
</a>
&nbsp;
{/if}
<a href={repoLink} target="_blank">
<img src="/media/icons/github-logo.png" alt="GitHub" width="18px" />
&nbsp;<span>{t('footer-github')}</span>
</a>
&nbsp;·&nbsp;
<a href={issuesLink} target="_blank" rel="noopener noreferrer">
<span>{t('footer-issues')}</span>
</a>
</p>
<p class="links last">
<small>
{t('footer-fork-of')}
<a href={upstreamLink} target="_blank" rel="noopener noreferrer">Priler/jarvis</a>
· CC BY-NC-SA 4.0
</small>
{#if $currentLanguage === "ru"}
{t('footer-support')} <a href={boostyLink} target="_blank" class="telegram-link">
<img src="/media/icons/boosty.webp" alt="Boosty" width="18px" />
<span>Boosty</span>
</a>.
{/if}
{#if $currentLanguage === "ua" || $currentLanguage === "en"}
{t('footer-support')} <a href={patreonLink} target="_blank" class="telegram-link">
<img src="/media/icons/patreon.png" alt="Patreon" width="18px" />
<span>Patreon</span>
</a>.
{/if}
</p>
</footer>
@ -139,48 +69,6 @@
line-height: 1.7em;
margin-top: 15px;
.edition {
display: inline-block;
margin-left: 6px;
padding: 1px 7px;
border-radius: 4px;
background: #b7411a;
color: #fff;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.5px;
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;
@ -191,14 +79,12 @@
&.last {
margin-top: -5px;
color: #8a8d90;
font-size: 11px;
}
}
}
a {
color: #555759 !important;
color: #555759!important;
text-decoration: none;
transition: 0.3s;
@ -215,7 +101,7 @@
}
&:hover {
color: #777a7d !important;
color: #777a7d!important;
& > span {
color: #2A9CD0;
@ -225,6 +111,30 @@
opacity: 1;
}
}
&.telegram-link {
color: #185876;
display: inline-block;
&:hover {
color: #2A9CD0;
// background: url(/media/images/bg/bg24.gif);
// background-repeat: no-repeat;
// background-size: contain;
}
}
&.special-link {
color: #941d92;
display: inline-block;
&:hover {
color: #FF07FC;
background: url(/media/images/bg/bg24.gif);
background-repeat: no-repeat;
background-size: contain;
}
}
}
}
</style>

View file

@ -1,7 +1,7 @@
<script lang="ts">
import { goto } from "@roxi/routify"
import { invoke } from "@tauri-apps/api/core"
import { onMount, onDestroy } from "svelte"
import { onMount } from "svelte"
import { currentLanguage, setLanguage, translations, translate } from "@/stores"
let appVersion = ""
@ -13,54 +13,15 @@
const languages = [
{ code: "ru", label: "RU", flag: "🇷🇺", name: "Русский" },
{ code: "en", label: "EN", flag: "🇬🇧", name: "English" },
{ code: "ua", label: "UA", flag: "🇺🇦", name: "Українська" },
]
// Profile chip — shows the active profile icon + name, click to swap.
interface ProfileInfo {
name: string
description: string
icon: string
greeting: string
}
let activeProfile: ProfileInfo | null = null
let profileNames: string[] = []
let profileDropdownOpen = false
let profilePoll: ReturnType<typeof setInterval> | null = null
async function refreshProfile() {
try {
activeProfile = await invoke<ProfileInfo>("profile_active")
} catch { /* ignore */ }
}
async function loadProfileNames() {
try {
profileNames = await invoke<string[]>("profile_list")
} catch { /* ignore */ }
}
async function selectProfile(name: string) {
try {
activeProfile = await invoke<ProfileInfo>("profile_set", { name })
} catch (err) {
console.error("profile set failed:", err)
}
profileDropdownOpen = false
}
function toggleProfileDropdown(e: MouseEvent) {
e.stopPropagation()
if (!profileDropdownOpen) {
loadProfileNames()
}
profileDropdownOpen = !profileDropdownOpen
}
onMount(async () => {
try {
appVersion = await invoke<string>("get_app_version")
commandsCount = await invoke<number>("get_commands_count")
// load saved language
const savedLang = await invoke<string>("db_read", { key: "language" })
if (savedLang) {
selectedLang = savedLang
@ -68,13 +29,6 @@
} catch {
commandsCount = 0
}
refreshProfile()
loadProfileNames()
profilePoll = setInterval(refreshProfile, 5000)
})
onDestroy(() => {
if (profilePoll !== null) clearInterval(profilePoll)
})
async function selectLanguage(code: string) {
@ -86,21 +40,18 @@
langDropdownOpen = !langDropdownOpen
}
function closeDropdowns(e: MouseEvent) {
function closeLangDropdown(e: MouseEvent) {
const target = e.target as HTMLElement
if (!target.closest('.lang-selector')) {
langDropdownOpen = false
}
if (!target.closest('.profile-selector')) {
profileDropdownOpen = false
}
}
$: currentLang = languages.find(l => l.code === $currentLanguage) || languages[0]
$: t = (key: string) => translate($translations, key)
</script>
<svelte:window on:click={closeDropdowns} />
<svelte:window on:click={closeLangDropdown} />
<header id="header" class="header">
<div class="header-left">
@ -116,80 +67,11 @@
</div>
<div class="header-right">
{#if activeProfile && activeProfile.name !== 'default'}
<div class="profile-selector">
<button
class="profile-chip"
title="{activeProfile.name}{activeProfile.description}. Кликни чтобы сменить."
on:click|stopPropagation={toggleProfileDropdown}
>
<span class="profile-icon">{activeProfile.icon || '★'}</span>
<span class="profile-name">{activeProfile.name}</span>
</button>
{#if profileDropdownOpen}
<div class="profile-dropdown">
{#each profileNames as pname}
<button
class="profile-option"
class:active={pname === activeProfile.name}
on:click|stopPropagation={() => selectProfile(pname)}
>
{pname}
</button>
{/each}
</div>
{/if}
</div>
{:else if activeProfile}
<div class="profile-selector">
<button
class="profile-chip default"
title="Текущий профиль — обычный. Кликни чтобы сменить."
on:click|stopPropagation={toggleProfileDropdown}
>
<span class="profile-icon"></span>
</button>
{#if profileDropdownOpen}
<div class="profile-dropdown">
{#each profileNames as pname}
<button
class="profile-option"
class:active={pname === activeProfile.name}
on:click|stopPropagation={() => selectProfile(pname)}
>
{pname}
</button>
{/each}
</div>
{/if}
</div>
{/if}
<button class="header-btn" on:click={() => $goto('/commands')}>
<span class="btn-text">{t('header-commands')}</span>
<span class="btn-badge purple">{commandsCount}+</span>
</button>
<button class="header-btn" on:click={() => $goto('/macros')} title="Voice macros">
<span class="btn-text">{t('header-macros') || 'Макросы'}</span>
</button>
<button class="header-btn" on:click={() => $goto('/scheduler')} title="Scheduled tasks">
<span class="btn-text">{t('header-scheduler') || 'Расписание'}</span>
</button>
<button class="header-btn" on:click={() => $goto('/memory')} title="Long-term memory">
<span class="btn-text">{t('header-memory') || 'Память'}</span>
</button>
<button class="header-btn" on:click={() => $goto('/plugins')} title="User plugins">
<span class="btn-text">{t('header-plugins') || 'Плагины'}</span>
</button>
<button class="header-btn" on:click={() => $goto('/history')} title="Recognition history">
<span class="btn-text">{t('header-history') || 'История'}</span>
</button>
<button class="header-btn" on:click={() => $goto('/settings')}>
<span class="btn-text">{t('header-settings')}</span>
</button>
@ -218,79 +100,6 @@
</header>
<style lang="scss">
.profile-selector {
position: relative;
margin-right: 6px;
}
.profile-chip {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 4px 9px;
background: rgba(255, 168, 60, 0.12);
border: 1px solid rgba(255, 168, 60, 0.35);
border-radius: 14px;
color: #ffa83c;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
&.default {
background: rgba(255, 255, 255, 0.04);
border-color: rgba(255, 255, 255, 0.12);
color: rgba(255, 255, 255, 0.55);
}
&:hover {
background: rgba(255, 168, 60, 0.22);
border-color: rgba(255, 168, 60, 0.55);
}
}
.profile-icon {
font-size: 14px;
line-height: 1;
}
.profile-dropdown {
position: absolute;
top: calc(100% + 6px);
right: 0;
background: rgba(20, 30, 35, 0.98);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
overflow: hidden;
z-index: 100;
min-width: 130px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
}
.profile-option {
display: block;
width: 100%;
padding: 0.55rem 0.85rem;
background: transparent;
border: none;
color: rgba(255, 255, 255, 0.75);
font-size: 0.78rem;
cursor: pointer;
text-align: left;
transition: all 0.15s ease;
&:hover {
background: rgba(255, 168, 60, 0.12);
color: #fff;
}
&.active {
background: rgba(255, 168, 60, 0.18);
color: #ffa83c;
font-weight: 600;
}
}
.lang-selector {
position: relative;
}

View file

@ -61,30 +61,9 @@ a {
margin: 0;
}
// Cap the WHOLE app (header + main) to 1280px on wide monitors and centre it,
// so labels in the nav don't sprawl across 1920px+ screens. The wrapper is
// `<Container fluid id="wrapper">` from svelteui fluid keeps it width: 100%
// inside the viewport, then we shrink the inner content here. Smaller windows
// are unaffected (max-width is a ceiling, not a floor).
//
// `!important` is unfortunate but svelteui's Container emits a width:100%
// rule that sometimes outweighs ours under HMR. The override is loud and
// localised only on the top-level wrapper.
#wrapper {
max-width: 1280px !important;
margin: 0 auto !important;
box-sizing: border-box;
}
#header,
main {
padding: 0 30px;
box-sizing: border-box;
}
.app-container.assist-page {
width: 100%;
margin: 0 auto;
}
// ### FORM OVERRIDES

View file

@ -50,6 +50,6 @@ export async function getSupportedLanguages(): Promise<string[]> {
try {
return await invoke<string[]>("get_supported_languages")
} catch {
return ["ru", "en"]
return ["ru", "en", "ua"]
}
}

View file

@ -12,21 +12,6 @@ export const lastRecognizedText = writable("")
export const lastExecutedCommand = writable("")
export const lastError = writable("")
// Daemon runtime state — populated by health_snapshot events from jarvis-app.
// `null` means we never received one (daemon not running or doesn't support
// QueryHealth yet).
export interface DaemonHealth {
tts_backend: string
llm_backend: string
llm_model: string | null
active_profile: string
memory_facts: number
scheduled_tasks: number
language: string
version: string | null
}
export const daemonHealth = writable<DaemonHealth | null>(null)
// ### CONNECTION ###
const IPC_URL = "ws://127.0.0.1:9712"
@ -148,19 +133,6 @@ function handleEvent(data: any) {
// bring window to foreground
revealWindow()
break
case "health_snapshot":
daemonHealth.set({
tts_backend: data.tts_backend ?? "none",
llm_backend: data.llm_backend ?? "none",
llm_model: data.llm_model ?? null,
active_profile: data.active_profile ?? "default",
memory_facts: data.memory_facts ?? 0,
scheduled_tasks: data.scheduled_tasks ?? 0,
language: data.language ?? "ru",
version: data.version ?? null,
})
break
}
}
@ -203,30 +175,6 @@ export function sendTextCommand(text: string): boolean {
return sendAction("text_command", { text })
}
// IMBA: hot-swap daemon's LLM backend over IPC so the running listener picks
// the new backend immediately. Returns false if daemon not connected.
export function switchDaemonLlm(backend: "groq" | "ollama"): boolean {
return sendAction("switch_llm", { backend })
}
// Tell daemon to re-read llm_backend from settings DB. Use after GUI changes
// the DB independently.
export function reloadDaemonLlm(): boolean {
return sendAction("reload_llm")
}
// Hot-swap daemon's TTS backend over IPC. Use "" or "auto" for re-resolve.
// Returns false if daemon not connected.
export function switchDaemonTts(backend: string): boolean {
return sendAction("switch_tts", { backend })
}
// Ask daemon for a runtime snapshot. Daemon will respond with a
// "health_snapshot" event which fills the `daemonHealth` store.
export function queryDaemonHealth(): boolean {
return sendAction("query_health")
}
async function revealWindow() {
try {
const window = getCurrentWindow()

View file

@ -1,249 +1,41 @@
<script lang="ts">
import { onMount } from "svelte"
import { TextInput, Space, Badge, Tooltip, Loader } from "@svelteuidev/core"
import { invoke } from "@tauri-apps/api/core"
import { MagnifyingGlass } from "radix-icons-svelte"
import { Notification, Space } from "@svelteuidev/core"
import { InfoCircled } from "radix-icons-svelte"
import HDivider from "@/components/elements/HDivider.svelte"
import Footer from "@/components/Footer.svelte"
import { currentLanguage, translations, translate } from "@/stores"
import { appInfo, translations, translate } from "@/stores"
$: t = (key: string) => translate($translations, key)
type JCommand = {
id: string
type: string
description?: string
script?: string
sandbox?: string
timeout?: number
phrases: Record<string, string[]>
sounds?: Record<string, string[]>
}
let commands: JCommand[] = []
let loading = true
let filter = ""
let errorMessage = ""
async function load() {
loading = true
errorMessage = ""
try {
commands = await invoke<JCommand[]>("get_commands_list")
commands = [...commands].sort((a, b) => a.id.localeCompare(b.id))
} catch (err) {
errorMessage = String(err)
console.error("commands list load failed:", err)
} finally {
loading = false
}
}
onMount(load)
function phrasesForLang(c: JCommand, lang: string): string[] {
return c.phrases?.[lang] ?? c.phrases?.["en"] ?? Object.values(c.phrases ?? {})[0] ?? []
}
function matches(c: JCommand, q: string): boolean {
if (!q) return true
const needle = q.toLowerCase()
if (c.id.toLowerCase().includes(needle)) return true
if ((c.description ?? "").toLowerCase().includes(needle)) return true
if ((c.type ?? "").toLowerCase().includes(needle)) return true
for (const list of Object.values(c.phrases ?? {})) {
for (const p of list) {
if (p.toLowerCase().includes(needle)) return true
}
}
return false
}
function typeColor(type: string): string {
switch (type) {
case "lua": return "#5b8def"
case "ahk": return "#b7411a"
case "cli": return "#8a8d90"
case "voice": return "#3aaf72"
case "terminate": return "#c93a3a"
case "stop_chaining": return "#9d6abf"
default: return "#6c6e71"
}
}
$: visible = commands.filter(c => matches(c, filter))
$: countLabel = filter
? `${visible.length} / ${commands.length}`
: `${commands.length}`
let tgLink = ""
appInfo.subscribe(info => {
tgLink = info.tgOfficialLink
})
</script>
<Space h="md" />
<Space h="xl" />
<div class="toolbar">
<TextInput
bind:value={filter}
placeholder={$currentLanguage === "ru" ? "Поиск по имени или фразе..." : "Search by id or phrase..."}
icon={MagnifyingGlass}
size="sm"
radius="md"
override={{ flex: 1 }}
/>
<Tooltip label={$currentLanguage === "ru" ? "Перечитать" : "Reload"} position="bottom">
<button class="reload-btn" on:click={load} aria-label="reload"></button>
</Tooltip>
<Notification
title={t('commands-wip-title')}
icon={InfoCircled}
color="blue"
withCloseButton={false}
>
{t('commands-wip-desc')}<br />
{t('commands-wip-follow')} <a href={tgLink} target="_blank">{t('commands-wip-channel')}</a>!
</Notification>
<div class="placeholder-image">
<img src="/media/images/tenor.gif" alt="bruh" width="320px" />
</div>
<div class="meta">
<span class="count">{countLabel}</span>
<span class="dot">·</span>
<span class="hint">
{$currentLanguage === "ru"
? "Скажи «Джарвис» + любую фразу из карточки"
: "Say \"Jarvis\" + any phrase from a card"}
</span>
</div>
{#if loading}
<div class="state"><Loader size="sm" /></div>
{:else if errorMessage}
<div class="state error">{errorMessage}</div>
{:else if visible.length === 0}
<div class="state">
{$currentLanguage === "ru" ? "Ничего не найдено" : "Nothing found"}
</div>
{:else}
<div class="grid">
{#each visible as cmd (cmd.id)}
<div class="card">
<div class="card-head">
<span class="cmd-id">{cmd.id}</span>
<Badge
size="xs"
radius="sm"
variant="filled"
override={{ backgroundColor: typeColor(cmd.type), color: "#fff" }}
>
{cmd.type}
</Badge>
{#if cmd.sandbox}
<Badge size="xs" radius="sm" variant="outline">{cmd.sandbox}</Badge>
{/if}
</div>
{#if cmd.description}
<div class="card-desc">{cmd.description}</div>
{/if}
<ul class="phrases">
{#each phrasesForLang(cmd, $currentLanguage) as p}
<li>«{p}»</li>
{/each}
</ul>
</div>
{/each}
</div>
{/if}
<HDivider />
<Footer />
<style lang="scss">
.toolbar {
display: flex;
align-items: center;
gap: 8px;
padding: 0 10px;
}
.reload-btn {
background: transparent;
border: 1px solid #3a3d40;
color: #c8cacc;
border-radius: 8px;
height: 34px;
width: 34px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: 0.2s;
&:hover {
border-color: #5b8def;
color: #5b8def;
}
}
.meta {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px 12px;
font-size: 12px;
color: #8a8d90;
.count {
background: #5b8def;
color: #fff;
padding: 1px 8px;
border-radius: 10px;
font-weight: 600;
}
.dot { color: #46484b; }
}
.state {
padding: 30px 12px;
<style>
.placeholder-image {
text-align: center;
color: #8a8d90;
font-size: 13px;
&.error {
color: #c93a3a;
font-family: monospace;
}
}
.grid {
display: grid;
grid-template-columns: 1fr;
gap: 8px;
padding: 0 10px 10px;
}
.card {
background: #1c1d20;
border: 1px solid #2a2c2f;
border-radius: 8px;
padding: 10px 12px;
transition: border-color 0.15s;
&:hover {
border-color: #3a3d40;
}
}
.card-head {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
flex-wrap: wrap;
}
.cmd-id {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
color: #e0e2e4;
font-weight: 600;
margin-right: auto;
}
.card-desc {
font-size: 12px;
color: #a0a3a6;
margin-bottom: 6px;
}
.phrases {
list-style: none;
margin: 0;
padding: 0;
font-size: 12px;
color: #c8cacc;
line-height: 1.6em;
li {
display: inline-block;
background: #25272a;
border-radius: 4px;
padding: 1px 7px;
margin: 2px 4px 2px 0;
}
margin-top: 25px;
}
</style>

View file

@ -1,313 +0,0 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte"
import { invoke } from "@tauri-apps/api/core"
import { goto } from "@roxi/routify"
import HDivider from "@/components/elements/HDivider.svelte"
import Footer from "@/components/Footer.svelte"
import {
Button, Space, Text, Notification, Badge, Loader, TextInput,
} from "@svelteuidev/core"
import { CrossCircled, Reload, Trash, MagnifyingGlass } from "radix-icons-svelte"
interface HistoryEntry {
ts: number
phrase: string
source: string
kind: "matched" | "not_found" | "llm_handled" | "error"
command_id: string | null
confidence_pct: number | null
via: string | null
success: boolean | null
error_message: string | null
}
let entries: HistoryEntry[] = []
let loading = true
let error = ""
let filter = ""
// Poll the disk-backed log every 2s so daemon writes show up live.
// Backed by `history_recent` which re-reads recognition_log.json each
// call — see crates/jarvis-gui/src/tauri_commands/history.rs.
let pollHandle: ReturnType<typeof setInterval> | null = null
async function load() {
try {
entries = await invoke<HistoryEntry[]>("history_recent", { limit: 200 })
} catch (e) {
error = String(e)
} finally {
loading = false
}
}
async function clearAll() {
if (!confirm("Очистить всю историю распознаваний?")) return
try {
await invoke<number>("history_clear")
await load()
} catch (e) {
error = String(e)
}
}
onMount(() => {
load()
pollHandle = setInterval(load, 2000)
})
onDestroy(() => {
if (pollHandle !== null) clearInterval(pollHandle)
})
function fmtTime(ts: number): string {
const d = new Date(ts * 1000)
const now = new Date()
const sameDay = d.toDateString() === now.toDateString()
if (sameDay) {
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
}
return d.toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
}
function kindColor(e: HistoryEntry): "lime" | "red" | "orange" | "blue" {
if (e.kind === "matched") return e.success ? "lime" : "red"
if (e.kind === "llm_handled") return "blue"
if (e.kind === "error") return "red"
return "orange" // not_found
}
function kindLabel(e: HistoryEntry): string {
switch (e.kind) {
case "matched": return e.success ? "Выполнено" : "Ошибка"
case "not_found": return "Не понял"
case "llm_handled": return "LLM ответил"
case "error": return "Сбой"
}
}
function viaLabel(via: string | null): string {
if (!via) return ""
const map: Record<string, string> = {
intent: "intent",
fuzzy: "fuzzy",
router: "LLM-router",
llm: "LLM",
}
return map[via] || via
}
$: filtered = filter.trim()
? entries.filter(e =>
e.phrase.toLowerCase().includes(filter.toLowerCase())
|| (e.command_id || "").toLowerCase().includes(filter.toLowerCase())
)
: entries
$: matched = entries.filter(e => e.kind === "matched" && e.success).length
$: misses = entries.filter(e => e.kind === "not_found" || e.kind === "error" || (e.kind === "matched" && !e.success)).length
</script>
<Space h="xl" />
<h2 class="page-title">История распознаваний</h2>
<Text size="sm" color="gray">
Каждая фраза, которую Jarvis услышал и обработал. Зелёный — команда
нашлась и выполнилась, оранжевый — не понял, синий — обработал через LLM,
красный — была ошибка. Полезно понимать, что именно надо переформулировать
или дотренировать.
</Text>
<Space h="md" />
<div class="stats-row">
<Badge color="lime" size="md" variant="filled">{matched}</Badge>
&nbsp;
<Badge color="orange" size="md" variant="filled">{misses}</Badge>
&nbsp;
<Badge color="gray" size="md" variant="light">всего {entries.length}</Badge>
</div>
<Space h="md" />
<div class="actions-row">
<TextInput
placeholder="Фильтр по фразе или команде…"
variant="filled"
bind:value={filter}
icon={MagnifyingGlass}
/>
&nbsp;
<Button color="gray" radius="md" size="sm" on:click={load}>
<Reload size={14} /> &nbsp; Обновить
</Button>
&nbsp;
<Button color="red" radius="md" size="sm" variant="outline" on:click={clearAll}>
<Trash size={14} /> &nbsp; Очистить
</Button>
</div>
<Space h="md" />
{#if error}
<Notification
title="Ошибка"
icon={CrossCircled}
color="red"
on:close={() => { error = "" }}
>
{error}
</Notification>
<Space h="sm" />
{/if}
{#if loading}
<Loader />
{:else if filtered.length === 0}
<Text size="sm" color="gray">
{entries.length === 0
? "История пуста. Скажи что-нибудь — появится здесь."
: "Под фильтр ничего не подходит."}
</Text>
{:else}
<div class="entry-list">
{#each filtered as e}
<div class="entry-card kind-{e.kind}">
<div class="entry-header">
<span class="entry-phrase">«{e.phrase}»</span>
<Badge color={kindColor(e)} variant="filled" size="sm">
{kindLabel(e)}
</Badge>
</div>
<div class="entry-meta">
<small>{fmtTime(e.ts)}</small>
{#if e.command_id}
<small><code>{e.command_id}</code></small>
{/if}
{#if e.via}
<small>через {viaLabel(e.via)}</small>
{/if}
{#if e.confidence_pct !== null}
<small>{e.confidence_pct}% увер.</small>
{/if}
<small class="source-tag">{e.source}</small>
</div>
{#if e.error_message}
<div class="entry-error">{e.error_message}</div>
{/if}
</div>
{/each}
</div>
{/if}
<Space h="xl" />
<Button color="gray" radius="md" size="sm" uppercase fullSize on:click={() => $goto("/")}>
Назад
</Button>
<HDivider />
<Footer />
<style lang="scss">
.page-title {
margin: 0 0 4px 0;
color: #fff;
font-size: 1.3rem;
font-weight: 600;
}
.stats-row {
display: flex;
align-items: center;
}
.actions-row {
display: flex;
gap: 0.5rem;
align-items: center;
:global(.svelteui-Input-root) {
flex: 1;
}
}
.entry-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.entry-card {
background: rgba(30, 40, 45, 0.75);
border: 1px solid rgba(255, 255, 255, 0.06);
border-left: 4px solid rgba(255, 255, 255, 0.15);
border-radius: 8px;
padding: 0.6rem 0.85rem;
&.kind-matched {
border-left-color: rgba(132, 204, 22, 0.7);
}
&.kind-not_found {
border-left-color: rgba(249, 115, 22, 0.7);
opacity: 0.85;
}
&.kind-llm_handled {
border-left-color: rgba(59, 130, 246, 0.7);
}
&.kind-error {
border-left-color: rgba(239, 68, 68, 0.85);
}
}
.entry-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.3rem;
}
.entry-phrase {
font-size: 0.95rem;
font-weight: 500;
color: #fff;
word-break: break-word;
}
.entry-meta {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
color: rgba(255, 255, 255, 0.55);
font-size: 0.72rem;
code {
background: rgba(255, 255, 255, 0.06);
padding: 1px 5px;
border-radius: 3px;
font-size: 0.7rem;
color: rgba(255, 255, 255, 0.85);
}
.source-tag {
color: rgba(255, 255, 255, 0.4);
font-style: italic;
}
}
.entry-error {
color: rgba(255, 120, 120, 0.95);
background: rgba(120, 30, 30, 0.2);
font-size: 0.75rem;
padding: 0.3rem 0.5rem;
border-radius: 4px;
margin-top: 0.4rem;
}
</style>

View file

@ -1,306 +0,0 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte"
import { invoke } from "@tauri-apps/api/core"
import { goto } from "@roxi/routify"
import HDivider from "@/components/elements/HDivider.svelte"
import Footer from "@/components/Footer.svelte"
import {
Button, Space, Text, Notification, Badge, TextInput, Loader,
} from "@svelteuidev/core"
import { Play, Trash, Plus, CrossCircled } from "radix-icons-svelte"
interface MacroInfo {
name: string
steps_count: number
steps: string[]
created_at: number
last_run: number | null
}
let macros: MacroInfo[] = []
let loading = true
let error = ""
let isRecording = false
let recordingName: string | null = null
let newMacroName = ""
let busyMacro = "" // macro currently being replayed
let recordingPoll: ReturnType<typeof setInterval> | null = null
async function load() {
loading = true
error = ""
try {
macros = await invoke<MacroInfo[]>("macros_list")
macros = [...macros].sort((a, b) => a.name.localeCompare(b.name))
await pollRecording()
} catch (e) {
error = String(e)
} finally {
loading = false
}
}
async function pollRecording() {
try {
isRecording = await invoke<boolean>("macros_is_recording")
recordingName = await invoke<string | null>("macros_recording_name")
} catch (e) { /* ignore */ }
}
async function startRecording() {
const name = newMacroName.trim()
if (!name) { error = "Введите имя макроса"; return }
try {
await invoke("macros_start_recording", { name })
newMacroName = ""
await pollRecording()
} catch (e) { error = String(e) }
}
async function saveRecording() {
try {
const count = await invoke<number>("macros_save_recording")
await load()
error = `Сохранил ${count} шагов`
setTimeout(() => { if (error.startsWith("Сохранил")) error = "" }, 3000)
} catch (e) { error = String(e) }
}
async function cancelRecording() {
try {
await invoke("macros_cancel_recording")
await pollRecording()
} catch (e) { error = String(e) }
}
async function playMacro(name: string) {
busyMacro = name
try {
const count = await invoke<number>("macros_replay", { name })
// 800ms × count + safety
setTimeout(() => { busyMacro = "" }, count * 900 + 500)
} catch (e) {
error = String(e)
busyMacro = ""
}
}
async function deleteMacro(name: string) {
if (!confirm(`Удалить макрос "${name}"?`)) return
try {
await invoke("macros_delete", { name })
await load()
} catch (e) { error = String(e) }
}
onMount(() => {
load()
recordingPoll = setInterval(pollRecording, 2000)
})
onDestroy(() => {
if (recordingPoll !== null) clearInterval(recordingPoll)
})
function fmtDate(ts: number | null): string {
if (!ts) return "—"
return new Date(ts * 1000).toLocaleString()
}
</script>
<Space h="xl" />
<h2 class="page-title">Макросы</h2>
<Text size="sm" color="gray">
Запиши последовательность голосовых команд, потом запускай её одной фразой.
</Text>
<Space h="md" />
{#if isRecording}
<Notification
title="Идёт запись макроса"
icon={Play}
color="orange"
withCloseButton={false}
>
<Text size="sm">
Записывается макрос <strong>{recordingName ?? "—"}</strong>.
Произнесите команды, потом нажмите «Сохранить» или скажите «Сохрани макрос».
</Text>
<Space h="sm" />
<Button color="lime" radius="md" size="xs" uppercase on:click={saveRecording}>
Сохранить
</Button>
&nbsp;
<Button color="gray" radius="md" size="xs" uppercase on:click={cancelRecording}>
Отменить
</Button>
</Notification>
<Space h="md" />
{:else}
<div class="new-macro-row">
<TextInput
placeholder="Имя нового макроса"
variant="filled"
bind:value={newMacroName}
/>
<Button color="lime" radius="md" size="sm" on:click={startRecording}>
<Plus size={14} /> &nbsp; Начать запись
</Button>
</div>
<Space h="md" />
{/if}
{#if error}
<Notification
title="Сообщение"
icon={CrossCircled}
color={error.startsWith("Сохранил") ? "teal" : "red"}
on:close={() => { error = "" }}
>
{error}
</Notification>
<Space h="sm" />
{/if}
{#if loading}
<Loader />
{:else if macros.length === 0}
<Text size="sm" color="gray">
Макросов пока нет. Создайте первый: введите имя, нажмите «Начать запись»,
произнесите команды, нажмите «Сохранить».
</Text>
{:else}
<div class="macro-list">
{#each macros as m}
<div class="macro-card">
<div class="macro-header">
<span class="macro-name">{m.name}</span>
<Badge color="blue" variant="filled" size="sm">
{m.steps_count} шагов
</Badge>
</div>
<div class="macro-steps">
{#each m.steps.slice(0, 5) as step}
<div class="step">{step}</div>
{/each}
{#if m.steps.length > 5}
<div class="step muted">+ ещё {m.steps.length - 5}</div>
{/if}
</div>
<div class="macro-meta">
<small>Создан: {fmtDate(m.created_at)}</small>
<small>Последний запуск: {fmtDate(m.last_run)}</small>
</div>
<div class="macro-actions">
<Button
color="lime" radius="md" size="xs" uppercase
on:click={() => playMacro(m.name)}
disabled={busyMacro === m.name}
>
{#if busyMacro === m.name}
Выполняется…
{:else}
<Play size={14} /> &nbsp; Запустить
{/if}
</Button>
&nbsp;
<Button
color="red" radius="md" size="xs" uppercase
on:click={() => deleteMacro(m.name)}
>
<Trash size={14} /> &nbsp; Удалить
</Button>
</div>
</div>
{/each}
</div>
{/if}
<Space h="xl" />
<Button color="gray" radius="md" size="sm" uppercase fullSize on:click={() => $goto("/")}>
Назад
</Button>
<HDivider />
<Footer />
<style lang="scss">
.page-title {
margin: 0 0 4px 0;
color: #fff;
font-size: 1.3rem;
font-weight: 600;
}
.new-macro-row {
display: flex;
gap: 0.5rem;
align-items: flex-end;
:global(.svelteui-Input-root) {
flex: 1;
}
}
.macro-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.macro-card {
background: rgba(30, 40, 45, 0.75);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 10px;
padding: 0.85rem 1rem;
}
.macro-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.macro-name {
font-size: 0.95rem;
font-weight: 600;
color: #fff;
}
.macro-steps {
background: rgba(0, 0, 0, 0.2);
border-radius: 6px;
padding: 0.4rem 0.6rem;
margin-bottom: 0.5rem;
.step {
font-size: 0.78rem;
color: rgba(255, 255, 255, 0.7);
line-height: 1.4em;
&.muted {
color: rgba(255, 255, 255, 0.4);
font-style: italic;
}
}
}
.macro-meta {
display: flex;
justify-content: space-between;
color: rgba(255, 255, 255, 0.4);
font-size: 0.7rem;
margin-bottom: 0.5rem;
}
.macro-actions {
display: flex;
gap: 0.4rem;
}
</style>

View file

@ -1,249 +0,0 @@
<script lang="ts">
import { onMount } from "svelte"
import { invoke } from "@tauri-apps/api/core"
import { goto } from "@roxi/routify"
import HDivider from "@/components/elements/HDivider.svelte"
import Footer from "@/components/Footer.svelte"
import {
Button, Space, Text, Notification, Badge, TextInput, Loader, Textarea,
} from "@svelteuidev/core"
import { Plus, Trash, CrossCircled, MagnifyingGlass } from "radix-icons-svelte"
interface MemoryFact {
key: string
value: string
created_at: number
last_used_at: number
use_count: number
}
let facts: MemoryFact[] = []
let loading = true
let error = ""
let filter = ""
// new-fact form
let newKey = ""
let newValue = ""
async function load() {
loading = true
error = ""
try {
facts = await invoke<MemoryFact[]>("memory_list")
facts = [...facts].sort((a, b) => b.last_used_at - a.last_used_at)
} catch (e) {
error = String(e)
} finally {
loading = false
}
}
async function addFact() {
const k = newKey.trim()
const v = newValue.trim()
if (!k) { error = "Ключ не может быть пустым"; return }
if (!v) { error = "Значение не может быть пустым"; return }
try {
await invoke("memory_remember", { key: k, value: v })
newKey = ""
newValue = ""
await load()
} catch (e) { error = String(e) }
}
async function deleteFact(key: string) {
if (!confirm(`Забыть факт "${key}"?`)) return
try {
await invoke("memory_forget", { key })
await load()
} catch (e) { error = String(e) }
}
onMount(load)
$: visible = filter
? facts.filter(f =>
f.key.toLowerCase().includes(filter.toLowerCase()) ||
f.value.toLowerCase().includes(filter.toLowerCase())
)
: facts
function fmtDate(ts: number): string {
if (!ts) return "—"
return new Date(ts * 1000).toLocaleString()
}
</script>
<Space h="xl" />
<h2 class="page-title">Память</h2>
<Text size="sm" color="gray">
Долговременные факты о пользователе. LLM получает их в системном промпте автоматически,
когда фразой пересекаются. Хранятся в <code>long_term_memory.json</code>.
</Text>
<Space h="md" />
<div class="new-fact">
<div class="fact-row">
<TextInput placeholder="Ключ (напр. 'любимый чай')" variant="filled" bind:value={newKey} />
<TextInput placeholder="Значение (напр. 'улун')" variant="filled" bind:value={newValue} />
<Button color="lime" radius="md" size="sm" on:click={addFact}>
<Plus size={14} /> &nbsp; Добавить
</Button>
</div>
</div>
<Space h="md" />
<TextInput
placeholder="Фильтр по ключу или значению"
variant="filled"
icon={MagnifyingGlass}
bind:value={filter}
/>
<Space h="sm" />
{#if error}
<Notification
title="Ошибка"
icon={CrossCircled}
color="red"
on:close={() => { error = "" }}
>
{error}
</Notification>
<Space h="sm" />
{/if}
{#if loading}
<Loader />
{:else if facts.length === 0}
<Text size="sm" color="gray">
Память пустая. Добавь факт сверху или скажи Jarvis-у "запомни что я люблю чай улун".
</Text>
{:else if visible.length === 0}
<Text size="sm" color="gray">
По фильтру ничего не найдено. Всего фактов: {facts.length}.
</Text>
{:else}
<div class="fact-list">
{#each visible as fact}
<div class="fact-card">
<div class="fact-header">
<span class="fact-key">{fact.key}</span>
<div class="fact-meta-inline">
<Badge color="cyan" variant="outline" size="sm">
{fact.use_count}× использований
</Badge>
</div>
</div>
<div class="fact-value">
{fact.value}
</div>
<div class="fact-meta">
<small>Создан: {fmtDate(fact.created_at)}</small>
<small>Последний доступ: {fmtDate(fact.last_used_at)}</small>
</div>
<div class="fact-actions">
<Button
color="red" radius="md" size="xs" uppercase
on:click={() => deleteFact(fact.key)}
>
<Trash size={14} /> &nbsp; Забыть
</Button>
</div>
</div>
{/each}
</div>
{/if}
<Space h="xl" />
<Button color="gray" radius="md" size="sm" uppercase fullSize on:click={() => $goto("/")}>
Назад
</Button>
<HDivider />
<Footer />
<style lang="scss">
.page-title {
margin: 0 0 4px 0;
color: #fff;
font-size: 1.3rem;
font-weight: 600;
}
.new-fact {
background: rgba(30, 40, 45, 0.55);
border: 1px solid rgba(255, 255, 255, 0.07);
border-radius: 10px;
padding: 0.75rem;
.fact-row {
display: grid;
grid-template-columns: 1fr 2fr auto;
gap: 0.5rem;
align-items: end;
}
}
.fact-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.fact-card {
background: rgba(30, 40, 45, 0.75);
border: 1px solid rgba(82, 254, 254, 0.15);
border-radius: 10px;
padding: 0.85rem 1rem;
}
.fact-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.4rem;
}
.fact-key {
font-size: 0.95rem;
font-weight: 600;
color: #fff;
}
.fact-meta-inline {
display: flex;
gap: 0.4rem;
}
.fact-value {
background: rgba(0, 0, 0, 0.2);
border-radius: 6px;
padding: 0.5rem 0.7rem;
color: rgba(255, 255, 255, 0.9);
font-size: 0.9rem;
line-height: 1.45em;
margin-bottom: 0.5rem;
}
.fact-meta {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
color: rgba(255, 255, 255, 0.4);
font-size: 0.7rem;
margin-bottom: 0.5rem;
}
.fact-actions {
display: flex;
gap: 0.4rem;
}
</style>

View file

@ -1,237 +0,0 @@
<script lang="ts">
import { onMount } from "svelte"
import { invoke } from "@tauri-apps/api/core"
import { goto } from "@roxi/routify"
import HDivider from "@/components/elements/HDivider.svelte"
import Footer from "@/components/Footer.svelte"
import {
Button, Space, Text, Notification, Badge, Loader, Switch,
} from "@svelteuidev/core"
import { CrossCircled, ExternalLink, Reload } from "radix-icons-svelte"
interface PluginEntry {
name: string
path: string
enabled: boolean
command_count: number
error: string | null
}
let plugins: PluginEntry[] = []
let loading = true
let error = ""
let info = ""
let busy = ""
async function load() {
loading = true
error = ""
try {
plugins = await invoke<PluginEntry[]>("plugins_list")
plugins = [...plugins].sort((a, b) => a.name.localeCompare(b.name))
} catch (e) {
error = String(e)
} finally {
loading = false
}
}
async function toggle(p: PluginEntry) {
busy = p.name
try {
await invoke("plugins_set_enabled", { name: p.name, enabled: !p.enabled })
await load()
info = `«${p.name}» ${p.enabled ? "выключен" : "включён"}. Перезапусти Jarvis, чтобы применить.`
setTimeout(() => { info = "" }, 5000)
} catch (e) {
error = String(e)
} finally {
busy = ""
}
}
async function openFolder() {
try {
const path = await invoke<string>("plugins_open_folder")
info = `Папка плагинов: ${path}`
setTimeout(() => { info = "" }, 4000)
} catch (e) {
error = String(e)
}
}
onMount(load)
</script>
<Space h="xl" />
<h2 class="page-title">Плагины</h2>
<Text size="sm" color="gray">
Дополнительные voice-команды, которые лежат рядом с конфигом
(<code>%APPDATA%\com.priler.jarvis\plugins\</code>). Каждый плагин — это
папка с <code>command.toml</code> и Lua-скриптами.
Меняется только после перезапуска.
</Text>
<Space h="md" />
<div class="actions-row">
<Button color="lime" radius="md" size="sm" on:click={openFolder}>
<ExternalLink size={14} /> &nbsp; Открыть папку
</Button>
&nbsp;
<Button color="gray" radius="md" size="sm" on:click={load}>
<Reload size={14} /> &nbsp; Обновить список
</Button>
</div>
<Space h="md" />
{#if info}
<Notification title="Готово" color="teal" on:close={() => { info = "" }}>
{info}
</Notification>
<Space h="sm" />
{/if}
{#if error}
<Notification
title="Ошибка"
icon={CrossCircled}
color="red"
on:close={() => { error = "" }}
>
{error}
</Notification>
<Space h="sm" />
{/if}
{#if loading}
<Loader />
{:else if plugins.length === 0}
<Text size="sm" color="gray">
Плагинов пока нет. Скопируйте папку плагина в указанный каталог
и нажмите «Обновить список».
</Text>
{:else}
<div class="plugin-list">
{#each plugins as p}
<div class="plugin-card" class:disabled={!p.enabled} class:errored={!!p.error}>
<div class="plugin-header">
<span class="plugin-name">{p.name}</span>
{#if p.error}
<Badge color="red" variant="filled" size="sm">ошибка</Badge>
{:else}
<Badge color={p.enabled ? "lime" : "gray"} variant="filled" size="sm">
{p.command_count} команд
</Badge>
{/if}
</div>
<div class="plugin-path">{p.path}</div>
{#if p.error}
<div class="plugin-error">{p.error}</div>
{/if}
<div class="plugin-actions">
<Switch
size="sm"
checked={p.enabled}
disabled={busy === p.name || !!p.error}
on:change={() => toggle(p)}
/>
<Text size="xs" color={p.enabled ? "lime" : "gray"}>
{p.enabled ? "Включён" : "Выключен"}
</Text>
</div>
</div>
{/each}
</div>
{/if}
<Space h="xl" />
<Button color="gray" radius="md" size="sm" uppercase fullSize on:click={() => $goto("/")}>
Назад
</Button>
<HDivider />
<Footer />
<style lang="scss">
.page-title {
margin: 0 0 4px 0;
color: #fff;
font-size: 1.3rem;
font-weight: 600;
}
code {
background: rgba(255, 255, 255, 0.08);
padding: 1px 4px;
border-radius: 3px;
font-size: 0.8rem;
}
.actions-row {
display: flex;
gap: 0.5rem;
align-items: center;
}
.plugin-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.plugin-card {
background: rgba(30, 40, 45, 0.75);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 10px;
padding: 0.85rem 1rem;
&.disabled {
opacity: 0.55;
}
&.errored {
border-color: rgba(220, 90, 90, 0.5);
}
}
.plugin-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.35rem;
}
.plugin-name {
font-size: 0.95rem;
font-weight: 600;
color: #fff;
}
.plugin-path {
color: rgba(255, 255, 255, 0.4);
font-size: 0.7rem;
font-family: ui-monospace, "Cascadia Mono", monospace;
word-break: break-all;
margin-bottom: 0.5rem;
}
.plugin-error {
color: rgba(255, 120, 120, 0.95);
background: rgba(120, 30, 30, 0.25);
font-size: 0.75rem;
padding: 0.35rem 0.55rem;
border-radius: 6px;
margin-bottom: 0.5rem;
}
.plugin-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
</style>

View file

@ -1,238 +0,0 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte"
import { invoke } from "@tauri-apps/api/core"
import { goto } from "@roxi/routify"
import HDivider from "@/components/elements/HDivider.svelte"
import Footer from "@/components/Footer.svelte"
import {
Button, Space, Text, Notification, Badge, Loader,
} from "@svelteuidev/core"
import { Clock, Trash, CrossCircled } from "radix-icons-svelte"
interface ScheduledTaskInfo {
id: string
name: string
schedule_human: string
action_kind: string
action_text: string | null
last_fired: number | null
enabled: boolean
created_at: number
}
let tasks: ScheduledTaskInfo[] = []
let loading = true
let error = ""
let poll: ReturnType<typeof setInterval> | null = null
async function load() {
try {
tasks = await invoke<ScheduledTaskInfo[]>("scheduler_list")
tasks = [...tasks].sort((a, b) => a.name.localeCompare(b.name))
} catch (e) {
error = String(e)
} finally {
loading = false
}
}
async function removeTask(id: string, label: string) {
if (!confirm(`Удалить задачу "${label}"?`)) return
try {
await invoke("scheduler_remove", { id })
await load()
} catch (e) { error = String(e) }
}
async function clearAll() {
if (!confirm("Удалить ВСЕ запланированные задачи?")) return
try {
const count = await invoke<number>("scheduler_clear")
error = `Удалено ${count}`
setTimeout(() => { if (error.startsWith("Удалено")) error = "" }, 2500)
await load()
} catch (e) { error = String(e) }
}
onMount(() => {
load()
poll = setInterval(load, 5000)
})
onDestroy(() => {
if (poll !== null) clearInterval(poll)
})
function fmtDate(ts: number | null): string {
if (!ts) return "—"
return new Date(ts * 1000).toLocaleString()
}
function actionColor(kind: string): string {
switch (kind) {
case "speak": return "cyan"
case "lua": return "violet"
default: return "gray"
}
}
</script>
<Space h="xl" />
<h2 class="page-title">Расписание</h2>
<Text size="sm" color="gray">
Запланированные задачи: напоминания, ежедневные брифинги, привычки.
Тик каждые 30 секунд.
</Text>
<Space h="md" />
{#if error}
<Notification
title="Сообщение"
icon={CrossCircled}
color={error.startsWith("Удалено") ? "teal" : "red"}
on:close={() => { error = "" }}
>
{error}
</Notification>
<Space h="sm" />
{/if}
{#if loading}
<Loader />
{:else if tasks.length === 0}
<Text size="sm" color="gray">
Расписание пустое. Добавь задачу голосом:
«напомни через 5 минут выключить кофеварку», «каждые 2 часа напоминай пить воду»,
«каждый день в 9 утра делай брифинг», «напоминай отдыхать глазам».
</Text>
{:else}
<div class="task-list">
{#each tasks as task}
<div class="task-card">
<div class="task-header">
<div class="task-title">
<span class="task-name">{task.name}</span>
<Badge color={actionColor(task.action_kind)} variant="outline" size="sm">
{task.action_kind}
</Badge>
</div>
<Badge color="orange" variant="filled" size="sm">
{task.schedule_human}
</Badge>
</div>
{#if task.action_text}
<div class="task-body">
{task.action_text}
</div>
{/if}
<div class="task-meta">
<small>ID: <code>{task.id}</code></small>
<small>Создано: {fmtDate(task.created_at)}</small>
<small>Последний запуск: {fmtDate(task.last_fired)}</small>
</div>
<div class="task-actions">
<Button
color="red" radius="md" size="xs" uppercase
on:click={() => removeTask(task.id, task.name)}
>
<Trash size={14} /> &nbsp; Отменить
</Button>
</div>
</div>
{/each}
</div>
<Space h="md" />
<Button color="red" radius="md" size="sm" uppercase fullSize on:click={clearAll}>
Очистить всё ({tasks.length})
</Button>
{/if}
<Space h="xl" />
<Button color="gray" radius="md" size="sm" uppercase fullSize on:click={() => $goto("/")}>
Назад
</Button>
<HDivider />
<Footer />
<style lang="scss">
.page-title {
margin: 0 0 4px 0;
color: #fff;
font-size: 1.3rem;
font-weight: 600;
}
.task-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.task-card {
background: rgba(30, 40, 45, 0.75);
border: 1px solid rgba(255, 168, 60, 0.18);
border-radius: 10px;
padding: 0.85rem 1rem;
}
.task-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
gap: 0.5rem;
}
.task-title {
display: flex;
gap: 0.5rem;
align-items: center;
}
.task-name {
font-size: 0.95rem;
font-weight: 600;
color: #fff;
}
.task-body {
background: rgba(0, 0, 0, 0.2);
border-radius: 6px;
padding: 0.4rem 0.6rem;
margin-bottom: 0.5rem;
color: rgba(255, 255, 255, 0.85);
font-size: 0.85rem;
line-height: 1.4em;
}
.task-meta {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
color: rgba(255, 255, 255, 0.4);
font-size: 0.7rem;
margin-bottom: 0.5rem;
code {
background: rgba(255, 255, 255, 0.05);
padding: 1px 5px;
border-radius: 3px;
font-size: 0.65rem;
color: rgba(255, 255, 255, 0.6);
}
}
.task-actions {
display: flex;
gap: 0.4rem;
}
</style>

View file

@ -5,11 +5,7 @@
import { setTimeout } from "worker-timers"
import { showInExplorer } from "@/functions"
import {
appInfo, assistantVoice, translations, translate,
switchDaemonLlm, reloadDaemonLlm, switchDaemonTts,
daemonHealth, queryDaemonHealth,
} from "@/stores"
import { appInfo, assistantVoice, translations, translate } from "@/stores"
import HDivider from "@/components/elements/HDivider.svelte"
import Footer from "@/components/Footer.svelte"
@ -24,8 +20,7 @@
Input,
InputWrapper,
NativeSelect,
Switch,
Badge,
Switch
} from "@svelteuidev/core"
import {
@ -35,8 +30,7 @@
Code,
Gear,
QuestionMarkCircled,
CrossCircled,
ChatBubble,
CrossCircled
} from "radix-icons-svelte"
$: t = (key: string) => translate($translations, key)
@ -91,117 +85,15 @@
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. Returns false if WS disconnected
// so we can warn the user — otherwise they'd think the swap
// silently failed.
const daemonAware = switchDaemonLlm(target as "groq" | "ollama")
if (!daemonAware) {
backendSwapError =
"Демон не подключён — LLM в нём переключится при следующем запуске."
}
await refreshActiveBackends()
queryDaemonHealth()
} catch (err) {
backendSwapError = String(err)
} finally {
backendSwapBusy = false
}
}
interface TtsSwapResult {
requested: string
applied: string
fell_back: boolean
note: string | null
}
let ttsSwapInfo: TtsSwapResult | null = null
async function applyTtsBackend(target: string) {
backendSwapError = ""
ttsSwapInfo = null
try {
// 1. GUI Tauri command — persists to DB + hot-swaps GUI's TTS.
// Returns the actual installed backend (may differ from
// requested if Piper/Silero unavailable → fell back to SAPI).
const res = await invoke<TtsSwapResult>("set_tts_backend", { name: target })
ttsSwapInfo = res
// 2. Tell the running daemon (separate process) to switch too.
// sendAction returns false if the WS isn't connected —
// surface that so the user knows the daemon won't change.
const daemonAware = switchDaemonTts(target || "auto")
if (!daemonAware) {
backendSwapError =
"Демон (jarvis-app) не подключён. Изменение применится при следующем запуске. " +
"Запусти Jarvis с главного экрана и повтори, если нужно немедленно."
}
// 3. Refresh the local GUI snapshot AND re-poll daemon health
// so the "Активный движок" chip updates within ~100ms.
// Without the daemon re-poll, the chip keeps showing the
// OLD daemon-side TTS until the next 5s footer poll.
await refreshActiveBackends()
queryDaemonHealth()
} 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 feedbackLink = ""
let logFilePath = ""
appInfo.subscribe(info => {
issuesLink = info.issuesLink
feedbackLink = info.feedbackLink
logFilePath = info.logFilePath
})
@ -274,6 +166,7 @@
const languageNames: Record<string, string> = {
us: 'English',
ru: 'Русский',
uk: 'Українська',
de: 'German',
fr: 'French',
es: 'Spanish',
@ -322,19 +215,6 @@
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)
}
@ -350,7 +230,7 @@
withCloseButton={false}
>
{t('settings-beta-desc')}<br />
{t('settings-beta-feedback')} <a href={issuesLink} target="_blank" rel="noopener noreferrer">{t('settings-beta-bot')}</a>.
{t('settings-beta-feedback')} <a href={feedbackLink} target="_blank">{t('settings-beta-bot')}</a>.
<Space h="sm" />
<Button
color="gray"
@ -427,145 +307,6 @@
/>
</Tabs.Tab>
<Tabs.Tab label={t('settings-ai-backends') || 'AI Backends'} icon={ChatBubble}>
<Space h="sm" />
<!--
The user-visible "Активный движок" chip MUST reflect the running
daemon (jarvis-app), not this GUI process. Otherwise toggling
the TTS dropdown looks like nothing happens — the GUI hardly
speaks, so its own backend stays stale.
Preference order:
1. `daemonHealth` store (filled via WS `query_health` event)
2. local `activeBackends` (GUI's own state) as fallback when
the daemon isn't running
Plus a connection badge so the user knows what they're seeing.
-->
{#if $daemonHealth || activeBackends}
<Alert title={t('settings-ai-active') || 'Активный движок'} color="cyan" variant="outline">
<Text size="sm" color="gray">
{#if $daemonHealth}
LLM: <strong>{$daemonHealth.llm_backend}</strong>
{#if $daemonHealth.llm_model}({$daemonHealth.llm_model}){/if}
&nbsp;·&nbsp; TTS: <strong>{$daemonHealth.tts_backend}</strong>
&nbsp;·&nbsp; {t('settings-profile') || 'Profile'}: <strong>{$daemonHealth.active_profile}</strong>
<Badge color="lime" variant="filled" size="xs">демон</Badge>
{:else if activeBackends}
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>
<Badge color="orange" variant="filled" size="xs">демон не отвечает — показано из GUI</Badge>
{/if}
</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>
<Space h="md" />
{/if}
{#if ttsSwapInfo && ttsSwapInfo.fell_back}
<Notification
title="TTS откатился на {ttsSwapInfo.applied.toUpperCase()}"
color="orange"
withCloseButton={true}
on:close={() => { ttsSwapInfo = null }}
>
Запросил <strong>{ttsSwapInfo.requested}</strong> — недоступен.
{#if ttsSwapInfo.note}<br/>{ttsSwapInfo.note}{/if}
</Notification>
<Space h="md" />
{:else if ttsSwapInfo && !ttsSwapInfo.fell_back}
<Notification
title="TTS переключён на {ttsSwapInfo.applied.toUpperCase()}"
color="teal"
withCloseButton={true}
on:close={() => { ttsSwapInfo = null }}
>
Применено сразу — без перезапуска.
</Notification>
<Space h="md" />
{/if}
<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
@ -754,39 +495,6 @@
<Space h="sm" />
<Button
color="grape"
radius="md"
size="sm"
uppercase
fullSize
on:click={() => $goto("/wake-trainer")}
>
Обучить wake-word (записать свой голос)
</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"

View file

@ -1,334 +0,0 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte"
import { invoke } from "@tauri-apps/api/core"
import { goto } from "@roxi/routify"
import HDivider from "@/components/elements/HDivider.svelte"
import Footer from "@/components/Footer.svelte"
import {
Button, Space, Text, Notification, Badge, Loader, TextInput, NumberInput,
} from "@svelteuidev/core"
import { CrossCircled, SpeakerLoud, Trash, Check, Resume } from "radix-icons-svelte"
interface TrainerStatus {
recording: boolean
session_name: string | null
target_samples: number
collected: number
}
interface TrainedModel {
name: string
path: string
size_bytes: number
modified: number
}
let status: TrainerStatus = { recording: false, session_name: null, target_samples: 10, collected: 0 }
let models: TrainedModel[] = []
let loading = true
let error = ""
let info = ""
let busy = false
let newName = ""
let targetSamples = 10
let sampleSeconds = 1.5
let threshold = 0.5
let savedModelPath = ""
let daemonRunning = false
async function load() {
loading = true
error = ""
try {
const [s, m, run] = await Promise.all([
invoke<TrainerStatus>("wake_trainer_status"),
invoke<TrainedModel[]>("wake_trainer_list_models"),
invoke<boolean>("is_jarvis_app_running"),
])
status = s
models = [...m].sort((a, b) => a.name.localeCompare(b.name))
daemonRunning = run
if (!status.recording) {
targetSamples = status.target_samples || 10
}
} catch (e) {
error = String(e)
} finally {
loading = false
}
}
async function startSession() {
const name = newName.trim()
if (!name) { error = "Введите имя модели"; return }
if (daemonRunning) {
error = "Сначала остановите jarvis-app — он держит микрофон."
return
}
busy = true
try {
await invoke("wake_trainer_start", { name, targetSamples })
await load()
info = `Сессия запущена. Сэмплов нужно: ${targetSamples}.`
} catch (e) { error = String(e) }
finally { busy = false }
}
async function recordOne() {
busy = true
try {
await invoke("wake_trainer_record_sample", { seconds: sampleSeconds })
await load()
info = `Записано: ${status.collected} / ${status.target_samples}`
} catch (e) { error = String(e) }
finally { busy = false }
}
async function finish() {
busy = true
try {
const path = await invoke<string>("wake_trainer_finish", { threshold })
savedModelPath = path
info = `Модель сохранена: ${path}. Перейдите в Настройки и выберите её, потом перезапустите Jarvis.`
await load()
} catch (e) { error = String(e) }
finally { busy = false }
}
async function cancel() {
busy = true
try {
await invoke("wake_trainer_cancel")
await load()
info = "Сессия отменена."
} catch (e) { error = String(e) }
finally { busy = false }
}
async function deleteModel(name: string) {
if (!confirm(`Удалить модель "${name}" и её сэмплы?`)) return
try {
await invoke("wake_trainer_delete_model", { name })
await load()
} catch (e) { error = String(e) }
}
let poll: ReturnType<typeof setInterval> | null = null
onMount(() => {
load()
poll = setInterval(load, 3000)
})
onDestroy(() => {
if (poll !== null) clearInterval(poll)
})
function fmtSize(n: number): string {
if (n < 1024) return `${n} B`
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
return `${(n / 1024 / 1024).toFixed(2)} MB`
}
function fmtDate(ts: number): string {
if (!ts) return "—"
return new Date(ts * 1000).toLocaleString()
}
</script>
<Space h="xl" />
<h2 class="page-title">Своё кодовое слово</h2>
<Text size="sm" color="gray">
Запишите 530 примеров своего голоса, и Jarvis обучит персональный
Rustpotter-детектор. Файл сохраняется как
<code>%APPDATA%\com.priler.jarvis\wake_words\&lt;имя&gt;.rpw</code>.
Активная модель выбирается в Настройках.
</Text>
<Space h="md" />
{#if daemonRunning}
<Notification title="Jarvis запущен" color="orange" withCloseButton={false}>
Остановите jarvis-app (через трей или кнопку «Стоп») — иначе микрофон
будет занят и запись не пойдёт.
</Notification>
<Space h="sm" />
{/if}
{#if info}
<Notification title="Готово" color="teal" icon={Check} on:close={() => { info = "" }}>
{info}
</Notification>
<Space h="sm" />
{/if}
{#if error}
<Notification title="Ошибка" color="red" icon={CrossCircled} on:close={() => { error = "" }}>
{error}
</Notification>
<Space h="sm" />
{/if}
{#if loading}
<Loader />
{:else if status.recording}
<div class="session-card">
<div class="row spaced">
<div>
<strong>Запись:</strong>
<code>{status.session_name}</code>
</div>
<Badge color="lime" variant="filled">
{status.collected} / {status.target_samples}
</Badge>
</div>
<Space h="sm" />
<Text size="xs" color="gray">
Произнеси своё кодовое слово (например, «Джарвис») чётко, по очереди,
одинаково. Длина каждого сэмпла — {sampleSeconds.toFixed(1)} сек.
</Text>
<Space h="sm" />
<div class="row">
<Text size="xs">Длина сэмпла: {sampleSeconds.toFixed(1)} сек</Text>
<NumberInput min={0.8} max={3.0} step={0.1} bind:value={sampleSeconds} disabled={busy} />
</div>
<Space h="sm" />
<Button color="lime" radius="md" size="sm" disabled={busy} on:click={recordOne}>
<SpeakerLoud size={14} /> &nbsp;
{busy ? "Запись..." : `Записать сэмпл (${status.collected + 1})`}
</Button>
&nbsp;
<Button color="gray" radius="md" size="sm" disabled={busy} on:click={cancel}>
Отменить
</Button>
{#if status.collected >= 5}
<Space h="md" />
<div class="row">
<Text size="xs">Порог детектора: {threshold.toFixed(2)}</Text>
<NumberInput min={0.30} max={0.90} step={0.05} bind:value={threshold} disabled={busy} />
</div>
<Text size="xs" color="gray">
Ниже — больше срабатываний (и ложных). Выше — пропусков больше.
</Text>
<Space h="sm" />
<Button color="teal" radius="md" size="sm" disabled={busy} on:click={finish}>
<Check size={14} /> &nbsp; Обучить и сохранить
</Button>
{/if}
</div>
{:else}
<div class="form-card">
<TextInput
label="Имя модели"
description="Только латиница / кириллица + цифры. Пробелы заменятся на _"
placeholder="например: my_jarvis"
bind:value={newName}
disabled={busy}
/>
<Space h="sm" />
<NumberInput
label="Сколько сэмплов записать"
description="5 минимум, 1015 рекомендовано"
min={5}
max={30}
bind:value={targetSamples}
disabled={busy}
/>
<Space h="sm" />
<Button color="lime" radius="md" size="sm" disabled={busy || daemonRunning} on:click={startSession}>
<Resume size={14} /> &nbsp; Начать запись
</Button>
</div>
{/if}
<Space h="xl" />
<h3 class="section-title">Обученные модели</h3>
{#if models.length === 0}
<Text size="sm" color="gray">Моделей пока нет — обучите первую выше.</Text>
{:else}
<div class="model-list">
{#each models as m}
<div class="model-card">
<div class="row spaced">
<strong>{m.name}</strong>
<Badge color="gray" variant="filled" size="sm">{fmtSize(m.size_bytes)}</Badge>
</div>
<div class="model-path">{m.path}</div>
<Text size="xs" color="gray">обновлено: {fmtDate(m.modified)}</Text>
<Space h="xs" />
<Button color="red" radius="md" size="xs" uppercase on:click={() => deleteModel(m.name)}>
<Trash size={12} /> &nbsp; Удалить
</Button>
</div>
{/each}
</div>
{/if}
<Space h="xl" />
<Button color="gray" radius="md" size="sm" uppercase fullSize on:click={() => $goto("/")}>
Назад
</Button>
<HDivider />
<Footer />
<style lang="scss">
.page-title {
margin: 0 0 4px 0;
color: #fff;
font-size: 1.3rem;
font-weight: 600;
}
.section-title {
color: rgba(255, 255, 255, 0.85);
font-size: 1.05rem;
margin: 0 0 0.5rem 0;
}
code {
background: rgba(255, 255, 255, 0.08);
padding: 1px 4px;
border-radius: 3px;
font-size: 0.8rem;
}
.row {
display: flex;
align-items: center;
gap: 0.75rem;
}
.row.spaced {
justify-content: space-between;
}
.session-card, .form-card, .model-card {
background: rgba(30, 40, 45, 0.75);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 10px;
padding: 0.85rem 1rem;
}
.model-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.model-path {
color: rgba(255, 255, 255, 0.4);
font-size: 0.7rem;
font-family: ui-monospace, "Cascadia Mono", monospace;
word-break: break-all;
margin: 0.2rem 0;
}
</style>

View file

@ -8,7 +8,6 @@ export {
lastRecognizedText,
lastExecutedCommand,
lastError,
daemonHealth,
connectIpc,
enableIpc,
disableIpc,
@ -17,15 +16,9 @@ export {
sendIpcMessage,
sendTextCommand,
stopJarvisApp,
reloadCommands,
switchDaemonLlm,
reloadDaemonLlm,
switchDaemonTts,
queryDaemonHealth,
reloadCommands
} from "./lib/ipc"
export type { DaemonHealth } from "./lib/ipc"
// re-export i18n
export {
translations,
@ -46,21 +39,12 @@ export const jarvisCpuUsage = writable(0)
export const assistantVoice = writable("")
// ### APP INFO
// Hardcoded fallbacks so the UI keeps working when the Rust side has not
// been rebuilt yet (the new get_upstream_link / get_issues_link / get_python_fork_link
// Tauri commands only exist after the next cargo build).
const BRANDING_FALLBACK = {
repositoryLink: "https://github.com/DmitryBykov-ISPO/J.A.R.V.I.S-rust",
upstreamLink: "https://github.com/Priler/jarvis",
issuesLink: "https://github.com/DmitryBykov-ISPO/J.A.R.V.I.S-rust/issues",
pythonForkLink: "https://github.com/DmitryBykov-ISPO/J.A.R.V.I.S-py",
}
export const appInfo = writable({
repositoryLink: BRANDING_FALLBACK.repositoryLink,
upstreamLink: BRANDING_FALLBACK.upstreamLink,
issuesLink: BRANDING_FALLBACK.issuesLink,
pythonForkLink: BRANDING_FALLBACK.pythonForkLink,
tgOfficialLink: "",
feedbackLink: "",
repositoryLink: "",
boostySupportLink: "",
patreonSupportLink: "",
logFilePath: ""
})
@ -75,38 +59,27 @@ export async function loadVoiceSetting() {
}
export async function loadAppInfo() {
const fetchOr = async (name: string, fallback: string): Promise<string> => {
try {
const v = await invoke<string>(name)
return v && v !== "error" ? v : fallback
} catch {
return fallback
}
}
// The old binary's get_repository_link returns Priler's upstream — override
// so we never display the upstream repo as if it were the current fork.
const fetchRepoOr = async (fallback: string): Promise<string> => {
const v = await fetchOr("get_repository_link", fallback)
if (/priler\/jarvis/i.test(v)) return fallback
return v
}
const [repo, upstream, issues, pythonFork, logPath] = await Promise.all([
fetchRepoOr(BRANDING_FALLBACK.repositoryLink),
fetchOr("get_upstream_link", BRANDING_FALLBACK.upstreamLink),
fetchOr("get_issues_link", BRANDING_FALLBACK.issuesLink),
fetchOr("get_python_fork_link", BRANDING_FALLBACK.pythonForkLink),
fetchOr("get_log_file_path", "")
const [tg, feedback, repo, boosty, patreon, logPath] = await Promise.all([
invoke<string>("get_tg_official_link"),
invoke<string>("get_feedback_link"),
invoke<string>("get_repository_link"),
invoke<string>("get_boosty_link"),
invoke<string>("get_patreon_link"),
invoke<string>("get_log_file_path")
])
appInfo.set({
tgOfficialLink: tg,
feedbackLink: feedback,
repositoryLink: repo,
upstreamLink: upstream,
issuesLink: issues,
pythonForkLink: pythonFork,
boostySupportLink: boosty,
patreonSupportLink: patreon,
logFilePath: logPath
})
} catch (err) {
console.error("failed to load app info:", err)
}
}
export async function updateJarvisStats() {

View file

@ -1,8 +0,0 @@
from PIL import Image
src = r"C:\Jarvis\rust\resources\icons\icon.png"
dst = r"C:\Jarvis\rust\resources\icons\icon.ico"
img = Image.open(src).convert("RGBA")
sizes = [(16, 16), (24, 24), (32, 32), (48, 48), (64, 64), (128, 128), (256, 256)]
img.save(dst, format="ICO", sizes=sizes)
print(f"wrote {dst} from {src} ({img.size})")

View file

@ -1,191 +0,0 @@
[[commands]]
id = "open_browser"
type = "lua"
script = "open_browser.lua"
sandbox = "full"
timeout = 5000
[commands.phrases]
ru = [
"открой браузер",
"запусти браузер",
"открой хром",
"запусти хром",
"включи браузер",
"браузер открой",
"нужен браузер",
]
en = [
"open browser",
"launch browser",
"open chrome",
"launch chrome",
"start browser",
"i need browser",
]
[[commands]]
id = "open_notepad"
type = "lua"
script = "open_notepad.lua"
sandbox = "full"
timeout = 5000
[commands.phrases]
ru = [
"открой блокнот",
"запусти блокнот",
"открой нотепад",
]
en = [
"open notepad",
"launch notepad",
]
[[commands]]
id = "open_calculator"
type = "lua"
script = "open_calculator.lua"
sandbox = "full"
timeout = 5000
[commands.phrases]
ru = [
"открой калькулятор",
"запусти калькулятор",
"нужен калькулятор",
]
en = [
"open calculator",
"launch calculator",
"calculator please",
]
[[commands]]
id = "open_explorer"
type = "lua"
script = "open_explorer.lua"
sandbox = "full"
timeout = 5000
[commands.phrases]
ru = [
"открой проводник",
"открой папку",
"запусти проводник",
"открой файлы",
]
en = [
"open explorer",
"open file explorer",
"open files",
"open folder",
]
[[commands]]
id = "open_terminal"
type = "lua"
script = "open_terminal.lua"
sandbox = "full"
timeout = 5000
[commands.phrases]
ru = [
"открой терминал",
"запусти терминал",
"открой консоль",
"запусти консоль",
"открой повершелл",
]
en = [
"open terminal",
"open powershell",
"open console",
"launch terminal",
]
[[commands]]
id = "open_settings"
type = "lua"
script = "open_settings.lua"
sandbox = "full"
timeout = 5000
[commands.phrases]
ru = [
"открой настройки",
"открой параметры",
"запусти настройки",
"открой настройки виндоус",
]
en = [
"open settings",
"launch settings",
"windows settings",
]
[[commands]]
id = "open_task_manager"
type = "lua"
script = "open_task_manager.lua"
sandbox = "full"
timeout = 5000
[commands.phrases]
ru = [
"открой диспетчер задач",
"запусти диспетчер задач",
"диспетчер задач",
]
en = [
"open task manager",
"launch task manager",
"task manager",
]
[[commands]]
id = "lock_screen"
type = "lua"
script = "lock_screen.lua"
sandbox = "full"
timeout = 3000
[commands.phrases]
ru = [
"заблокируй компьютер",
"заблокируй экран",
"блокировка экрана",
"залочь",
]
en = [
"lock screen",
"lock the computer",
"lock pc",
]
[[commands]]
id = "screenshot"
type = "lua"
script = "screenshot.lua"
sandbox = "full"
timeout = 5000
[commands.phrases]
ru = [
"сделай скриншот",
"скриншот",
"сними экран",
]
en = [
"take screenshot",
"screenshot",
"capture screen",
]

View file

@ -1,8 +0,0 @@
local res = jarvis.system.exec("rundll32.exe user32.dll,LockWorkStation")
if res.success then
jarvis.audio.play_ok()
else
jarvis.log("error", "lock screen failed: " .. tostring(res.stderr))
jarvis.audio.play_error()
end
return { chain = false }

View file

@ -1,9 +0,0 @@
local cmd = "start \"\" \"https://www.google.com\""
local res = jarvis.system.exec(cmd)
if res.success then
jarvis.audio.play_ok()
else
jarvis.log("error", "open browser failed: " .. tostring(res.stderr))
jarvis.audio.play_error()
end
return { chain = false }

Some files were not shown because too many files have changed in this diff Show more