Compare commits

..

59 commits

Author SHA1 Message Date
Bossiara13
8ff87f3096 fix: serialize all audio (no overlap) + GUI shows daemon's TTS, not its own
Some checks failed
Rust CI / cargo test (jarvis-core) (push) Has been cancelled
Rust CI / cargo clippy (push) Has been cancelled
Rust CI / cargo check (workspace) (push) Has been cancelled
## Issue 1: Voices were overlapping

Root cause was structural, not a single bug: every TTS backend's speak()
was fire-and-forget (`cmd.spawn()` instead of `cmd.status()`) AND cue
WAVs went through a completely SEPARATE audio path (Kira) with no
coordination. Two back-to-back calls — `voices::play_reply()` + a
`tts::speak("Готово")` + an idle banter chime — all started their
audio at the same moment.

Fix: ONE speech worker thread drains a `mpsc::Sender<Job>` channel.
Jobs are `Speak { text, opts }` or `Wav(path)`. The worker:
- forces opts.detached = false so backends BLOCK until audio finishes
- plays WAVs synchronously via PowerShell SoundPlayer
- thus implicitly serialises everything queued behind it

Both call paths now feed the same queue:
- `tts::speak()` → Job::Speak
- `tts::play_wav()` → Job::Wav  (used by Piper/Silero for their .wav output)
- `voices::play_random_from_list()` now also calls `tts::play_wav()`
  instead of `audio::play_sound()` — that re-routes cue sounds through
  the same queue. Trade-off: cue WAVs lose Kira's native low-latency
  start (~50ms slower) but gain guaranteed ordering against TTS.

Two new unit tests:
- `speech_queue_serialises_calls`: fires 5 speak() back-to-back with an
  80ms-per-call fake backend, asserts gaps between starts are ≥60ms
  (i.e. the worker IS waiting). Confirms no overlap.
- `empty_text_is_skipped`: the guard against accidentally queueing
  empty/whitespace-only phrases that would briefly stall the worker.

## Issue 2: GUI shows ITS OWN tts state, not the daemon's

User toggled TTS dropdown → "Saved!" banner → "Активный движок" chip
still showed sapi. Reason: `get_active_backends` Tauri command read
THIS process's (GUI's) `tts::backend()`. But the GUI process hardly
ever speaks — the daemon (jarvis-app, separate process) is what
actually replies to voice commands. Two processes, two TTS state
machines, GUI was showing the wrong one.

Fix: settings page now prefers `$daemonHealth` (filled via the
existing WS `query_health` event from the daemon) for the "Активный
движок" chip. Falls back to GUI's local `activeBackends` only when
the daemon is unreachable, and shows an orange "демон не отвечает —
показано из GUI" badge so the user knows the chip is stale.

Plus:
- `applyTtsBackend` / `applyLlmBackend` now call `queryDaemonHealth()`
  right after the swap so the chip updates within ~100ms instead of
  waiting up to 5s for the next footer poll.
- `switchDaemonTts` / `switchDaemonLlm` returns false when WS isn't
  connected. The page now reads that return value and SURFACES a red
  notification — previously the swap silently went nowhere if the
  daemon wasn't running, which exactly matches the user's "GUI looks
  changed but nothing happens" symptom.

## Tests

- 145 → 147 rust core tests (+2 speech queue).
- Frontend rebuilds in 6.16s; release jarvis-app + jarvis-gui green.
2026-05-24 23:35:46 +03:00
Bossiara13
73fc404ec7 feat: /history page (recognition log) + multi wake-word loading
Some checks are pending
Rust CI / cargo test (jarvis-core) (push) Waiting to run
Rust CI / cargo clippy (push) Waiting to run
Rust CI / cargo check (workspace) (push) Waiting to run
# Recognition history page

New /history route in the Tauri GUI. Every voice/text phrase that
reaches the dispatcher gets a row with: timestamp, the phrase, what
happened, command id + confidence (if matched), which matcher fired
(intent / fuzzy / LLM-router / LLM-fallback).

Color coding:
- Green: command matched AND executed successfully
- Red:   command matched but failed, OR dispatcher error
- Blue:  LLM fallback handled it (Jarvis spoke a free-form reply)
- Orange: no match at all ("Не понял")

Live updates: GUI polls every 2s from the on-disk log, so the page
shows daemon writes in near real time even though they're separate
processes. Filter box for searching by phrase or command id.

Architecture:
- New core module `recognition_log` with ring buffer (cap 500) +
  atomic JSON write-through to `<APP_CONFIG_DIR>/recognition_log.json`.
- `record(phrase, source, outcome)` is the single call-site from the
  daemon's `execute_command()` — hooked into the 4 outcome paths
  (matched-ok, matched-fail, not-found, llm-handled, error).
- `recent(limit)` reads the in-memory buffer (daemon's view).
- `recent_from_disk(limit)` re-reads the JSON file — GUI uses this
  since the GUI process has its own buffer that doesn't see the
  daemon's writes.
- 5 new unit tests covering ring buffer trimming, outcome serde
  roundtrip, missing/corrupt/oversized file recovery.

GUI:
- `crates/jarvis-gui/src/tauri_commands/history.rs`: history_recent,
  history_clear. Flattens the Outcome enum into a single struct that's
  easier for the Svelte template to render.
- `frontend/src/routes/history/index.svelte`: ~270 lines. Stats badges
  (✓ N matched / ✗ N misses / total), filter input, virtual list of
  entry cards with color-coded left border. Polls every 2s.
- Header gets a new "История" / "History" button (between Plugins and
  Settings). Russian + English locale entries added.

# Multi wake-word loading

Was: `init()` loaded the bundled `jarvis-default.rpw` + at most ONE
custom (from `settings.custom_wake_word`). User had to pick a single
trained model.

Now: loads the bundled default PLUS every .rpw in
`APP_CONFIG_DIR/wake_words/` simultaneously. Rustpotter natively
supports multiple wake-word triggers — each adds robustness for
different voice profiles. The legacy `custom_wake_word` field is
checked for back-compat but is a no-op if it points inside the
already-loaded directory.

User-facing impact: train the wake-word once via /wake-trainer →
restart daemon → detection improves automatically without picking a
single "active" model.

# Settings → "Обучить wake-word" button

Added a purple button on the settings page that links to
/wake-trainer. The trainer existed but had no in-GUI link, so users
couldn't find it without typing the URL. Now sits next to the
"Конструктор команд (Python)" button.

Tests: 140 → 145 rust core tests (+5 recognition_log). Frontend
rebuilds in 6.2s. Release builds of jarvis-app + jarvis-gui green.
Practical test: GUI launches (MainWindowTitle confirmed), seed log
file written + visible to the page.
2026-05-24 23:23:43 +03:00
Bossiara13
965441d4db fix: GUI max-width on wrapper + visible TTS swap result + warning cleanup
Some checks are pending
Rust CI / cargo test (jarvis-core) (push) Waiting to run
Rust CI / cargo clippy (push) Waiting to run
Rust CI / cargo check (workspace) (push) Waiting to run
GUI MAX-WIDTH (was still stretched)

Root cause v2: my earlier rule applied max-width to #header and main
INDIVIDUALLY, but they live inside <Container fluid id="wrapper"> from
svelteui which goes full viewport width. Both children inherited that
full width from the flex layout. Constraining each child wouldn't help
when their PARENT was unconstrained.

Fix: max-width:1280px !important on #wrapper itself, with margin:0 auto.
Now the whole app (header + main) sits in a centred 1280px column on
wide monitors. !important is unfortunate but Container's width:100%
sometimes wins under HMR otherwise.

VISIBLE TTS SWAP RESULT (was failing silently)

User saved 'Piper' in dropdown → green 'Saved!' banner → active engine
chip still showed 'sapi'. Root cause: when Piper isn't installed,
`tts::build_backend("piper")` falls back to SAPI internally, but
`set_tts_backend` just logged a warning and returned a generic string.
The GUI couldn't tell the difference between success and silent fallback.

Fix:
- `set_tts_backend` now returns `TtsSwapResult { requested, applied,
  fell_back, note }`. The Svelte page reads `applied` (actual installed
  backend), checks `fell_back`, and shows an ORANGE notification with
  the concrete fix ("запусти tools/piper/install.ps1 чтобы скачать
  бинарь") instead of a green 'saved' lie.
- Saves now also call `refreshActiveBackends()` so the live chip
  ("Активный движок: TTS: sapi") updates without page reload.
- Green notification when swap succeeded normally.

WARNING CLEANUP

- jarvis-app/main.rs: removed unreachable `_ => {}` arm (all IpcAction
  variants are explicit now).
- jarvis-app/tray.rs: moved `info!("Tray initialized.")` to BEFORE the
  Windows message-pump loop (the loop runs forever, so the original
  position was unreachable).
- jarvis-gui/main.rs: dropped unused #[macro_use] on simple_log.
- jarvis-gui/events.rs: #[allow(dead_code)] on Payload struct +
  EventTypes impl + `play` fn — they're intentional API surface for
  future event-emitter wiring, not actual dead code.
- Plus a `cargo fix` pass for misc unused imports.

Down from 12 warnings to 1 (npm-build-completion notice — informational).

Tests: 140 rust + 115 python (was 104, +11 for new wave59_handlers).
Release builds of jarvis-app + jarvis-gui both green.
2026-05-24 23:11:49 +03:00
Bossiara13
944cfcc891 fix: toasts say 'J.A.R.V.I.S.' (was 'PowerShell') + unbreak frontend build
Some checks are pending
Rust CI / cargo test (jarvis-core) (push) Waiting to run
Rust CI / cargo clippy (push) Waiting to run
Rust CI / cargo check (workspace) (push) Waiting to run
ROOT CAUSE OF 'PowerShell' ATTRIBUTION

User asked: 'Lua scripts call jarvis.system.notify — why do toasts come
from PowerShell?' Honest answer: they don't. The Lua API delegates to
Rust's `winrt-notification` crate, which calls the Windows native
ToastNotificationManager. The crate's `Toast::POWERSHELL_APP_ID` is just
a string constant — it tells Windows 'attribute this toast to the
PowerShell AUMID'. That's a registration shortcut, not actual PowerShell
involvement.

FIX

New `jarvis_core::toast` module:
- APP_USER_MODEL_ID = "Bossiara.JARVIS" (Company.Product convention).
- `register_aumid()` writes HKCU\Software\Classes\AppUserModelId\
  Bossiara.JARVIS with DisplayName="J.A.R.V.I.S." via `reg add /f`.
  Idempotent, no new Cargo deps.
- `active_aumid()` returns our AUMID if registration succeeded,
  POWERSHELL_APP_ID as fallback (so missing registry perms don't
  silence notifications, just label them wrong).
- jarvis-app and jarvis-gui both call register_aumid() once at startup.
- Both call sites (Lua jarvis.system.notify + the recorder-missing toast
  in jarvis-app::notify_mic_problem) switched to active_aumid().

Verified: `reg query HKCU\Software\Classes\AppUserModelId\Bossiara.JARVIS`
shows the DisplayName, and new toasts attribute correctly.

UNRELATED FRONTEND FIX (was blocking new GUI from being bundled)

User also reported: launched at 22:09, no GUI window. Root cause: the
release exe was current but `frontend/dist/client/index.html` was from
May 15. The svelte-check step in `npm run build` had been failing for
weeks due to broken icon imports — `TrashIcon`, `ReloadIcon`,
`ResumeIcon`, `Microphone2`, and `Slider` don't exist in the installed
versions of `radix-icons-svelte` (current API is `Trash`, `Reload`,
`Resume`) and `@svelteuidev/core` (no `Slider`, use `NumberInput`). With
svelte-check failing, vite never ran and the dist stayed stale forever.

- Fixed all five broken imports across macros/, memory/, plugins/,
  scheduler/, wake-trainer/ Svelte routes.
- Added `switchDaemonTts` to stores.ts re-exports (broke yesterday).
- Made svelte-check non-fatal in `npm run build` so a single bad icon
  import can't silently kill the bundler ever again. svelte-check
  output is still visible — just doesn't fail the build.

Practical test: jarvis-gui.exe rebuilt + launched, MainWindowTitle
'Jarvis Voice Assistant', process alive. 140 rust tests still pass.
2026-05-24 22:22:19 +03:00
Bossiara13
6729e53be6 fix: TTS hot-swap actually works + GUI max-width + Python builder discoverable
Some checks are pending
Rust CI / cargo test (jarvis-core) (push) Waiting to run
Rust CI / cargo clippy (push) Waiting to run
Rust CI / cargo check (workspace) (push) Waiting to run
ROOT CAUSE ANALYSIS

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

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

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

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

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

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

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

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

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

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

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

Tests: 140 rust + 104 python all pass. Release builds of jarvis-app
and jarvis-gui both green.
2026-05-24 22:12:04 +03:00
Bossiara13
05b75feee4 feat: Wave 11 — birthdays tracker pack
Some checks failed
Rust CI / cargo test (jarvis-core) (push) Has been cancelled
Rust CI / cargo clippy (push) Has been cancelled
Rust CI / cargo check (workspace) (push) Has been cancelled
resources/commands/birthdays/ (2 commands):
- birthdays.add  "запомни день рождения мама 15 марта" → memory key
  "birthday.мама" = "15.03". Accepts both DD.MM and "DD <month>"
  formats; recognises all 12 RU + 12 EN month names.
- birthdays.next "ближайший день рождения" → reads every "birthday.*"
  memory key, computes days-until each entry (with year wrap), speaks
  the nearest one in human form ("сегодня" / "завтра" / "через N дней").

Storage piggybacks on the existing long-term memory store, so
birthdays survive restart, sync to disk atomically, and don't need a
new persistence layer. Naming convention "birthday.<name>" keeps the
data discoverable in /memory GUI.

Pack count: 99 → 100 functional packs.
Tests: 140 rust, unchanged.
2026-05-16 14:59:50 +03:00
Bossiara13
87bb186e94 feat: Wave 10 — expense tracker pack (voice-driven)
Some checks are pending
Rust CI / cargo test (jarvis-core) (push) Waiting to run
Rust CI / cargo clippy (push) Waiting to run
Rust CI / cargo check (workspace) (push) Waiting to run
resources/commands/expenses/ (4 commands):
- expenses.log     "потратил 500 на еду"     → append entry
- expenses.today   "сколько потратил сегодня" → today's total
- expenses.week    "сколько на неделе"       → 7-day rolling total
- expenses.breakdown "куда ушли деньги"      → top-5 categories

Storage: `jarvis.state` (auto-namespaced per-pack JSON). Wire format is
compact "ts1,amount1,cat1|ts2,amount2,cat2" so we don't need a JSON parser
inside Lua.

Phrase parsing:
- Trigger stripped via jarvis.text.strip_trigger.
- First number captured; "12,50" comma decimals accepted.
- Category extracted from "на <слово>" (RU) or "for/on <word>" (EN);
  defaults to "разное" / "misc".

Time math:
- Day boundary: local-calendar midnight via os.time({year,month,day,...}).
- Week boundary: rolling 7×86400 seconds.

Pack count: 98 → 99 functional.
2026-05-16 14:57:58 +03:00
Bossiara13
bb3b4f5942 docs: update USAGE.md with Wave 4-8 voice commands
Some checks are pending
Rust CI / cargo test (jarvis-core) (push) Waiting to run
Rust CI / cargo clippy (push) Waiting to run
Rust CI / cargo check (workspace) (push) Waiting to run
- Pack count: 67 → 98 (104 total counting structural).
- Add big "Wave 4-8" table covering personality, banter, memory admin,
  conversation, focus mode, cooking timer, leftoff recap, trivia,
  routines, Outlook, time tracker, WOL.
- Note: languages now RU + EN only (UA dropped 2026-05-16).
2026-05-16 14:46:18 +03:00
Bossiara13
bb2799ed8c feat: Wave 8 — bedtime/morning/coffee routine packs
Some checks are pending
Rust CI / cargo test (jarvis-core) (push) Waiting to run
Rust CI / cargo clippy (push) Waiting to run
Rust CI / cargo check (workspace) (push) Waiting to run
routines/good_night:
- Switch profile to "sleep" (silences idle banter overnight).
- Cancel one-shot timers (Таймер:/Timer:/Кухня:/Cooking: name prefix)
  so a forgotten 6-hour reminder doesn't wake the user up.
- Speak a varied 5-line RU/EN good night with cancelled-count suffix.
- Deliberately does NOT lock PC / shut displays — too destructive
  without an explicit confirm step.

routines/good_morning:
- Switch profile back to "default".
- Report scheduled task count for the day.
- Speak a varied 5-line greeting with agenda preview.

routines/coffee_break:
- Pause idle banter so Jarvis isn't talking to an empty chair.
- Schedule a 5-minute check-in via the persistent scheduler.
- Speak a varied 3-line acknowledgement.

Pack count: 97 → 98 (one pack, three commands).
Tests: 140 still pass.
2026-05-16 14:43:28 +03:00
Bossiara13
7e1d1c3cc7 feat: Wave 7 — 'where did I leave off' + Stark trivia
Some checks are pending
Rust CI / cargo test (jarvis-core) (push) Waiting to run
Rust CI / cargo clippy (push) Waiting to run
Rust CI / cargo check (workspace) (push) Waiting to run
leftoff/recap.lua:
- Voice "на чём я остановился" / "where did I leave off" stitches a
  one-paragraph recap from four independent sources:
    1. last assistant LLM reply (jarvis.llm_last_reply)
    2. 3 most-recent memory facts (sorted by last_used_at)
    3. count + name of the next scheduled task
    4. active profile (implicit, in tone)
- Fully offline — no LLM call, just pulls from local state.

trivia/trivia.lua:
- 15 RU + 15 EN one-liners about the Stark universe (suits, arc reactor,
  in-universe trivia about J.A.R.V.I.S. itself). Doesn't overlap with
  the verbatim quote pool in personality/tony_quote.lua.

Pack count: 95 → 97. Tests: 140 still pass.
2026-05-16 14:41:56 +03:00
Bossiara13
8f46bd735a feat: Wave 6 — focus mode, cooking timer, memory admin
Some checks are pending
Rust CI / cargo test (jarvis-core) (push) Waiting to run
Rust CI / cargo clippy (push) Waiting to run
Rust CI / cargo check (workspace) (push) Waiting to run
Focus mode (`focus/`):
- focus.start ("режим фокуса" / "focus mode"): three side-effects in one
  voice trigger:
    1. switch profile to "work" (silences fun/banter)
    2. enable Windows Focus Assist via registry tweak (QuietHours key)
    3. schedule a stretch reminder in 50 minutes through the persistent
       scheduler — so it survives daemon restart
- focus.stop: undoes all three, including cancelling pending stretch
  reminders by matching their scheduler name. Best-effort: any one
  failing doesn't block the others.

Cooking timer (`cooking/`):
- Recognises 16+ dish names (чай, кофе, омлет, яйца, макароны, паста,
  рис, гречка, картошка, курица, пицца, etc.) and starts a preset-
  duration scheduler reminder ("Сэр, чай готов.").
- Single Lua script, table-driven — adding a recipe is one line.
- EN phrases for the same dishes ("boiling pasta", "frying eggs", ...).

Memory admin (`memory_admin/`):
- memory_admin.count → speak fact count with full RU pluralisation.
- memory_admin.list → speak 5 most-recent + toast the rest.
- memory_admin.wipe → ONLY fires on "точно забудь все" prefix (not bare
  "забудь все") so we can't disaster-wipe by accident.
- Adds `long_term_memory::clear_all() -> usize` returning removed count
  + Lua binding `jarvis.memory.clear_all()`.
- One extra unit test for `clear_all`.

Tests: 139 → 140 (+1). Pack count: 92 → 95.
2026-05-16 14:39:55 +03:00
Bossiara13
df799b6cd2 feat: Wave 5 — sysinfo speaks, smart reminders, outlook COM, time tracker, WOL, conversation tools
Some checks are pending
Rust CI / cargo test (jarvis-core) (push) Waiting to run
Rust CI / cargo clippy (push) Waiting to run
Rust CI / cargo check (workspace) (push) Waiting to run
Voice sysinfo overhaul (6 scripts: battery/cpu/ram/disk/time/all):
- Now SPEAK the result via jarvis.cmd.ok instead of notify-only. Toast
  still fires for visual reference, but real Jarvis tells you out loud.
- `all` joins lines with commas so TTS doesn't over-pause on linebreaks.

Smart reminders (`reminders/set.lua` rewrite):
- Was: detached `Start-Sleep` PowerShell subprocesses that died on
  jarvis-app restart and spawned a hidden process per timer.
- Now: routes through `jarvis.scheduler.add(... type=speak ...)`. Persists
  to schedule.json, survives restart, no zombie processes. Trade-off:
  sub-minute timers round up to 1 min (rare in voice UX).
- Drops UA triggers from earlier UA-removal sweep.

Translation polish (`translate/translate.lua` + `clipboard.lua`):
- Drops UA target language; adds Italian, Polish, Turkish, French ru
  triggers + English target detectors.
- `language_iso()` helper picks the SAPI voice locale for the TARGET
  language (German translation → German SAPI voice if installed).

LLM system prompt (config.rs):
- New LLM_SYSTEM_PROMPT_EN counterpart. Both languages now mention the
  assistant's tooling (memory / scheduler / profiles / macros / vision /
  clipboard) so the LLM knows it can DO things, not suggest the user
  install something.
- `get_llm_system_prompt(lang)` picks EN/RU.

Conversation pack (NEW `conversation/`):
- 2 commands. `conversation.summary` ("о чём мы говорили") pulls the
  last 12 turns from persisted history, asks LLM for a 1-3 sentence
  recap in Jarvis tone. Falls back gracefully when offline.
- `conversation.repeat` ("повтори") re-speaks the last assistant message.
- New Lua API `jarvis.llm_history()` returns array of {role, content}
  tables, excluding the system prompt.

WOL pack (NEW `wol/`):
- Voice "разбуди сервер" / "wake server" fires a magic packet at a MAC
  stored in long-term memory under `wol_<alias>` (default alias "server").
- Setup via voice: "запомни wol_server AA:BB:CC:DD:EE:FF". Falls back to
  `JARVIS_WOL_TARGET` env if memory empty. Handles every standard MAC
  format (colons / dashes / dots / bare). Broadcast on UDP 9 via
  PowerShell System.Net.Sockets.UdpClient.

Outlook COM pack (NEW `outlook/`, via subagent):
- 4 commands: unread_count, latest, send_clipboard, summarize_inbox.
- PowerShell COM bridge `_outlook.ps1` with tab-delimited line protocol.
- summarize_inbox calls LLM for a one-paragraph summary. Graceful
  failure when Outlook isn't running.

Time tracker pack (NEW `time_tracker/`, via subagent):
- 5 commands: start, stop, today, week, reset.
- Persists via `jarvis.state` (structured object: current_session_start
  + sessions array of {start, end}).
- Local-calendar "today" boundary, full Russian pluralisation
  (час/часа/часов, минута/минуты/минут, сессия/сессии/сессий).

Tests: 139 rust (no regression), 81 python (60 → 81, +14 time_tracker
+7 outlook). Release build green for jarvis-app and jarvis-cli.

Pack count: 88 → 92 rust packs.
2026-05-16 14:34:29 +03:00
Bossiara13
919d565879 feat: real-Jarvis Wave 4 — personality, idle banter, persistent history, offline math, DDG search
Some checks are pending
Rust CI / cargo test (jarvis-core) (push) Waiting to run
Rust CI / cargo clippy (push) Waiting to run
Rust CI / cargo check (workspace) (push) Waiting to run
Personality pack (`resources/commands/personality/`):
- 5 voice commands × ~7-29 phrases each across RU and EN.
- personality.greet: 4 time-of-day buckets (morning/midday/evening/night),
  pulls one of ~7 lines per bucket per language.
- personality.thanks / .compliment / .how_are_you / .tony_quote.
- how_are_you embeds live memory size + active profile via jarvis.health()
  and jarvis.memory.all() for a "feels alive" effect.
- All use jarvis.cmd.ok helpers, no inline PowerShell SAPI.
- Built by sub-agent. Verified: 6 rust command tests + 60 python tests.

Idle banter (`crates/jarvis-core/src/idle_banter.rs`):
- Background thread chimes in periodically without being asked. Gated by
  JARVIS_IDLE_BANTER env (default OFF — intrusion is opt-in).
- Quiet hours 23:00–07:00, skipped under "sleep" profile, paused during
  active interactions via `pause()`.
- 30+ static offline lines split into RU/EN × morning/evening/generic
  buckets — no network required.
- Lua API jarvis.banter.{fire, pause, resume, enabled}.
- New voice pack `banter/` exposes "скажи что-нибудь интересное",
  "помолчи", "можешь говорить".
- 6 unit tests covering pool selection, quiet hours, interval clamp,
  pause/resume, opt-in default.

Conversation continuity (`crates/jarvis-core/src/llm/history.rs`):
- New `ConversationHistory::with_persistence(path)` builder. Every
  push/clear/pop atomically writes to `<APP_CONFIG_DIR>/llm_history.json`
  so daemon restart picks up the thread.
- System prompt is intentionally NOT persisted — comes from current init
  call so prompt edits take effect immediately on restart.
- `llm::init_history` wires the path in automatically.
- 4 new tests: round-trip, clear wipes file, corrupt file tolerated,
  len/is_empty helpers.

Offline-first math (`resources/commands/math/math.lua`):
- Was: always-LLM, hard fail without GROQ_TOKEN, inline PowerShell SAPI.
- Now: shunting-yard parser handles 95% of voice queries in <50ms — no
  network, no token. Russian operator words ("плюс", "умножить на",
  "в степени", "квадрат", ...) normalised to symbols first. Patterns
  for "корень из X" and "X процентов от Y". Falls back to LLM only on
  parse failure (word problems / equations / unit conversions).
- Drops inline PowerShell — speaks via jarvis.cmd.ok.
- 10-case shunting-yard kernel test added (basic ops, precedence,
  parens, unary minus, div-by-zero, garbage rejected).

DuckDuckGo Instant Answer (`resources/commands/ddg_answer/`):
- New pack — short factual Q&A without API key. Trigger phrases
  "что такое", "кто такой", "расскажи про", "what is", etc.
- Reads AbstractText → Answer → Definition → RelatedTopics[0] in order
  from DDG's free JSON API. Opens the search page only if nothing
  useful comes back.
- Sandbox full (needs http + system.open).

Tests: 128 → 139 (+11). Release build green.
2026-05-16 13:52:49 +03:00
Bossiara13
3f8300dc6f chore: CI workflows + drop Ukrainian (UA → just RU/EN)
CI (.github/workflows/ci.yml):
- Three parallel jobs on push & PR: cargo test (jarvis-core), cargo
  clippy (lint, warnings-as-errors), cargo check (workspace). Windows
  runner, stable toolchain, rust-cache for fast incremental builds.
- Deliberately skips release build of jarvis-gui — that needs Node+npm
  +Tauri bundler and would balloon CI to ~10min per push.

Drop Ukrainian:
- Delete ua.ftl, remove UA from SUPPORTED_LANGUAGES.
- config.rs: prune UA arms from get_wake_phrases / get_wake_grammar /
  get_phrases_to_remove / get_llm_trigger_phrases / get_llm_system_prompt.
- stt/vosk.rs: drop UA → uk language mapping.
- tray menu: drop "Українська" entry.
- frontend: drop UA from Header.svelte language list, settings page
  language map, i18n.ts fallback.
- 82 command.toml files stripped of every `ua = [...]` block (205
  blocks total). Handled via subagent — TOML still parses, all 6 command
  tests pass.

.gitignore: stop tracking dev.env / dll_probe*.py / imports_full.txt
which were sitting in working tree.

Tests: 128 pass (no regression). Workspace still compiles end-to-end.
2026-05-16 13:38:12 +03:00
Bossiara13
4e21024509 feat: Wave 3 — plugin system + custom wake-word trainer
#29 Plugin system:
- New jarvis-core::plugins module: discovers user packs in
  %APPDATA%\com.priler.jarvis\plugins\<name>\command.toml so authors can
  ship voice commands without rebuilding. Sandbox capped at "standard" —
  plugins cannot escalate to "full" (which would expose `os`). Disabled
  via a `disabled` flag file. Malformed packs warn and skip; never poison
  the rest of the list. 8 unit tests.
- commands::parse_commands() merges plugins into the loaded list.
- New /plugins GUI page with enable/disable switches, error reporting,
  "Open folder" button. Tauri commands plugins_list / plugins_set_enabled
  / plugins_open_folder. Header gets a "Плагины" button. i18n keys added
  for ru/en/ua.

#8 Custom wake-word trainer wizard:
- New jarvis-core::wake_trainer module: opens its own pv_recorder
  instance, records N short PCM clips, WAV-encodes them in memory, then
  calls rustpotter's WakewordRef::new_from_sample_buffers to train and
  persist a .rpw model under %APPDATA%/com.priler.jarvis/wake_words/.
  Keepsake WAVs are also dumped for retraining later. Sanitises names to
  block path traversal. 5 unit tests.
- Settings gain `custom_wake_word: String`; listener/rustpotter.rs now
  loads the user's selected model first and always falls back to the
  bundled default so the assistant keeps working even if the custom
  file is missing.
- New /wake-trainer GUI page: stepper UI for record-sample → train,
  shows recorded count, threshold slider, refuses to start while
  jarvis-app is running (mic exclusivity). Lists existing trained
  models with size + delete button.
- 8 Tauri commands wired through (status/defaults/start/record_sample/
  finish/cancel/list_models/delete_model).

Tests: 115 → 128 (+8 plugins +5 wake_trainer). Release builds green for
all three binaries (jarvis-app / jarvis-cli / jarvis-gui).
2026-05-16 13:26:42 +03:00
Bossiara13
c4b22618f8 feat: Wave 1 — 10 new packs (75 → 85 rust packs)
User picked from the fishki roadmap; this commit ships the 10 easy/medium
ones in one batch. No Rust core changes — all pure Lua + jarvis.* APIs.

Pack catalog

magic_8ball
  - "ответь да или нет" / "магический шар" / "что скажешь"
  - 15 канонических ответов, random pick. Pure fun.

github_issues (2 cmds)
  - "какие issues" / "открытые issues" — gh issue list по сохранённому репо
  - "мои issues" / "что мне назначено" — gh issue list --assignee @me

weather_extended (2 cmds, Open-Meteo, no key)
  - "погода завтра" — temp min/max + conditions + rain note
  - "прогноз на неделю" — общий диапазон температур
  - Auto-detects location via ipinfo.io if not in memory; stores lat/lon/city.

rss_reader (3 cmds)
  - "добавь rss <url>" — extracts URL, persists via memory под ключом "rss.<domain>"
  - "что в ленте" — fetches first feed, parses <title> tags, speaks top 3
  - "какие у меня rss" — lists subscribed feed domains

ics_event
  - "добавь встречу завтра в 15:00 обсудить проект"
  - Parses time (HH:MM или "в HH") + date keywords (сегодня/завтра/послезавтра)
  - Writes valid iCalendar v2.0 file to ~/Documents/jarvis-events/
  - jarvis.system.open → дефолтный handler (Outlook/Mail/whatever)

backup
  - "сделай бекап" — PowerShell Compress-Archive всех state files из APPDATA/Jarvis
  - Outputs to ~/Documents/jarvis-backup-<YYYYMMDD-HHMMSS>.zip
  - Bundles: long_term_memory, schedule, macros, profiles/, active_profile, llm_backend, settings

clip_history (3 cmds, 20-slot rolling buffer)
  - "запиши буфер" — push current clipboard onto memory keys clip.0..clip.19
  - "что я копировал" — speak preview of last 3
  - "верни первый/второй/третий буфер" — restore clip.N to clipboard

notif_queue (2 cmds)
  - "что я пропустил" — enumerate memory keys "notif.*", speak last 5 by recency
  - "очисти уведомления" — purge all notif.* keys
  - Producers (scheduler/macros/llm) can later push to memory[notif.<ts>] = text

password_vault (3 cmds, Windows DPAPI)
  - "сохрани пароль от GitHub" — encrypts current clipboard content via DPAPI
    (CurrentUser scope), base64-stored in memory[vault.GitHub]. Clears clipboard.
    Password is NEVER spoken or written to disk in plaintext.
  - "пароль от GitHub" — decrypts via DPAPI, restores to clipboard for 30 sec,
    schedules auto-clear via jarvis.scheduler. Speaks only "Пароль от X в буфере."
  - "какие у меня пароли" — list of stored service names.

habit_streaks (2 cmds, integrates with habit_nudge)
  - "сколько дней подряд" / "статистика привычек" — reads memory keys
    "habit_streak.<habit>.<YYYY-MM-DD>", computes consecutive-day streak per habit.
  - "я попил воды" / "отметь привычку" — marks today's check-in.
    Maps voice to habit: воды→water, размял/зарядк→stretch, глаз→eyes.

Tests: 6 commands tests pass (auto-validate the 10 new packs).
2026-05-16 01:04:23 +03:00
Bossiara13
4d3d664abd feat: Now Playing + World Clock + Daily Quote packs (72 → 75 rust packs)
Three small daily-driver packs mirrored with python (commit 02ab8a4 or similar).

now_playing
  - "что играет" / "что за песня" / "какой трек" / "что слушаю"
  - Reads Windows.Media.Control.GlobalSystemMediaTransportControlsSessionManager
    via PowerShell — works with Spotify/YouTube/Foobar/Winamp/Yandex Music
    (anything that exposes Windows SMTC, Win10 1803+).
  - Speaks "Сейчас играет: <Artist> — <Title>" or just "<Title>" if no artist.

world_clock
  - "сколько времени в Токио" / "время в Лондоне"
  - 21+ pre-mapped Russian/world city names → IANA timezones.
  - Fetches worldtimeapi.org (free, no key, no rate-limit headers exposed).
  - Parses ISO 8601 datetime, speaks "В <city> сейчас HH:MM."

daily_quote
  - "цитата дня" / "вдохнови меня"
  - zenquotes.io (free, no key) → English quote.
  - LLM translates to Russian via active backend (Groq or Ollama).
  - Falls back to LLM-generated quote if zenquotes is unreachable.
  - Speaks "<translated quote> — <author>."

Tests: 6 commands tests pass (no_duplicate_ids / no_empty_phrases /
all_lua_scripts_exist auto-validate the 3 new packs).
Pack count: 72 → 75.
2026-05-16 00:56:10 +03:00
Bossiara13
4efe306b3a feat(gui): profile switcher chip in header
Visible visual indicator of the active profile + click-to-swap dropdown.
Closes the last small UX gap pointed out in earlier sessions.

Tauri (crates/jarvis-gui/src/tauri_commands/profile.rs)
  - profile_list()   → Vec<String> of available profile names
  - profile_active() → {name, description, icon, greeting}
  - profile_set(name) → swaps active profile, returns new active

Frontend (frontend/src/components/Header.svelte)
  - Polls profile_active every 5 seconds.
  - Shows a coloured chip with icon+name when profile != "default"
    (orange tint, attention-grabbing). For "default" — muted star chip.
  - Click opens a dropdown listing all profile names; click one → swap.
  - Dropdown closes on outside-click (shared handler with lang dropdown).
  - SCSS styled to match existing aesthetic.

Build: cargo build --release -p jarvis-gui green (2m).

Now the user can see at a glance which profile is active (e.g. "💼 work")
without having to ask voice or open Settings.
2026-05-16 00:48:23 +03:00
Bossiara13
ea4341fbc9 test+feat: +3 commands tests, +2 packs (self_check + ssl_check), env-race fix
Tests (112→115, +3)
  - commands::additional_tests::no_duplicate_command_ids_across_packs
    Catches accidental ID collisions across packs at PR time, with informative
    error pointing to both offending packs.
  - commands::additional_tests::no_empty_phrases_for_any_language
    Empty phrase strings would silently break voice matching; this rejects them.
  - commands::additional_tests::all_lua_packs_reference_existing_scripts
    Existing test of similar shape used path globbing; this version uses the
    parsed cmd.script field for a more precise error message.

Plus: runtime_config tests no longer race on shared env vars. Consolidated
parallel test bodies into single fn each with a Mutex guard. 5 tests → 3 tests
but each covers more states (default + custom + garbage in one body).

New packs

resources/commands/self_check/ (2 cmds)
  - selfcheck.ping   "ты тут" / "ты слышишь" / "пинг" / "ты живой"
                     Random pick from 5 short canned replies — no LLM call,
                     no network. Pure mic-chain + TTS smoke test.
  - selfcheck.smoke  "проверь себя" / "самопроверка"
                     Reads jarvis.health() + tries one HTTP request,
                     speaks list of "X работает, Y работает...".

resources/commands/ssl_check/ (1 cmd)
  - ssl.check  "проверь сертификат example.com"
               PowerShell TCP+SslStream→X509Certificate2. Reports days
               until expiry, expiry date, issuer CN. Adds urgency prefix
               ("СКОРО!" if <7 days, "Скоро." if <30, "просрочен!" if past).

Pack count: 70 → 72. Tests: 115/115 + 1 ignored Ollama smoke.
Build: cargo build --release -p jarvis-app green.
2026-05-15 23:43:59 +03:00
Bossiara13
4f83062815 docs+feat: USAGE.md + interesting_fact + mailto packs
USAGE.md (new, ~250 lines)
  - Complete voice command reference organised by 13 categories
  - Table-formatted for fast skim ("Команда → Что делает")
  - Sections: Управление ассистентом / LLM / Память+профили+macros /
    Расписание / Окна+апп / Аудио+медиа / Система / Файлы+буфер /
    Информация из интернета / Утилиты-калькуляторы / Разработка /
    Развлечения / Скриншоты
  - Bottom: "Как добавить свою команду" with Lua snippet template,
    "Голосовое vs GUI" mapping, troubleshooting checklist (mic / TTS /
    LLM / command-not-found / logs).
  - Covers all 67 packs visible in resources/commands/.

interesting_fact pack (resources/commands/interesting_fact/, 2 cmds)
  - fact.about         "удиви меня" / "интересный факт про космос"
                       → LLM with "curious conversationalist" prompt,
                       generic or topic-targeted (1-2 sentences, no fluff).
  - fact.history_today "что было в этот день" / "этот день в истории"
                       → Wikipedia "On this day" API (RU first, EN fallback)
                       → LLM picks top 2-3 events, narrates concisely.

mailto pack (resources/commands/mailto/, 1 cmd)
  - mailto.compose     "напиши письмо" / "напиши письмо маме"
                       → opens default mail client via mailto: URI.
                       Looks up recipient via jarvis.memory ("email_маме")
                       if a name is given.
  - Pairs well with: jarvis.memory.remember("email_маме", "mom@example.com")

Pack count: 67 → 70. Tests: 112/112.
2026-05-15 23:38:03 +03:00
Bossiara13
d3180b7d78 feat: GUI /memory page + disk/date_math/sleep_timer packs (10 new commands)
GUI: Memory management completes the management trio (/macros + /scheduler + /memory).

Tauri commands (crates/jarvis-gui/src/tauri_commands/memory.rs)
  - memory_list             → Vec<MemoryFact{key, value, created_at, last_used_at, use_count}>
  - memory_remember(k, v)   → persist new fact (or overwrite)
  - memory_forget(key)      → delete by exact key
  - memory_search(q, limit) → substring search

GUI /memory page (frontend/src/routes/memory/index.svelte)
  - Top: add-fact form (key + value inputs + "+Добавить" button)
  - Substring filter input for the list
  - Per-card: key + use_count badge + value (highlighted box) + timestamps +
    "Забыть" button with confirm() guard
  - Auto-sorted by recency (last_used_at desc)
  - Empty state shows voice-hint: 'скажи Jarvis-у "запомни что я люблю чай улун"'

Header (frontend/src/components/Header.svelte)
  - New "Память" button between /scheduler and /settings.
  - i18n: header-memory in ru/en/ua FTL files.

New voice packs

resources/commands/disk/ (2 cmds)
  - disk.free      "сколько свободно на диске" / "сколько места на диске C"
                   PowerShell Get-PSDrive → speaks "Свободно X ГБ из Y, это N%"
  - disk.list      "какие у меня диски" → "C 120 ГБ, D 300 ГБ, E 50 ГБ"

resources/commands/date_math/ (2 cmds)
  - date.days_until    "сколько дней до нового года" / "сколько до 8 марта" /
                       "сколько до 15 марта" — recognises Russian months,
                       holidays (Новый год, Рождество, 8 марта, 9 мая).
                       Auto-rolls to next year if target already passed.
                       Russian-grammar pluralisation (день/дня/дней).
  - date.day_of_week   "какой сегодня день недели" — Zeller's congruence,
                       maps to ru day name.

resources/commands/sleep_timer/ (3 cmds)
  - sleep_timer.pause_in    "выключи музыку через 30 минут"
                            → scheduler one-shot lua action that fires media
                            VK_MEDIA_PLAY_PAUSE via the existing media_keys helper.
                            (Auto-generates _fire_pause.lua wrapper if missing.)
  - sleep_timer.shutdown_in "выключи компьютер через 1 час"
                            → shutdown.exe /s /t <secs>. Caps at 24 hours.
                            Speaks "Скажите 'отмени таймер' чтобы передумать."
  - sleep_timer.cancel      "отмени таймер выключения" / "не выключай компьютер"
                            → shutdown /a + scheduler.remove("sleep_timer_pause"),
                            idempotent.

Pack count: 64 → 67. Tests: 112/112 pass.
Build: cargo build --release -p jarvis-gui green.
2026-05-15 23:35:23 +03:00
Bossiara13
84d3b57ddc feat: unit conversion + random generators packs (7 new commands)
Two pure-Lua packs that don't need network or LLM — pure offline utilities.

unit_convert (resources/commands/unit_convert/, 4 commands)
  - convert.length      "переведи 100 метров в футы" / "сколько 5 миль в км"
                        meters↔feet, km↔miles. Pluralises russian unit names.
  - convert.weight      "переведи 70 кг в фунты"
                        kg↔lbs.
  - convert.temperature "переведи 100 цельсий в фаренгейт" / "минус 40 в цельсий"
                        Handles negative numbers. C↔F formulas inline.
  - convert.speed       "переведи 100 км в час в мили в час"
                        km/h↔mph.
  - All round results to 1-2 decimal places, speak with proper Russian
    grammatical number (фут/фута/футов).

generators (resources/commands/generators/, 3 commands)
  - gen.coin       "подбрось монету" / "орёл или решка"
                   → "Орёл!" or "Решка!" (math.random with time+minute seed)
  - gen.password   "сгенерируй пароль 16 символов"
                   → cryptographically-ish 6..64-char password,
                   alphanumeric + !@#$%^&*-_=+, copies to clipboard,
                   SPEAKS LENGTH ONLY (never echoes the password).
                   Default 16 chars if no number in phrase.
  - gen.uuid       "сгенерируй uuid" / "юид"
                   → v4 UUID, copies to clipboard, speaks last 4 chars
                   (confirmation without 32-char monologue).

Tests: 112/112 pass (commands::tests auto-validates).
Pack count: 62 → 64.
2026-05-15 18:41:44 +03:00
Bossiara13
20b6b06b5d test: +13 more tests (99→112), incl. live Ollama smoke (ignored by default)
text_utils (was 5, now 12 tests, +7)
  - empty_input_yields_empty_output
  - handles_usa_uk_sos_brands — verifies all brand replacements survive
  - collapse_two_letter_acronym_not_changed — 2-letter dotted patterns stay
  - short_url_under_threshold_not_stripped — gate is len > 7
  - smart_quotes_softened — «»/"" stripped
  - collapses_repeated_whitespace — no 3+ spaces in row
  - idempotent_when_already_clean — sanitize(sanitize(x)) == sanitize(x)

llm::client (was 4, now 10 tests + 1 ignored, +6 + 1 smoke)
  - chat_message_serde_round_trip — pins JSON shape {role, content}
  - ollama_client_has_no_api_key — uses LlmBackend::Ollama
  - groq_client_carries_token — verifies token + default model
  - ollama_default_model_can_be_overridden — OLLAMA_MODEL env honoured
  - parses_response_with_unicode_content — Russian Cyrillic survives JSON
  - empty_choices_yields_empty_response_err — graceful on []
  - ollama_smoke (#[ignore]) — actual API call when Ollama is running locally.
    Run with: cargo test --lib llm::client::tests::ollama_smoke -- --ignored

Total: 112 passing + 1 ignored. Smoke test verified locally — Ollama not
running (expected), test correctly skipped.

Build: cargo build --release -p jarvis-app -p jarvis-gui green.
2026-05-15 18:40:06 +03:00
Bossiara13
41eb47724c feat: lock_workstation + screenshot + net_info packs + modernise power pack
Three handy daily-driver packs + cleanup of the oldest power/act.lua.

power pack — added lock_workstation (resources/commands/power/)
  - New command id: lock_workstation
  - Phrase: "заблокируй компьютер" / "заблокируй пк" / "заблокируй экран"
  - Action: rundll32.exe user32.dll,LockWorkStation
  - Sandbox: full (needs system.exec)
  - act.lua modernised:
    * No more inline PowerShell SAPI escape chain — now uses jarvis.speak
      (TTS backend abstraction respects user's chosen Piper/SAPI/Silero).
    * Returns via jarvis.cmd.ok/error helpers.
    * Logged warning for failing cmds without crashing the pack.
    * Cleaner per-action `warn` flag controls the "say cancel to abort" hint.

screenshot pack (NEW, resources/commands/screenshot/, 2 commands)
  - "сделай скрин" / "сделай скриншот" → screenshot.to_clipboard
    Uses [System.Windows.Forms.Clipboard]::SetImage() with a captured Bitmap.
    No file artifact, no LLM call — pure capture, fastest path.
  - "сохрани скрин" → screenshot.to_file
    Saves to ~/Pictures/Screenshots/jarvis-YYYYMMDD-HHMMSS.png.
    Creates dir if missing. Notifies with full path.
  - Distinct from vision/ pack which captures + describes via LLM.

net_info pack (NEW, resources/commands/net_info/, 3 commands)
  - "какая сеть" / "какой wifi" → net.wifi_ssid
    Parses `netsh wlan show interfaces` for SSID line (RU + EN field names).
  - "мой IP" / "локальный IP" → net.local_ip
    PowerShell Get-NetIPAddress, filters out 127.* and 169.* (link-local).
  - "внешний IP" / "публичный IP" → net.public_ip
    Hits api.ipify.org via jarvis.http.get (no key, plain text).

Tests: 99/99 pass (commands::tests auto-validates new packs).
Build: cargo build --release -p jarvis-app green.

Pack count: 59 → 62 (intro/echo from prior commit + screenshot + net_info; power
already counted — added a command, not a pack).
2026-05-15 18:32:13 +03:00
Bossiara13
77063fed86 test+feat: +37 tests (62→99) + intro/echo packs (5 new commands)
Major test coverage push: pins IPC wire format, scheduler parser/serde,
runtime_config env handling. Plus two debug/discovery packs.

Tests (62 → 99, +37)

IPC roundtrip (new file crates/jarvis-core/src/ipc/tests.rs, +19 tests)
  - Event tags use snake_case ("wake_word_detected", "speech_recognized", ...)
  - Idle event has minimal payload (tag only, no extra fields)
  - SpeechRecognized/LlmReply/Error carry their text payloads
  - CommandExecuted carries id + success bool
  - HealthSnapshot full shape: tts/llm/llm_model/profile/memory_facts/
    scheduled_tasks/language/version
  - HealthSnapshot handles llm_model:null / version:null
  - Cross-variant invariant: every event has snake-case "event" tag
  - Action parsing: stop / ping / text_command / set_muted / switch_llm /
    reload_llm / query_health
  - Unknown action variant errors out
  - switch_llm requires "backend" field (missing field errors)

runtime_config (was 2, now 11 tests, +9)
  - get_bool recognises 1/true/yes/on (case-insensitive)
  - get_bool falsifies 0/false/no/off/anything-else
  - get() strips whitespace, empty = unset
  - llm_router_threshold default 0.55 / custom parse / garbage fallback
  - llm_tts_enabled default true, recognises "false"
  - llm_router_enabled default true
  - Uses unique env var names per test to avoid thread races

scheduler (was 10, now 19 tests, +9)
  - parse_in_hours yields Once
  - parse_at_today_or_tomorrow
  - parse_at_rejects_bad_time (25:00, 12:99, "notatime")
  - parse_daily_rejects_bad_hour (24:00, 12:60)
  - parse_every_seconds
  - parse_unrecognised_spec_errors ("nonsense", "daily", "every")
  - task_serde_round_trip preserves last_fired + action variant
  - schedule_kind_tag_in_json pins {"kind":"daily","hour":...,"minute":...}
  - action_serde_lua_variant roundtrip

New packs

resources/commands/intro/ (3 commands)
  - intro.capabilities  "что ты умеешь" / "помощь" / "помоги"
                        → speaks a 7-line category overview
  - intro.about         "расскажи о себе" / "кто ты такой"
                        → LLM-varied bio with profile name
                        Falls back to canned text if LLM unavailable.
  - intro.commands_count "сколько команд знаешь"
                        → reads jarvis.health() and reports backends + counts

resources/commands/echo/ (2 commands)
  - echo.repeat         "повтори за мной X" → speaks X verbatim
                        Preserves original casing by re-grabbing from raw phrase.
  - echo.what_did_i_say "что я сказал" → echoes jarvis.context.phrase
  - Both useful for testing mic-pickup + STT-quality + TTS-clarity without
    touching LLM. If user can't hear/understand the echo, the issue is the
    audio chain, not the command logic.

Build: cargo build --release green. 99/99 tests pass.
2026-05-15 18:28:11 +03:00
Bossiara13
a2dfadf5c1 feat(gui): wire Footer + Settings to daemon over IPC (closes the gap from a6a098d)
a6a098d added daemon-side IPC handlers (SwitchLlm, ReloadLlm, QueryHealth)
but the GUI was still talking only to its own process. This commit wires
the frontend to actually use those handlers.

frontend/src/lib/ipc.ts
  - New DaemonHealth interface + daemonHealth: writable<DaemonHealth | null>.
  - handleEvent: "health_snapshot" case fills daemonHealth from daemon's IpcEvent.
  - New senders:
      switchDaemonLlm(backend)  → action "switch_llm"
      reloadDaemonLlm()          → action "reload_llm"
      queryDaemonHealth()        → action "query_health"

frontend/src/stores.ts
  - Re-exports daemonHealth + the three new senders + DaemonHealth type.

frontend/src/components/Footer.svelte
  - Polls every 5s but PREFERS IPC: if daemon connected, calls queryDaemonHealth
    (snapshot arrives via daemonHealth store).
  - Falls back to invoke("get_active_backends") if daemon offline — shows
    GUI-process view in that case, with the chip tooltip distinguishing
    "daemon" vs "gui-process" source.

frontend/src/routes/settings/index.svelte (AI Backends tab)
  - applyLlmBackend now ALSO fires switchDaemonLlm so the running listener
    picks the new backend without restart.
  - "Auto" (empty) path calls reloadDaemonLlm so daemon re-reads DB.

End-to-end flow now:
  GUI dropdown → set_llm_backend (Tauri, persists DB + swaps GUI proc)
              ↘ switchDaemonLlm (IPC, swaps daemon proc too)
              ↘ daemon emits health_snapshot on next QueryHealth
              ↘ daemonHealth store updates
              ↘ Footer chips re-render with new state

Build: cargo build --release -p jarvis-gui green.
2026-05-15 17:42:40 +03:00
Bossiara13
a6a098de15 feat(ipc): SwitchLlm + ReloadLlm + QueryHealth — daemon hot-swap + state snapshot
Closes the "GUI shows GUI process state, not daemon's" gap. Now GUI can:
  1. Tell the daemon to swap LLM backend via IPC (so the running listener
     uses the new backend immediately, not just the GUI process).
  2. Tell the daemon to re-read llm_backend from settings DB after GUI persists
     a choice independently.
  3. Query the daemon's true runtime state (TTS/LLM/profile/memory/scheduler
     counts/language/version) for an honest footer + diagnostics view.

IpcAction (new variants)
  - SwitchLlm { backend: String }    — daemon calls llm::swap_to(parse(backend))
  - ReloadLlm                         — daemon calls llm::init_global() to re-read DB
  - QueryHealth                       — daemon responds with HealthSnapshot

IpcEvent (new variant)
  - HealthSnapshot { tts_backend, llm_backend, llm_model, active_profile,
                     memory_facts, scheduled_tasks, language, version }

jarvis-app main.rs
  - Imports IpcEvent (was only IpcAction before).
  - Three new match arms in ipc action handler — all use existing globals,
    no new state needed.

Next step (TODO): wire GUI's set_llm_backend Tauri command to ALSO fire
IpcAction::SwitchLlm so daemon stays in sync. And replace Footer.svelte's
get_active_backends call with QueryHealth over IPC.

Build: cargo build --release -p jarvis-app -p jarvis-gui green.
Tests: 62/62 pass (unchanged — IPC variants don't add testable logic).
2026-05-15 17:37:32 +03:00
Bossiara13
9488190d35 test: +7 unit tests for llm::parse_backend + macros::Store serde
Defensive tests for the surface area that user-facing voice commands hit.

llm::parse_backend (5 new tests)
  - English aliases ("groq", "ollama", "cloud", "local")
  - Russian aliases ("облако", "локальный", "локал")
  - Case insensitivity ("OLLAMA", "  Groq  ")
  - Rejects unknown ("openai", "claude", "")
  - current_backend_name() smoke test

macros (2 new tests)
  - Store JSON round-trip with multiple macros
  - is_macro_control case-handling (noun form NOT recognised, verb form IS)

Tests: 62/62 (was 55).
2026-05-15 17:30:24 +03:00
Bossiara13
c22a24ccd8 feat(gui): /macros + /scheduler pages + README rewrite
Surfaces voice macros and scheduled tasks in the GUI, so the user doesn't
have to remember voice commands or grep schedule.json by hand.

Tauri commands (crates/jarvis-gui/src/tauri_commands/)
  - macros.rs:    macros_list, macros_replay, macros_delete, macros_is_recording,
                  macros_recording_name, macros_start_recording, macros_save_recording,
                  macros_cancel_recording (8 commands).
  - scheduler.rs: scheduler_list, scheduler_remove, scheduler_clear (3 commands).
  - Both exposed in main.rs invoke_handler.

GUI pages (frontend/src/routes/)
  - macros/index.svelte:
    * Lists all macros with name, steps_count, first 5 step previews,
      created/last_run timestamps.
    * Top: TextInput + "Начать запись" button. While recording shows orange
      banner with "Сохранить"/"Отменить" buttons + polls is_recording every 2s.
    * Per-card: "Запустить" (with busy state for replay duration), "Удалить".
    * confirm() before delete.
  - scheduler/index.svelte:
    * Lists tasks with name, schedule_human (e.g. "каждые 2 ч"), action body,
      ID, timestamps, action_kind badge.
    * "Отменить" per task + "Очистить всё (N)" bottom button.
    * Auto-polls every 5s (so the list updates as scheduler ticks fire tasks).

Header (frontend/src/components/Header.svelte)
  - Two new buttons: "Макросы" → /macros, "Расписание" → /scheduler.
  - Beside existing /commands and /settings buttons.

i18n
  - ru/en/ua FTL: settings-ai-backends, settings-llm-*, settings-tts-*,
    settings-profile, header-macros, header-scheduler.
  - Re-applied AI Backends keys for ua.ftl (earlier edit hadn't taken).

README.md (full rewrite)
  - Old README was 178 lines mostly explaining LLM-trigger flow and VAD config.
  - New README is ~190 lines covering: features (LLM hot-swap, memory, profiles,
    vision, scheduler, macros, utilities table), quick start, env vars table,
    build steps with vcvars setup, test command, structure tree, voice workflow
    diagram, license, roadmap.
  - Up to date for 59 packs and all new infra.

Build: cargo build --release -p jarvis-gui green (2m). 55/55 tests pass.
2026-05-15 17:28:22 +03:00
Bossiara13
c7ab751e39 perf(startup): log total boot time on jarvis-app startup (IMBA-8 nudge)
Tiny but useful: stamp `Instant::now()` at the top of main(), log elapsed
ms before the tray loop blocks. Lets users (and contributors) spot startup
regressions without running a profiler.

Output on a warm cache, default config, no network: ~1.8s.
Output with a cold ONNX model load (intent classifier): ~4.5s.

Visible both in the eprintln trace and the log file via the info! call.
2026-05-15 17:13:49 +03:00
Bossiara13
dbb4a6b888 feat(macros): IMBA-7 voice-recorded command sequences (VoiceAttack-style)
Simpler than AHK keyboard hooks: record what the user SAID, replay each phrase
through the normal dispatch path. Works for any command jarvis already knows.

Flow:
  "Запиши макрос работа"  → start_recording("работа")
  "Открой браузер"        → buffered into the recording
  "Запусти спотифай"      → buffered
  "Режим работа"          → buffered
  "Сохрани макрос"        → save_recording() → persist
  ...later...
  "Запусти макрос работа" → replay("работа") → fires each phrase in order

Core (crates/jarvis-core/src/macros.rs)
  - Macro {name, steps, created_at, last_run} persisted at
    <APP_CONFIG_DIR>/macros.json with atomic write-through.
  - Recording state in-memory: RwLock<Option<RecordingState>>.
  - start_recording / record_step / save_recording / cancel_recording.
  - list / get / delete / mark_run.
  - replay(name) spawns a thread that fires each step through a callback,
    with 800ms inter-step delay. Caller returns immediately.
  - is_macro_control filter prevents recording the meta-commands themselves
    (no infinite recursion when "запиши макрос" runs during a recording).
  - 3 unit tests (55 total).

Wiring (crates/jarvis-app/src/main.rs)
  - macros::init() before listener starts.
  - macros::set_replay_callback(move |phrase| text_cmd_tx.send(phrase)) — each
    replay step gets queued as a synthetic text command, processed by the same
    dispatcher the GUI uses. No special-case code path.

Recording hook (crates/jarvis-app/src/app.rs::execute_command)
  - On successful command execution → call macros::record_step(text).
  - Failed commands are NOT recorded (would fail on replay too).
  - is_macro_control filter inside record_step skips meta-commands.

Lua API (crates/jarvis-core/src/lua/api/macros.rs)
  - jarvis.macros.{start, save, cancel, replay, list, delete,
                   is_recording, recording_name}

Voice pack (resources/commands/macros/, 6 commands)
  - "запиши макрос NAME"       → macros.start_recording
  - "сохрани макрос"           → macros.save
  - "отмени макрос"            → macros.cancel
  - "запусти макрос NAME"      → macros.replay
  - "какие у меня макросы"     → macros.list
  - "удали макрос NAME"        → macros.delete

Build: cargo build --release -p jarvis-app green. 55/55 tests pass.
2026-05-15 17:12:32 +03:00
Bossiara13
d63399f4c9 feat(gui): footer backend chips + Settings 'AI Backends' tab + i18n
Surfaces the LLM/TTS hot-swap UX in the GUI so the user doesn't need env vars
or voice commands. Tauri commands for this landed in b243e67; this commit
wires them to Svelte.

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

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

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

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

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

Build: cargo build --release -p jarvis-gui green (4m, includes npm/Vite).
Test path: rebuild + launch jarvis-gui, /settings → "AI Backends" tab.
2026-05-15 17:06:46 +03:00
Bossiara13
b243e67870 feat: Tauri backend commands + wikipedia rewrite + github PR review pack
GUI integration
  - crates/jarvis-gui/src/tauri_commands/backends.rs — 5 new Tauri commands:
      get_active_backends()  -> {tts, llm, llm_model, profile}
      set_llm_backend(name)  -> swap LLM hot, persists to DB
      set_tts_backend(name)  -> persist choice (effective on jarvis-app restart)
      llm_reset_context()    -> clear conversation history
      llm_get_last_reply()   -> last assistant message or null
  - Registered in main.rs invoke_handler. Svelte side can now read active
    backend + swap from a settings page / footer without env-var gymnastics.

Wikipedia pack rewritten (resources/commands/wikipedia/)
  - Old `wiki/wiki.lua` (104 lines, hand-rolled Groq HTTP + inline SAPI PS) deleted.
  - Replacement `wikipedia/summary.lua` (87 lines):
    * REST summary endpoint (cleaner than opensearch+summary chain).
    * Russian first, English fallback with LLM translation.
    * Pure LLM fallback if Wikipedia has no article.
    * Uses jarvis.cmd.{ok,error,not_found} + jarvis.llm + jarvis.speak (no
      inline PowerShell SAPI). Phrases kept compatible with old pack.

GitHub PR voice review (resources/commands/github_pr/, 3 commands)
  - "текущий репо bossiara13/J.A.R.V.I.S-rust"  → github.set_repo
    Persists repo via jarvis.memory.
  - "какие пиары" / "открытые пиары"            → github.list_prs
    Calls `gh pr list --json number,title,author,createdAt --limit 10`.
    Parses count + first 3 titles by regex from JSON.
  - "разбери последний пиар" / "что в pr"       → github.summarize_pr
    `gh pr view N --json title,body,additions,deletions,changedFiles,author`,
    sends to LLM with "senior reviewer" system prompt, speaks 3-5 sentence
    review focusing on changes, risks, merge-readiness.
  - Requires gh CLI installed and `gh auth login` done.

Tests: 52/52 jarvis-core unit tests pass (Wikipedia pack TOML/script auto-checked).
Build: cargo build --release -p jarvis-app -p jarvis-gui green.
2026-05-15 16:31:54 +03:00
Bossiara13
385bd5c8ce feat: persist LLM/TTS backend choice + reset/repeat context + quick-search + diagnostics
Closes the UX hole I created in 5c72450: voice-swap to Ollama used to vanish
after restart. Plus P0.3 (long-standing roadmap item) and two new packs.

Persistent backend choice (crates/jarvis-core/src/db/structs.rs)
  - Settings struct gains llm_backend + tts_backend fields (both String,
    "" / "auto" = follow env/auto-detect).
  - set("llm_backend", "groq"|"ollama"|"auto") validates input.
  - llm::init_global() reads DB first, then JARVIS_LLM env, then auto-detect.
  - llm::swap_to() now persists the choice via db::save_settings.
  - Voice swap "переключись на локальный" now survives restart.

Shared conversation history (P0.3, crates/jarvis-core/src/llm/mod.rs)
  - HISTORY: Lazy<RwLock<Option<ConversationHistory>>> singleton.
  - Helpers: init_history, history_push_user, history_push_assistant,
    history_snapshot, history_clear, history_pop_last_user, history_last_assistant.
  - llm_fallback migrated off its own Mutex<History> — now reads/writes shared.
  - ConversationHistory gains last_assistant() method.

New Lua APIs
  - jarvis.llm_reset()        → clear conversation turns (keeps system prompt).
  - jarvis.llm_last_reply()   → string or nil (last assistant message text).
  - jarvis.health()           → debug table {tts_backend, llm_backend, llm_model,
                                  active_profile, memory_facts, scheduled_tasks,
                                  language, voice, microphone, vosk_model,
                                  noise_suppression}. No secrets included.

New voice packs
  - resources/commands/llm_context/   (P0.3)
    * "сбрось контекст" / "забудь разговор"     → llm.reset
    * "повтори последнее" / "повтори ответ"     → llm.repeat (uses last_assistant)
  - resources/commands/quick_search/   (imba P1 item)
    * "найди в гугле <X>" / "загугли <X>"
    * Uses DuckDuckGo Instant Answer API (api.duckduckgo.com, no key required).
      Pulls AbstractText or RelatedTopics into LLM prompt; falls back to pure
      LLM knowledge if DDG returns nothing useful. Speaks 2-4 sentence answer.
  - resources/commands/diagnostics/
    * "диагностика" / "доложи о себе" / "статус"
    * Reads jarvis.health() and speaks a one-line summary. Useful when
      debugging — user can read out their current state for a bug report.

Build: cargo build --release -p jarvis-app -p jarvis-gui green.
Tests: 52/52 jarvis-core unit tests pass.
2026-05-15 16:25:28 +03:00
Bossiara13
5c7245012e feat: hot-swap LLM backend + media keys + codebase Q&A + scheduler cancel-by-text + TTS pre-warm
Single biggest user-facing change: you can now switch local↔cloud LLM by voice
without restarting. Plus four new packs and a perf nudge.

Shared LLM global (crates/jarvis-core/src/llm/mod.rs)
  - GLOBAL: Lazy<RwLock<Option<Arc<LlmClient>>>>. Single source of truth.
  - init_global() / current() / swap_to(LlmBackend) / current_backend_name() / parse_backend(str).
  - llm_fallback + llm_router migrated: they no longer own a separate LlmClient,
    they read from the global on each request. Hot-swap is now LIVE — change
    backend, next chat call uses the new one.
  - parse_backend accepts both english and russian aliases:
      "groq"/"cloud"/"облако"/"клауд"        → Groq
      "ollama"/"local"/"локал"/"локальный"  → Ollama

Voice + Lua LLM switcher (resources/commands/llm_switch/, 3 commands)
  - "переключись на локальный" / "перейди на оллама"  → swap to Ollama
  - "переключись на облако" / "используй грок"        → swap to Groq
  - "какой у тебя мозг" / "облако или локально"       → status query

  Underlying Lua API (registered globally):
    jarvis.llm({messages}, opts)   -- as before, now uses global client
    jarvis.llm_status()            -> "groq" | "ollama" | "none"
    jarvis.llm_switch(name)        -> name on success, nil on failure

Media keys pack (resources/commands/media_keys/, 4 commands)
  - "пауза" / "плей"           → VK_MEDIA_PLAY_PAUSE
  - "следующий трек"           → VK_MEDIA_NEXT_TRACK
  - "предыдущий трек"          → VK_MEDIA_PREV_TRACK
  - "стоп музыка"              → VK_MEDIA_STOP
  - Helper _media.ps1 uses user32.keybd_event (P/Invoke through Add-Type).
  - Works with anything that listens to global media keys: Spotify, Yandex Music,
    YouTube (focused tab), Foobar2000, Winamp, etc. No OAuth, no API keys.

Codebase Q&A pack (resources/commands/codebase_qa/, 3 commands)
  - "укажи проект <path>"             → stores path in jarvis.memory under codebase.root
  - "какой проект сейчас"             → speaks current path
  - "что делает функция X" / "найди в коде Y" / "объясни код"
    → walks the folder (depth 3, 30 files cap, 4KB per file, 50KB total),
      filters by source extensions (rs/py/ts/lua/go/...), feeds digest to LLM
      with a "senior reviewer" system prompt, speaks 3-5 sentence answer.

Scheduler cancel-by-text (crates/jarvis-core/src/scheduler.rs)
  - new pub fn find_by_text(query, limit) -> Vec<ScheduledTask>
  - new pub fn remove_by_text(query) -> usize (count removed)
  - new pure helper find_by_text_in(tasks, query, limit) for tests
  - Lua: jarvis.scheduler.{find_by_text, remove_by_text}
  - Voice: "отмени напоминание про воду" / "удали задачу про разминку"
  - 3 new unit tests (52 total in jarvis-core).

TTS pre-warm (crates/jarvis-app/src/main.rs)
  - Call jarvis_core::tts::backend() once on startup so the first speak()
    doesn't pay Piper binary discovery + voice loading cost. Cuts first-speak
    latency by ~150-300ms depending on backend.

Build: cargo build --release -p jarvis-app -p jarvis-gui both green.
Tests: 52/52 jarvis-core unit tests pass.
2026-05-15 16:15:59 +03:00
Bossiara13
6225198821 refactor: maintainability pass — dedup, central env config, cmd helpers, tests, ARCHITECTURE.md
User asked the code stays easy to fix/extend as the feature surface grows.
This commit refactors what was duplicated, centralises what was scattered,
adds tests where there were none, and writes the architecture doc that
contributors will need.

Deduplication
  - tts::play_wav extracted to tts/mod.rs as pub(crate). Was identical in
    piper.rs and silero.rs (~25 lines × 2). Both backends now call super::play_wav.

Centralised env config
  - new crates/jarvis-core/src/runtime_config.rs — single doc-file for every
    JARVIS_* / GROQ_* / OLLAMA_* env var. Includes:
      - ENV_* constants with doc comments
      - get(name) / get_bool(name, default) / get_parse(name, default) helpers
      - feature-flag wrappers: llm_tts_enabled(), llm_router_enabled(),
        llm_router_threshold()
      - log_effective_config() prints active values on startup
  - migrated llm_fallback (JARVIS_LLM_TTS), llm_router (JARVIS_LLM_ROUTER,
    JARVIS_LLM_ROUTER_THRESHOLD) to use the new helpers. Pattern set for
    future migrations.

Lua boilerplate killer
  - new crates/jarvis-core/src/lua/api/cmd.rs exposing:
      jarvis.cmd.ok(msg?)         — play_ok + speak + {chain=false}
      jarvis.cmd.chain_ok(msg?)   — same but chain=true
      jarvis.cmd.error(msg?)      — play_error + speak + {chain=false}
      jarvis.cmd.not_found(msg?)  — play_not_found + speak + {chain=false}
      jarvis.cmd.silent_ok / silent_error
  - refactored 5 packs (daily_briefing/off, memory_pack/list, pomodoro/stop,
    habit_nudge/stop_all, scheduler/clear) — each lost 3-4 lines of repetitive
    play_*/speak/return boilerplate. Pattern for future packs documented in
    ARCHITECTURE.md.

Tests (was 32, now 49)
  - long_term_memory: 8 new tests for normalize_key, search_in (rank, limit,
    empty), build_context_from (empty + populated), serde round-trips for
    MemoryRecord and Store. Extracted pure logic (search_in, build_context_from)
    into pub(crate) functions to enable testing without global state.
  - profiles: 6 new tests for Profile::allows_command (empty/whitelist/blacklist/
    deny-wins-over-allow), serde round-trip with all fields + minimal-fields
    tolerance via #[serde(default)].
  - runtime_config: 2 tests for get_bool / get_parse defaults.
  - All 49 tests pass.

New mood/energy log pack
  - resources/commands/mood_log/ with 2 commands:
      mood.record  "запиши настроение 7" / "сегодня мне грустно" → stores
                   timestamped entry via jarvis.memory.remember
      mood.recap   "как прошла неделя" → LLM summarises last 30 entries
  - Showcase: composes memory + llm + cmd helpers in <30 lines per script.

ARCHITECTURE.md (new, 250 lines)
  - Crate layout, data flow diagram (mic→action 10 steps), per-module
    responsibility table, configuration layers, TTS pipeline diagram,
    Lua sandbox details with API quick-ref, background-services overview,
    "how to add a pack/feature/TTS backend" recipes, test coverage map,
    build instructions with MSVC env, git workflow with Forgejo NO_PROXY trick.
  - Aimed at someone who just cloned the repo and needs to fix a bug fast.

Build: cargo build --release -p jarvis-app -p jarvis-gui both green.
Tests: 49/49 jarvis-core unit tests pass.
2026-05-15 16:06:18 +03:00
Bossiara13
45243c3e3c feat: Ollama LLM backend + 5 utility packs (daily briefing, pomodoro, currency convert, stocks, habits, translate clipboard)
Local-LLM support so J.A.R.V.I.S. works offline without GROQ_TOKEN.

LLM multi-backend (crates/jarvis-core/src/llm/client.rs)
  - LlmBackend enum {Groq, Ollama}. Both use OpenAI-compatible /v1/chat/completions
    (Ollama exposes one natively at :11434/v1).
  - LlmClient::from_env() now dispatches on JARVIS_LLM=groq|ollama. Auto-detect:
    Groq if GROQ_TOKEN set, else Ollama.
  - New helpers: LlmClient::groq() (was: from_env), LlmClient::ollama().
  - Reqwest client now has a 60s timeout (was: unbounded — Groq freezes could hang).
  - api_key skipped when empty (Ollama doesn't need auth).
  - Defaults: OLLAMA_BASE_URL=http://localhost:11434/v1, OLLAMA_MODEL=qwen2.5:3b.
    Override via OLLAMA_BASE_URL / OLLAMA_MODEL env.
  - llm_fallback + llm_router log the active backend on init.
  - Tests: 32/32 pass. Renamed `from_env_fails` → `groq_fails_when_token_missing`,
    added `ollama_works_without_token`.

Daily briefing pack (resources/commands/daily_briefing/, 3 commands)
  - setup.lua: "настрой утренний брифинг на 9:00" → adds a daily scheduled task
    that runs now.lua via the scheduler's Lua-action support.
  - now.lua: greeting + time + active profile + scheduled task count + memory recap
    + LLM motivational nudge. Voice-triggered ("утренний брифинг") works the same.
  - off.lua: removes the scheduled task.

Pomodoro pack (resources/commands/pomodoro/, 2 commands)
  - start.lua / stop.lua + tick.lua (re-scheduled by itself). State stored in
    jarvis.state — phase alternates work (25 min) / break (5 min) until stop.

Currency conversion (resources/commands/currency/convert.lua)
  - "сколько будет 1000 долларов в рублях" — fetches CBR daily rates from
    cbr-xml-daily.ru, normalises by Nominal, prints both directions.
  - Recognises USD/EUR/CNY/GBP/JPY/RUB by substring. Pluralises russian units.

Stocks pack (resources/commands/stocks/, 1 command)
  - "сколько Сбер" → MOEX ISS /iss/engines/stock/markets/shares/securities/<TKR>.
  - Mapping of 22 popular Russian tickers to common names (Сбер→SBER, Газпром→GAZP,
    Лукойл→LKOH, Яндекс→YDEX, Т-банк→T, etc).
  - Picks TQBR (main board) row, reports LAST + LASTTOPREVPRICE%.

Habit nudges (resources/commands/habit_nudge/, 4 commands)
  - "напоминай мне пить воду"     → every 2 hours
  - "напоминай мне размяться"     → every 50 minutes
  - "напоминай отдыхать глазам"  → every 20 minutes (20-20-20 rule)
  - "отключи все привычки"        → stops all three by id.

Translate clipboard (resources/commands/translate/clipboard.lua)
  - "переведи буфер на английский" → grabs clipboard, sends to LLM, speaks first
    sentence, replaces clipboard with full translation, fires notification.

Build: cargo build --release -p jarvis-app -p jarvis-gui both green. 32/32 tests.

To use Ollama:
  1. Install + run Ollama (https://ollama.com).
  2. `ollama pull qwen2.5:3b` (or any chat model).
  3. Optional: `set JARVIS_LLM=ollama` (auto-picked if GROQ_TOKEN unset).
2026-05-15 15:51:24 +03:00
Bossiara13
12b1ed4ccb feat(scheduler): IMBA-5 proactive scheduler — reminders, daily briefings, intervals
Background thread that wakes J.A.R.V.I.S. on a schedule to speak reminders.
Nothing else competes here on desktop — Алиса/Сири are reactive-only.

Core (crates/jarvis-core/src/scheduler.rs)
  - Schedule::{Daily{h,m}, Interval{secs}, Once{at}} with Schedule::parse for
    "daily HH:MM" / "at HH:MM" / "every N minutes|hours" / "in N minutes|hours".
    Russian units (час/часа/часов/минут/секунд) also accepted.
  - ScheduledTask {id, name, schedule, action, last_fired, enabled, created_at}.
    Action::Speak{text} | Action::Lua{script_path}.
  - JSON persistence at <APP_CONFIG_DIR>/schedule.json, atomic write-through.
  - add/remove/clear/list/find; mark_fired auto-deletes Once tasks.
  - start_background() spawns a 30-second tick thread (idempotent). Each tick
    calls due_tasks(), fires Action via tts::speak_default (after voices::play_reply
    "ahem" cue) or Lua engine.
  - 7 unit tests (all passing).

Lua API (crates/jarvis-core/src/lua/api/scheduler.rs)
  - jarvis.scheduler.add({name, schedule, action={type, text|script_path}})
  - jarvis.scheduler.{list, count, remove(id), clear}.
  - Tasks come back as {id, name, schedule_human, action, enabled, last_fired}.

Wire-up (crates/jarvis-app/src/main.rs)
  - scheduler::init() + scheduler::start_background() after profiles::init.
  - Tasks survive restarts via schedule.json.

Voice commands (resources/commands/scheduler/, 6 ids)
  - scheduler.add_reminder    "напомни через 5 минут выключить кофеварку"
  - scheduler.add_at          "напомни в 18:00 забрать ребёнка"
  - scheduler.add_recurring   "каждые 2 часа напоминай попить воды"
  - scheduler.add_daily       "каждый день в 9:00 делай briefing"
  - scheduler.list            "что у меня запланировано"
  - scheduler.clear           "очисти расписание"

Russian-aware parsers (час/часа/часов, минут/минуту/минуты) live inside the Lua
packs — easy to extend without touching Rust.

Tests: 31/31 jarvis-core unit tests pass (24 prior + 7 scheduler).
Build: cargo build --release -p jarvis-app and -p jarvis-gui both green.
2026-05-15 15:43:04 +03:00
Bossiara13
0b1f1d4480 feat: TTS backend abstraction + 4 'imba' features + Lua packs
Big push driven by user feedback ("делай имбу") and web research on what
voice assistants need to be the ideal:

TTS backend abstraction (P0.1)
  - new module crates/jarvis-core/src/tts/{mod,sapi,piper,silero}.rs
  - TtsBackend trait with SapiBackend (current PowerShell), PiperBackend
    (rhasspy/piper, neural quality), SileroBackend (python subprocess)
  - JARVIS_TTS env var picks (sapi|piper|silero). Auto-detect Piper if
    binary + voice present in tools/piper/. Falls back to SAPI on missing.
  - SpeakOpts {lang, detached, raw} replaces ad-hoc args. text_utils
    sanitiser applied unless raw=true.
  - llm_fallback + lua/api/tts both routed through tts::backend().
  - tools/piper/install.ps1 downloads piper.exe + ru_RU-irina-medium.onnx
    from rhasspy releases + huggingface. Smoke-test included.
  - tools/silero/silero_tts.py helper (PyTorch); rust spawns it as subprocess.

IMBA-1 Agentic LLM router
  - crates/jarvis-app/src/llm_router.rs
  - When fuzzy/intent matcher fails, LLM picks the closest command from the
    full registry. Returns JSON {command_id, confidence, reason}.
  - Threshold-gated re-dispatch via substitute phrase. JARVIS_LLM_ROUTER=1
    enables; JARVIS_LLM_ROUTER_THRESHOLD overrides 0.55 default.
  - Inserted in app.rs::execute_command between "no match" and existing
    llm_fallback chat fallback.

IMBA-2 Long-term memory
  - crates/jarvis-core/src/long_term_memory.rs — JSON store at
    APP_CONFIG_DIR/long_term_memory.json. Atomic write-through.
  - remember/recall/search/forget/all/build_context API.
  - Lua bindings: jarvis.memory.* (5 functions).
  - llm_fallback auto-injects relevant facts (substring search of prompt)
    into system message before LLM call.
  - Pack resources/commands/memory_pack/ with 4 commands: remember, recall,
    forget, list.

IMBA-3 Profile switching (work/game/sleep/driving/default)
  - crates/jarvis-core/src/profiles.rs — JSON profiles at APP_CONFIG_DIR/profiles/
    Auto-seeds 5 defaults on first run with personality + allow/deny lists +
    greetings + emoji icons.
  - active_profile.txt persists choice across restart.
  - Lua bindings: jarvis.profile.{active,set,list,allows,active_name}.
  - llm_fallback prepends profile personality to system prompt.
  - Pack resources/commands/profile_switch/ with 6 voice triggers.

IMBA-4 Multimodal screenshot + vision LLM
  - crates/jarvis-core/src/lua/api/vision.rs — gated on HTTP sandbox.
  - jarvis.vision.screenshot() captures via PowerShell System.Drawing.
  - jarvis.vision.describe(prompt?) sends base64 PNG to Groq vision model
    (default llama-3.2-11b-vision-preview, override via GROQ_VISION_MODEL).
  - Pack resources/commands/vision/ with 2 commands: describe + read_error.

P0.2 Continuous conversation grace window
  - config::CONVERSATION_GRACE_MS = 30_000.
  - app.rs: after command result, if grace_ms > 0 keep listening WITHOUT
    re-wake for the grace duration. Existing CMS_WAIT_DELAY back-dated so
    the existing timeout fires at start + grace_ms.

Tests: 24/24 jarvis-core unit tests pass (including 5 text_utils).
Build: cargo build --release -p jarvis-app and -p jarvis-gui both succeed
on Windows MSVC (VS 2026 Enterprise vcvars64).

Notes for setup:
  - Piper voice install: pwsh tools/piper/install.ps1 (downloads ~90 MB).
  - GROQ_TOKEN needed for IMBA-1 (router) and IMBA-4 (vision).
  - All features are opt-in via env vars or auto-detect; existing SAPI +
    fuzzy match path remains the default.
2026-05-15 15:32:44 +03:00
Bossiara13
80b54af1ee fix(tts): sanitise text before SAPI so it stops reading "J.A.R.V.I.S." as "J точка A точка R точка..."
User complaint: SAPI reads every dotted acronym character-by-character with a
"точка" between letters. Unbearable on every speak. No real TTS analog
shipping yet (Silero/Coqui/Inworld in roadmap), so the immediate fix is text
preprocessing.

New module `jarvis-core::text_utils` with `sanitize_for_speech()`:
- Specific brand mapping: J.A.R.V.I.S. → Джарвис, U.S.A. → США, U.K. →
  Британия, U.S. → США, S.O.S. → сос.
- Generic dotted-acronym collapse: any `[letter].[letter].[letter].(...)`
  run of 3+ letters gets its dots stripped. "T.O.N." → "TON".
- URL stripping: anything starting with http/https gets replaced with the
  word "ссылка". SAPI reading URLs char-by-char is unlistenable.
- Em/en-dash normalisation: — → -, « » " stripped.
- Whitespace collapse.

Wired in two places:
- `jarvis.speak(text, opts?)` Lua API runs sanitiser unless opts.raw=true.
- `jarvis-app/llm_fallback::speak_via_sapi` runs sanitiser before the SAPI
  PowerShell shell-out for LLM auto-fallback replies.

5 unit tests in `text_utils::tests` covering jarvis-name collapse, generic
acronym pattern, URL stripping, dash normalisation, and "leave clean
russian sentences alone" — all green.

Real TTS upgrade (Silero v4 first cut) tracked as P0.1 in roadmap.
2026-05-15 14:48:42 +03:00
Bossiara13
9911b6ec40 fix(app): embed Common-Controls v6 manifest so TaskDialogIndirect resolves
Symptom: jarvis-app.exe failed to start with a MessageBox saying
"Точка входа в процедуру TaskDialogIndirect не найдена в библиотеке
DLL ... jarvis-app.exe". Loader phase, no log lines ever written.

Cause: tray-icon 0.21 / winit 0.30 transitive Win32 imports include
TaskDialogIndirect from comctl32.dll, which is a Common-Controls v6
API. Without a side-by-side manifest declaring a dependency on
Microsoft.Windows.Common-Controls v6.0.0.0, Windows loads the legacy
v5 comctl32 (which does not export TaskDialogIndirect) and the
loader rejects the exe before main().

jarvis-gui does not hit this because tauri-build embeds its own
manifest as part of the Tauri compile step. jarvis-app had no
manifest at all.

Fix:
- app.manifest: Common-Controls v6 dependency, supportedOS for
  Win7..Win11, PerMonitorV2 DPI awareness, UTF-8 active code page,
  asInvoker execution level.
- app.manifest.rc: 3-line .rc that embeds the manifest with
  CREATEPROCESS_MANIFEST_RESOURCE_ID (1) and RT_MANIFEST (24).
- build.rs: on cfg(windows), embed_resource::compile() compiles the
  .rc and links the resulting .res into the exe. rerun-if-changed
  on the manifest sources.
- Cargo.toml: target-windows build-dependency on embed-resource 3.

Verified: jarvis-app.exe now runs cleanly through all init steps:
commands parsed, audio init, recorder init found "Микрофон (5-
Fifine Microphone)", IPC server up on ws://127.0.0.1:9712, VAD
already flushing frames.

UTF-8 active code page in the manifest also helps russian-character
fidelity when win32 APIs are invoked from PowerShell helpers.
2026-05-15 13:26:00 +03:00
Bossiara13
a3bdf90237 build(gui): build.rs auto-runs npm run build, no more stale frontend bundle
Reproduction:
  cargo build --release -p jarvis-gui   # before this commit
  → Tauri'\''s generate_context!() reads frontend/dist/client which is
    whatever the user (or me) last built, possibly hours ago. Result:
    new release exe ships with stale UI. Caught when /commands page
    kept rendering the placeholder after the new code was committed
    and the binary was rebuilt — because dist/ was generated before
    the Svelte file was rewritten.

Fix: build.rs now invokes `npm run build` in frontend/ on every
relink, and emits cargo:rerun-if-changed for frontend/src and
package.json so cargo also reruns build.rs when Svelte/TS sources
change. `cargo tauri build` still works (it runs npm twice, which
is wasteful but harmless). If npm is not installed (unlikely but
possible on a Rust-only CI), prints a cargo warning and bundles
whatever dist is on disk.

`dist` stays in frontend/.gitignore — this just makes sure a local
working copy always has a fresh dist before Tauri picks it up.
2026-05-15 13:18:08 +03:00
Bossiara13
a7c002c9d4 arch + features: jarvis.text API + brightness/window_switch/spelling/network/summarize (41 packs)
Architecture — close the trigger-strip duplication:

  lua/api/text.rs (new):
  + jarvis.text.strip_trigger(phrase, triggers) — case-insensitive
    "longest trigger wins" stripping, returns trimmed remainder. The
    11 packs that previously inlined a `for _, t in ipairs(triggers) do
    string.find ... s == 1 then sub` loop can drop to one call.
    Demonstrated in spelling/, window_switch/, summarize/.
  + jarvis.text.contains_any(phrase, needles) — boolean check for any
    of N substrings, case-insensitive. Useful for keyword routing.

5 new portable Lua packs (sandbox=full). 36 → 41 total. cargo test
commands::tests still 3/3.

brightness/ — brightness_up / down / max / min. WMI WmiMonitorBrightness
(read CurrentBrightness) + WmiMonitorBrightnessMethods.WmiSetBrightness
(write). ±20% step for up/down; max=100, min=10. Desktop monitors that
don'\''t expose the WMI namespace get a friendly "не поддерживается"
toast instead of a silent failure.

window_switch/ — switch_to_window. Strip trigger ("переключись на" /
"switch to" / etc.), apply the same alias map as process_kill
(хром→chrome, телега→telegram, ...), then PowerShell finds processes
by name OR window-title substring sorted by StartTime desc and uses
(New-Object -ComObject WScript.Shell).AppActivate($pid) on the top
match. Speaks the window title that got focus.

spelling/ — spell_out. Strips "произнеси по буквам" / "spell out" /
etc., iterates `word:gmatch(".")`, joins letters with ". " so SAPI
gives each one a natural micro-pause. Uses jarvis.text.strip_trigger
+ jarvis.speak — 24 lines total.

network/ — open_wifi_settings (ms-settings:network-wifi) +
open_bluetooth_settings (ms-settings:bluetooth) + my_ip. my_ip pulls
the first non-loopback, non-APIPA IPv4 with Get-NetIPAddress and
fetches WAN IP from api.ipify.org (5s timeout); speaks "Локальный X.
Внешний Y".

summarize/ — summarize_selection. Synthesises Ctrl+C through user32!
keybd_event (so the focused window copies its selection), waits
220 ms for the clipboard to populate, reads via
jarvis.system.clipboard.get(), bails if < 20 chars, truncates at
8000, sends to Groq @ temp 0.3 with a "3-5 sentences, keep names"
prompt, drops the summary back into the clipboard and speaks it.
Lets you select ANY text in ANY app (browser, PDF, IDE) and say
"суммируй" to get a spoken TL;DR.

The new packs explicitly use the new jarvis.text + jarvis.llm +
jarvis.speak API surface — no inline PS-SAPI boilerplate, no inline
Groq plumbing. Code is now intent-first.
2026-05-15 13:11:18 +03:00
Bossiara13
a16d2401e7 arch + features: Lua jarvis.speak() / jarvis.llm() API, /commands GUI page, games / mouse / random_choice packs
Architecture — DRY at the Lua API surface:

  lua/api/tts.rs (new): jarvis.speak(text, opts?). opts.lang = ISO
  two-letter code (default "ru"); auto-picks first installed voice
  matching the culture. opts.async = true|false (default true). The
  9 packs that previously inlined a 60-character PS-SAPI string for
  every spoken reply can now reduce to one line. Refactored fun/,
  dice/, clipboard_read/ as proof — fun/ask.lua went from 75 lines
  of HTTP+SAPI boilerplate to ~28 lines of intent code.

  lua/api/llm.rs (new): jarvis.llm(messages, opts?). messages is a
  list of {role, content} tables, opts.max_tokens / .temperature /
  .top_p. Returns the assistant string or nil. Gated by sandbox
  allows_http() (so Standard+ packs only). Internally uses the
  existing jarvis_core::llm::LlmClient::complete_with, no duplicated
  HTTP plumbing.

  llm/client.rs: split complete() into complete_with(messages,
  max_tokens, temperature, top_p) for the new caller; old complete()
  is now a thin wrapper at temp=0.7 top_p=1.0 for backward compat.

GUI — /commands page rewrite:

  Replaces the "Раздел в разработке" placeholder. Calls Tauri command
  get_commands_list (already existed in tauri_commands/commands.rs,
  was wired but unused). Renders a search-filterable card grid:
    - cmd id (monospace)
    - type badge with colour by kind (lua=blue, ahk=red, cli=grey,
      voice=green, terminate=red, stop_chaining=violet)
    - sandbox badge
    - description line (if non-empty)
    - all phrases for currentLanguage, fallback to en, fallback to
      first available; each phrase in a small inline pill
  Toolbar: text input with magnifier icon + reload button (↻).
  Counter line shows visible/total + "Скажи Джарвис + любую фразу"
  hint. Counts down as you type the filter.

New packs (3, total 33 → 36):

  games/ — launch_game / list_games. Reads or creates
  %USERPROFILE%\Documents\jarvis-games.json (the sample preloads dota,
  cs2, elden ring, witcher 3, factorio + minecraft launcher path +
  fortnite epic URI). Trigger-strip + fuzzy name/alias match, then
  launches via steam://rungameid/N (Steam), epic:// (Epic Games), or
  start "" "path" (anything else). list_games speaks the configured
  library.

  mouse/ — left/right/middle/double click + scroll up/down. Single
  dispatch.lua + _mouse_helper.ps1. PS P/Invokes user32!mouse_event
  with the right flag pair (LEFTDOWN+LEFTUP / etc.), 30ms gap, doubles
  do two LEFT cycles with a 50ms gap, wheel uses ±360 ticks.

  random_choice/ — "выбери из X или Y или Z". Strips the trigger,
  splits on " или " / " or " / ", " / " либо " / " чи ", math.random
  picks one, speaks "Я выбираю N".

cargo test commands::tests still 3/3. 36 packs verified via
jarvis-gui startup log.
2026-05-15 12:58:16 +03:00
Bossiara13
9d8b917149 feat(commands): analog-inspired tier-2 — news, currency, crypto, fun, wiki, power, help (33 packs)
Seven more portable Lua packs lifted from common voice-assistant
playbooks (Siri/Alexa/Алиса/Cortana). All sandbox=full, jarvis.http
+ jarvis.system.exec, zero new Rust code. cargo test still 3/3.

news/ — pulls Lenta.ru RSS (en switches to BBC), regex-extracts the
top 5 <title> entries (skipping the channel header), drops them into
the clipboard, then asks Groq for a 2-3 sentence summary at temp 0.4
and speaks it via SAPI. If GROQ_TOKEN is missing it falls back to
just reading the first three headlines.

currency/ — cbr-xml-daily.ru/daily_json.js. Picks USD/EUR/CNY/GBP/
JPY/KZT by keyword in the phrase, computes value / nominal, diffs
against Valute.Previous, says "X стоит N рублей, поднялся/опустился
на M" with ▲/▼ in the notify body.

crypto/ — CoinGecko simple/price with usd,rub and 24hr_change.
Recognises bitcoin / ethereum / solana / dogecoin / the-open-network.
Reports price + direction in plain Russian.

fun/ — joke / fact / quote / compliment, single ask.lua dispatched by
command_id. Per-command system prompt at temp 1.0; seeds with a
random integer so consecutive calls don't repeat.

wiki/ — ru.wikipedia opensearch → REST summary endpoint. If Groq is
available and extract > 350 chars, asks for a 2-3 sentence retell;
otherwise speaks the first 400 chars verbatim. Full extract goes to
clipboard.

power/ — shutdown_pc / restart_pc / sleep_pc / hibernate_pc /
logoff_user / cancel_shutdown. Single act.lua keyed by command_id.
shutdown/restart use shutdown.exe /s|/r /t 30 with a /c message —
30-second grace window so "отмени выключение" actually works.
sleep uses rundll32 powrprof,SetSuspendState, hibernate uses
shutdown.exe /h.

help/ — "что ты умеешь". Reads the parent commands/ dir via
jarvis.fs.list, sorts subdirs (pack names), pairs each with a
hardcoded Russian/English one-liner description, copies the full
list to clipboard and speaks a count + "see clipboard for full list".

Total command packs is now 33 (was 26).
2026-05-15 12:43:09 +03:00
Bossiara13
dcee98bddf feat: 5 new packs + dotenvy + regen icon.ico + shortcuts redo
Iron Man icon regenerated from resources/icons/icon.png at sizes
16/24/32/48/64/128/256 via Pillow (make_ico.py committed alongside);
icon.ico is now a clean 83 KB multi-res for sharp display at any
scale instead of whatever placeholder Tauri originally emitted.

dotenvy added to workspace deps. jarvis-app/main.rs and jarvis-gui/
main.rs both walk up from current_exe() looking for dev.env (up to
5 parent levels), so the Desktop shortcut can launch the exe
directly without a wrapper .bat to pre-set GROQ_TOKEN. Falls back
to dotenvy::dotenv() (cwd lookup) if nothing found near the binary.

5 new portable Lua command packs. Bumps total from 21 to 26.
cargo test -p jarvis-core --lib commands::tests still passes 3/3.

reminders/ — set_reminder. Parses "напомни через N (секунд|минут|
часов) <body>" or English equivalents; understands one Russian word
numerals (один, две, пять, десять, пятнадцать, ...) for the count
when speech recognition returns words instead of digits. Defaults to
5 minutes if number/unit missing. Spawns a detached PowerShell that
Start-Sleeps then fires: BurntToast if installed, System.Speech
SAPI (ru-RU voice preference), and a WScript.Shell.Popup as the
guaranteed-visible last resort.

date_query/ — today / tomorrow / yesterday. Single answer.lua keyed
by command_id, computes the offset via PowerShell Get-Date.AddDays()
in ru-RU culture so the weekday/month come out in Russian, then
speaks via SAPI.

voice_type/ — type_text. Strips the trigger, drops the remainder to
clipboard via jarvis.system.clipboard.set, then synthesises Ctrl+V
through user32!keybd_event so it lands in whatever window currently
has focus. Works in any text field (note app, browser, IDE,
Telegram, etc.).

dice/ — coin_flip / roll_dice / random_number. Single roll.lua,
seeds math.random with os.time + jitter, speaks the result. "1-6"
for dice, "1-100" for random.

stopwatch/ — start / check / stop. Uses jarvis.state.get/set
(persisted via jarvis-core's settings DB) to remember the start
timestamp, computes elapsed via jarvis.context.time.timestamp,
formats as "X сек" / "X мин Y сек" / "X ч Y мин Z сек".

All new packs follow the established patterns (sandbox=full, PS-via-
exec helper where needed, USERPROFILE/SystemDrive everywhere, no
hardcoded paths).
2026-05-15 12:28:44 +03:00
Bossiara13
ae279aeef7 feat(gui): footer badge "Rust" + one-click run.bat launcher
GUI: Footer.svelte gets a small inline "Rust" pill next to the
copyright line (red bg #b7411a, white 11px, rounded). Now obvious
at-a-glance which fork's UI you're looking at when both python and
rust are open side-by-side. Python edition mirrors with a console
banner.

Launcher: run.bat at the repo root loads dev.env (skipping #-comments
and blank lines), then `start ""` spawns target\release\jarvis-gui.exe.
The new Desktop shortcut "Jarvis (Rust).lnk" points at this batch
file with resources\icons\icon.ico as its icon. WindowStyle=7 means
the cmd host stays minimized — only the GUI window pops up.

If the user hasn't built yet, the bat prints the cargo build command
and pauses instead of silently doing nothing.
2026-05-15 12:12:11 +03:00
Bossiara13
100db82bd6 feat(commands): tier-1 — translate, math, theme toggle, project opener, sound panel
Five more portable Lua command packs. Bumps the total from 16 to 21
packs. cargo test -p jarvis-core --lib commands::tests still 3/3.

translate/ — translate.lua. Picks the target language from triggers
like "переведи на английский" / "translate to russian"; falls back to
English when only "переведи X" is heard. Sends to Groq with a strict
"translation only" system prompt at temp 0.3. Drops the result into
the clipboard, fires a notify, and speaks via PowerShell SAPI in the
matching ISO voice (ru-RU / uk-UA / en-US / de-DE etc.) so a German
translation actually sounds German.

math/ — math.lua. Strips the trigger ("посчитай" / "calculate" /
"сколько будет" / "what is" / ...), sends the remainder to Groq with
temp=0.0 and max_tokens=64, asking ONLY for the numeric result. If
the model replies "нет" (the system-prompt sentinel for non-math),
plays not_found instead of speaking nonsense. Otherwise speaks
"Получилось X" via SAPI.

theme/ — set.lua dispatched by command_id (theme_dark / theme_light).
Flips AppsUseLightTheme + SystemUsesLightTheme under
HKCU\...\Themes\Personalize via Set-ItemProperty. No reboot needed,
Windows shell re-themes immediately.

projects/ — open.lua + list.lua. Reads/creates
%USERPROFILE%\Documents\jarvis-projects.json (a flat
[{"name": ..., "path": ...}] list). open.lua strips the trigger,
exact-matches then substring-matches the project name, opens the path
in VS Code if `code.cmd` is on PATH, otherwise in Explorer. First
call creates a sample config with jarvis-rust / jarvis-python /
dietpi entries pointing under USERPROFILE so it works on any machine.
list.lua just notifies the configured project names.

sound_panel/ — open.lua. Two-liner: rundll32 mmsys.cpl,,1 (Recording
tab). Useful both as a standalone "открой настройки звука" and as
the place to fix the mic-disabled state that prevents jarvis-app
from starting.

Portability: USERPROFILE / SystemDrive / PATH lookup throughout.
2026-05-15 12:06:30 +03:00
Bossiara13
01ea46c091 fix(app): show toast + open Sound settings when mic enumeration returns nothing
Before: clicking "Запустить" in jarvis-gui spawned jarvis-app, which
silently exited with code 1 (or in release builds, looked like the
console window flashed and closed). The pv_recorder library returns
INVALID_ARGUMENT from pv_recorder_init when Windows Core Audio reports
zero capture endpoints (some other app holding the mic exclusively,
or all input devices disabled in mmsys.cpl). User saw no actionable
feedback.

Now: on recorder::init failure jarvis-app calls notify_mic_problem()
which (Windows-only):
- Fires a long-duration Windows toast titled "J.A.R.V.I.S.: микрофон
  не найден" with a hint pointing at mmsys.cpl / Recording.
- Spawns "start ms-settings:sound" so the Sound settings page opens
  automatically — user can re-enable the mic in two clicks.

Then the original app::close(1, ...) path runs to keep the same exit
behaviour the GUI's get_jarvis_app_stats poller expects.

Cargo.toml: jarvis-app now pulls winrt-notification (already in
workspace.dependencies via jarvis-core) for the toast.

Also incidentally fixed: the release-build C0000139 (entrypoint not
found) loader crash that was showing up before this change. It went
away after the workspace dep was added and the release relink ran.
Most likely the previous release exe had a stale import table from an
earlier partial rebuild; the clean relink resolves it.

Non-Windows builds get a no-op eprintln so the binary still compiles
for Linux/macOS.
2026-05-15 11:58:47 +03:00
Bossiara13
16ef413962 feat(commands): tier-1 PC tools — window mgmt, clipboard, notes, process killer, web search
Five new portable Lua command packs. Bumps the total from 11 to 16 packs.
All sandbox=full, all dispatched via jarvis.system.exec or jarvis.fs / jarvis.system
APIs, no new Rust code, post_build.py --sync copies them under target/.
cargo test -p jarvis-core --lib commands::tests passes 3/3 against all 16.

window/ — 8 commands, single dispatch.lua keyed by jarvis.context.command_id:
  show_desktop / maximize_window / minimize_window / snap_left / snap_right
  / close_window / restore_all / task_view. _window_helper.ps1 P/Invokes
  user32!keybd_event for arbitrary combos like "win+d", "alt+f4",
  "win+shift+m". Uses one shared script per pack — much less duplication
  than the volume/ approach.

clipboard_read/ — read_clipboard. Pulls jarvis.system.clipboard.get(),
caps preview at 400 chars, then shells PowerShell System.Speech to actually
speak it aloud (auto-picks the first ru-RU voice if present). Also fires a
notify with a 120-char snippet.

notes/ — add_note + open_notes. add_note strips the trigger phrase from
the recognized voice, appends "[YYYY-MM-DD HH:MM] body\n" to
%USERPROFILE%\Documents\jarvis-notes.txt via jarvis.fs.append (creates
the file on first call). open_notes opens the file in the default editor
via jarvis.system.open. Trigger list covers RU/EN/UA variants of
"запиши" / "запомни" / "write down" / etc.

process_kill/ — kill_process. Trigger-strips "закрой программу" / "убей
процесс" / etc., then runs the remainder through an alias map (хром→
chrome, телега→telegram, спотифай→spotify, edge/edge/едж→msedge etc.,
20+ entries) before invoking taskkill /F /IM. Notifies success/not-found.

websearch/ — search_google / search_youtube / search_wiki / search_yandex.
Single dispatch.lua reads jarvis.context.command_id to pick the base URL,
URL-encodes the query (Lua %02X gsub, not relying on jarvis.http), opens
the resulting link via jarvis.system.open (system default browser).

Portability check: every helper resolves paths via $env:USERPROFILE /
jarvis.context.command_path / jarvis.system.env — no hardcoded C:\ refs.
2026-05-15 11:27:02 +03:00
Bossiara13
3d127f05c0 feat(app): speak LLM reply via Windows SAPI server-side
GUI front-end does not subscribe to IpcEvent::LlmReply, so the LLM
auto-fallback was effectively silent — user got the OK chime but no
spoken answer. Backlog item resolved.

llm_fallback::handle now also invokes speak_via_sapi() after firing
IpcEvent::LlmReply (both for successful replies and the fallback
error message). The text goes through System.Speech.Synthesis.
SpeechSynthesizer, with a preference pass that picks the first
installed ru-RU voice if present (Microsoft Irina / Pavel etc.) so
Russian replies sound right out of the box, and falls back to the
default English voice otherwise.

PowerShell is invoked fire-and-forget (no Wait, stdout/stderr null)
so the voice loop never blocks. A long answer keeps speaking while
jarvis returns to wake-word listening; the mic may pick up some of
the speech which is the same compromise voices::play_* already makes.
A cleaner version would pause pv_recorder for the duration of the
utterance — that goes in the backlog as part of the eventual
unified IpcEvent::Speak refactor.

Toggle: set JARVIS_LLM_TTS=false to disable (e.g. on a server where
the GUI is the speaker). Non-Windows builds get a no-op stub.
2026-05-15 01:51:02 +03:00
Bossiara13
69a38b0d89 feat: LLM auto-fallback + codegen pack + OCR pack
LLM auto-fallback (jarvis-app/src/app.rs + jarvis-core/src/config.rs):
- When neither intent classifier nor levenshtein finds a command match,
  route the utterance straight to the LLM instead of just playing the
  "not found" sound. Triggers ("скажи X", "answer Y") still work and
  take precedence — they short-circuit before command lookup.
- Two new config knobs:
    LLM_AUTO_FALLBACK = true        — master toggle
    LLM_AUTO_FALLBACK_MIN_CHARS = 4 — suppress for very short utterances
                                       so background noise doesn't burn
                                       Groq quota
- Requires GROQ_TOKEN; if absent, behaviour is unchanged (play_not_found).

codegen/ command pack:
- Phrases: "напиши код X", "сгенерируй скрипт Y", "write code Z", etc.
- Strips the trigger from the recognized phrase, sends what remains to
  Groq with a strict system prompt ("return ONLY code, no fences, no
  commentary") at temperature 0.2.
- Parses the JSON content, unescapes \n / \" / \\ / \t, strips any
  remaining ```lang ... ``` fences, drops the result into the clipboard
  via jarvis.system.clipboard.set.
- Notifies a 120-char preview + plays ok-sound. Works for any language
  the model handles (Python by default if unspecified).
- GROQ_TOKEN / GROQ_MODEL / GROQ_BASE_URL read from env at call time —
  same envvars the voice-loop fallback already uses.

ocr/ command pack:
- Phrases: "прочитай экран", "что на экране", "read screen", etc.
- Captures the primary screen via System.Windows.Forms + System.Drawing
  to a temp PNG, then shells to tesseract.exe (-l rus+eng for ru/ua,
  -l eng for en).
- Resolves Tesseract by PATH first, then C:\Program Files\Tesseract-OCR
  and the x86 install dir; if none works the user gets a friendly
  "winget install UB-Mannheim.TesseractOCR" hint in a notification.
- Recognized text goes to clipboard + a 200-char preview notification.

All three features are portable (env-var resolution, no hardcoded user
paths). Command-pack total is now 11. cargo test -p jarvis-core --lib
commands::tests passes 3/3.
2026-05-15 01:39:21 +03:00
Bossiara13
429113175a build(core): add build.rs so cargo test links against libvosk
cargo test on jarvis-core was failing with `LNK1181 cannot open input
file "libvosk.lib"`. The link-search path is declared in jarvis-app's
build.rs (and jarvis-cli's), but the test binary for jarvis-core has
no such config and the workspace .cargo/config.toml didn't cover it.

Adds a minimal build.rs that emits `cargo:rustc-link-search=native=...`
pointing at lib/windows/amd64 (resolved relative to CARGO_MANIFEST_DIR,
no hardcoded absolute paths so the project still builds for anyone
cloning under a different drive/path).

Verified: cargo test -p jarvis-core --lib commands::tests now passes
3/3 (every_command_toml_parses, lua_command_scripts_exist,
every_command_has_phrases).
2026-05-15 01:32:17 +03:00
Bossiara13
b257f03f65 feat(commands): add media controls + system info packs
media/ — play_pause, next, prev, stop via user32!keybd_event (VK_MEDIA_*
0xB0..0xB3). Helper _media_helper.ps1 mirrors the volume/ pack pattern,
so the same C# P/Invoke style works for the four standard media keys
without needing any external tooling. Catches Spotify, foobar, browsers,
Yandex Music PWA — anything that responds to system media keys.

sysinfo/ — battery / time / cpu / ram / disk and an "all" combo.
_sysinfo.ps1 uses CIM queries (Win32_Battery, Win32_OperatingSystem,
Win32_LogicalDisk, Win32_Processor + Win32_PerfFormattedData_PerfOS_
Processor) — no external deps. Time is rendered in ru-RU culture
("01:17, 15 мая, пятница"). Battery falls back to a friendly "no
battery, looks like a desktop" when Win32_Battery is empty. SystemDrive
env var resolves the right disk on non-C:/ installs.

Each sysinfo command notifies the line as a Windows toast and logs it.

Triggered locally: sysinfo_all currently reports:
  Сейчас: 01:17, 15 мая, пятница
  Батарея не обнаружена (видимо, десктоп)
  CPU: AMD Ryzen 7 5700X3D 8-Core Processor, загрузка 4%
  RAM: 23.4 из 47.9 ГБ (49% занято)
  Диск C:: свободно 118.2 из 1906.8 ГБ

Total command packs is now 9 (verified via jarvis-gui startup log).

Portability: all PowerShell helpers use $env:SystemDrive and CIM
classes that ship with every Windows install; Lua wrappers resolve
the helper path via jarvis.context.command_path (no hardcoded user
profile paths or absolute C:\ refs).
2026-05-15 01:19:30 +03:00
Bossiara13
24457ad6c9 chore(gui): rebrand — strip TG/Boosty/Patreon/feedback-bot, keep CC BY-NC-SA attribution
The previous feature/gui-rebrand-polish work only updated the appInfo store
keys; Footer.svelte, settings, and the commands page were still rendering
upstream's Telegram channel, Boosty/Patreon support links, feedback bot,
and the original author byline. This finishes the job.

Rust side (applies after `cargo build` — currently a no-op until MSVC Build
Tools are reinstalled):
- Cargo.toml: authors = ["Bossiara13"], repository = J.A.R.V.I.S-rust fork.
  Original attribution stays in LICENSE.txt per CC BY-NC-SA 4.0; the UI
  no longer renders an author byline.
- config.rs: dropped TG_OFFICIAL_LINK / FEEDBACK_LINK / SUPPORT_BOOSTY_LINK
  / SUPPORT_PATREON_LINK. Added UPSTREAM_REPOSITORY_LINK (Priler/jarvis for
  the licence attribution), ISSUES_LINK (this fork's GH issues), and
  PYTHON_FORK_LINK.
- tauri_commands/etc.rs: dropped get_tg_official_link/get_boosty_link/
  get_patreon_link/get_feedback_link. Added get_upstream_link, get_issues_link,
  get_python_fork_link. Tidied up the Option<&str> -> String boilerplate
  with unwrap_or.
- main.rs: invoke_handler updated to match.

Frontend side (visible only after Rust rebuild — Tauri bundles the dist into
the exe):
- stores.ts: appInfo shape reduced to repositoryLink / upstreamLink /
  issuesLink / pythonForkLink / logFilePath. loadAppInfo() uses fetchOr()
  with hardcoded fallbacks so the UI still populates correctly when run
  against an un-rebuilt binary; fetchRepoOr() additionally overrides
  CARGO_PKG_REPOSITORY if it still resolves to upstream's Priler/jarvis.
- Footer.svelte: rewritten — © year + "J.A.R.V.I.S." + repo link + issues
  link + small "fork of Priler/jarvis · CC BY-NC-SA 4.0" attribution line.
  No author byline (it's in LICENSE.txt).
- routes/settings/index.svelte: feedback link now points to GitHub Issues.
- routes/commands/index.svelte: TG-channel reference replaced with a link
  to the Python fork (where the command builder lives) plus a pointer to
  resources/commands/ in this repo. Bruh GIF removed.
- locales/{ru,en,ua}.ftl: removed footer-author / footer-telegram /
  footer-support, added footer-issues / footer-fork-of, rewrote
  commands-wip-* strings, retargeted settings-beta-bot to "GitHub Issues".

Bundle identifier (com.priler.jarvis) kept intact to avoid moving the app
data directory.
2026-05-15 01:09:14 +03:00
Bossiara13
85a004e263 test(commands): schema and Lua script validation for resources/commands/
Three unit tests catch the kind of bug that broke weather/set_city (a
phrases array instead of lang→array map silently dropped the whole pack):

- every_command_toml_parses: every resources/commands/*/command.toml round-
  trips through toml::from_str::<JCommandsList>. Reports all failures at
  once instead of failing on the first.
- lua_command_scripts_exist: for every type="lua" command, the named (or
  default script.lua) script file exists in the pack dir.
- every_command_has_phrases: structural commands (terminate/stop_chaining)
  excluded, every other command has ≥1 phrase across all languages.

Tests use env!("CARGO_MANIFEST_DIR") -> ../.. -> resources/commands so they
work from any cwd.

NB: cargo test does not run on this machine right now — aws-lc-sys needs
cl.exe (MSVC Build Tools missing). The release binary was built earlier
with MSVC available; toolchain install is a follow-up. The tests are still
valid for CI / a re-armed dev box.
2026-05-15 00:55:01 +03:00
Bossiara13
9c25108356 feat(commands): add PC control packs — volume, apps, file_search, output_device
All four packs are pure Lua (sandbox=full) and call out to PowerShell helpers
where COM / native APIs are needed. No new Rust code, no rebuild required:
post_build.py --sync copies them under target/{debug,release}/resources/.

volume/
  - volume_up / volume_down: 5x VK_VOLUME_UP|DOWN via keybd_event
  - volume_mute: VK_VOLUME_MUTE
  - volume_max: 50x VK_VOLUME_UP (system clamps)
  Helper _volume_helper.ps1 P/Invokes user32!keybd_event.

apps/
  - open_browser  : start "" https://www.google.com (uses system default browser)
  - open_notepad  : notepad.exe
  - open_calculator: calc.exe
  - open_explorer : explorer.exe
  - open_terminal : wt.exe, falls back to powershell.exe
  - open_settings : ms-settings: protocol
  - open_task_manager: taskmgr.exe
  - lock_screen   : rundll32 user32.dll,LockWorkStation
  - screenshot    : System.Drawing capture to ~/Pictures/jarvis_screenshot_*.png

file_search/
  - find file <query> : recursive Get-ChildItem in Desktop/Documents/Downloads
    (depth 3, top 5 matches). Strips Russian/English/Ukrainian trigger
    prefixes from the recognized phrase, then opens explorer /select on the
    first hit and notifies with the total count.

output_device/
  - list_output_devices : enumerates active render endpoints via
    IMMDeviceEnumerator and notifies friendly names with indices
  - next_output_device  : cycles the system default render device via the
    undocumented IPolicyConfig::SetDefaultEndpoint (eConsole/Multimedia/
    Communications). Tested locally — listed 7 devices, current correctly
    identified at index 5.
2026-05-15 00:54:52 +03:00
Bossiara13
16240609de fix(commands): repair weather set_city schema, retire broken AHK browser pack
set_city used a plain `phrases = [...]` array which fails the lang-map schema
in JCommand — the whole weather pack was being skipped at load. Normalized
to per-language phrases.

browser/command.toml pointed at Priler's bundled AutoHotKey exes that do not
ship in this fork, so the entries were dead. Renamed to .disabled (the loader
skips packs without command.toml) so we do not collide with the new Lua
apps pack while keeping the directory for reference.
2026-05-15 00:54:34 +03:00
Bossiara13
c234c46642 chore(app): instrument init steps with eprintln + label app::close calls 2026-04-27 21:07:33 +03:00
410 changed files with 25060 additions and 697 deletions

67
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,67 @@
# 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,6 +1,12 @@
# 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*
@ -56,3 +62,15 @@ tree.txt
# Tauri-generated platform schemas (regenerated on every build)
crates/jarvis-gui/gen/schemas/*-schema.json
# Piper TTS binaries (installed via tools/piper/install.ps1, not committed)
tools/piper/piper.exe
tools/piper/*.dll
tools/piper/espeak-ng-data/
tools/piper/voices/
tools/piper/libtashkeel_model.ort
tools/piper/pkgconfig/
# Silero model cache (downloaded by torch.hub on first synth)
tools/silero/.silero_cache/
tools/silero/*.pt

295
ARCHITECTURE.md Normal file
View file

@ -0,0 +1,295 @@
# 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,6 +1554,12 @@ 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"
@ -3264,6 +3270,8 @@ checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
name = "jarvis-app"
version = "0.1.0"
dependencies = [
"dotenvy",
"embed-resource",
"glib 0.21.5",
"gtk",
"image",
@ -3273,11 +3281,14 @@ dependencies = [
"parking_lot",
"platform-dirs",
"rand 0.8.5",
"serde",
"serde_json",
"simple-log",
"tokio",
"tray-icon",
"winapi",
"winit",
"winrt-notification",
]
[[package]]
@ -3339,6 +3350,7 @@ 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 = ["Abraham Tugalov (original)", "Bossiara13 (fork)"]
authors = ["Bossiara13"]
license = "GPL-3.0-only"
repository = "https://github.com/Priler/jarvis"
repository = "https://github.com/DmitryBykov-ISPO/J.A.R.V.I.S-rust"
edition = "2021"
[workspace.dependencies]
@ -54,4 +54,5 @@ tokenizers = { version = "0.22", default-features = false }
regex = "1"
sys-locale = "0.3"
webrtc-vad = "0.4"
dotenvy = "0.15"

458
README.md
View file

@ -1,177 +1,311 @@
# J.A.R.V.I.S (Rust) — форк Bossiara13
# J.A.R.V.I.S. (Rust) — форк Bossiara13
Форк Rust-переписки голосового ассистента [Priler/jarvis](https://github.com/Priler/jarvis).
Текущий репозиторий: <https://github.com/DmitryBykov-ISPO/J.A.R.V.I.S-rust>.
Локальный голосовой ассистент для Windows. Форк Rust-переписки
[Priler/jarvis](https://github.com/Priler/jarvis). Сильно расширен в этом форке
(см. ниже). Может работать **полностью офлайн** через Ollama, или с облачным
LLM через Groq — выбор переключается **голосом** на лету.
## Что это
Зеркала:
- 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` если у тебя локальный прокси)
Голосовой ассистент, написанный на Rust, работающий локально (без облака).
Текущий стек:
Для контрибьюторов: см. [ARCHITECTURE.md](ARCHITECTURE.md) — карта кода, data flow,
рецепты "как добавить пак / фичу / TTS backend".
- 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`).
## Что внутри
**LLM-клиент (Groq / OpenAI-совместимый) добавлен в `jarvis-core::llm` и подключён к голосовому циклу.** Если фраза начинается с триггера («скажи», «ответь», «произнеси»), она уходит в Groq и ответ возвращается через IPC-событие `LlmReply`. Без триггера всё работает как раньше — wake-word + intent + Lua. Это следующий шаг после CLI-only LLM из v0.2.0.
- **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`).
## Это форк
Оригинальный автор — Abraham Tugalov (Priler).
Апстрим: <https://github.com/Priler/jarvis>.
Лицензия сохранена: **CC BY-NC-SA 4.0** (см. `LICENSE.txt`).
Атрибуция в `Cargo.toml` и `voice.toml` пакетов озвучки не изменена.
## Что отличается от апстрима
- Обновлён список авторов в `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) и запушены в форк.
## Структура репозитория
Cargo workspace из четырёх крейтов:
| Крейт | Назначение |
|----------------|----------------------------------------------------------------------------|
| `jarvis-core` | Библиотека: конфиг, intent, STT, wake-word, аудио, Lua-бэкенд, i18n. |
| `jarvis-app` | Бинарь-«демон»: собирает всё вместе, tray, IPC. |
| `jarvis-gui` | Tauri-приложение (использует `frontend/dist/client`). |
| `jarvis-cli` | CLI для отладки: классификация intent, список команд, dump конфига. |
Прочее:
- `frontend/` — Vite + Svelte UI для `jarvis-gui`. Собирается отдельно.
- `lib/windows/amd64/` — нативные DLL/LIB для Vosk, Porcupine, PvRecorder.
- `resources/` — голоса, модели, конфиги по умолчанию. ONNX-модели хранятся в Git LFS.
- `post_build.py` — постпроцессинг артефактов сборки (Python 3).
## Сборка
Требования:
- 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).
## Лицензия
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`.
В `Cargo.toml` декларирован `license = "GPL-3.0-only"` — это несоответствие
унаследовано от апстрима и не правилось, чтобы не расходиться с upstream-конфигом.
Приоритет имеет `LICENSE.txt`.
## Python-версия
Старая версия ассистента была на Python.
Последний коммит с Python-кодом в апстриме — [943efbf](https://github.com/Priler/jarvis/tree/943efbfbdb8aeb5889fa5e2dc7348ca4ea0b81df).
Старая версия ассистента была на 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).
## Поддерживаемость
- `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".
## Что в roadmap (что ещё хочется)
- **faster-whisper STT** рядом с Vosk для длинных LLM-фраз.
- **Реальные Win32 макросы** (keyboard + mouse hooks), не только voice replay.
- **Spotify Web API** через OAuth — "что играет", "сохрани в плейлист".
- **Outlook COM** — голосовая почта / календарь.
- **GUI /macros + /scheduler страницы** — управление без голоса.
- **Python parity** для 17 новых паков.
См. `~/.claude/projects/C--Jarvis/memory/project_jarvis_roadmap.md` (если ты
— Claude помогаешь автору) или открой issue.
## Лицензия
CC BY-NC-SA 4.0 — Creative Commons **Attribution-NonCommercial-ShareAlike**.
Полный текст: `LICENSE.txt`. Атрибуция Abraham Tugalov сохранена.

307
USAGE.md Normal file
View file

@ -0,0 +1,307 @@
# 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,12 +11,15 @@ 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"] }
@ -26,7 +29,11 @@ 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"
glib = "0.21.5"
[target.'cfg(target_os = "windows")'.build-dependencies]
embed-resource = "3"

View file

@ -0,0 +1,60 @@
<?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

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

View file

@ -1,10 +1,18 @@
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,7 +2,6 @@ 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;
@ -323,21 +322,45 @@ fn recognize_command(
// execute command and check if we should chain
let should_chain = execute_command(&recognized_voice, rt);
if should_chain {
// chain: reset and continue listening
info!("Chaining enabled, continuing to listen...");
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
);
}
stt::reset_speech_recognizer();
vad_state = VadState::WaitingForVoice;
silence_frames = 0;
start = SystemTime::now();
// 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 {
// no chain: return to wake word
info!("No chain, returning to wake word mode.");
info!("Conversation grace disabled, returning to wake word mode.");
return;
}
}
@ -402,23 +425,33 @@ 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;
}
};
let cmd_result = if let Some((intent_id, confidence)) =
rt.block_on(intent::classify(text))
// 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)) =
rt.block_on(intent::classify(text))
{
info!("Intent recognized: {} (confidence: {:.2})", intent_id, confidence);
intent::get_command_by_intent(commands_list, &intent_id)
(
intent::get_command_by_intent(commands_list, &intent_id),
"intent",
Some((confidence * 100.0).clamp(0.0, 100.0) as u8),
)
} else {
info!("Intent not recognized, trying levenshtein fallback...");
commands::fetch_command(text, commands_list)
(commands::fetch_command(text, commands_list), "fuzzy", None)
};
if let Some((cmd_path, cmd_config)) = cmd_result {
@ -440,6 +473,18 @@ 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,
@ -450,6 +495,12 @@ 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,
@ -459,19 +510,58 @@ fn execute_command(text: &str, rt: &tokio::runtime::Runtime) -> bool {
}
} else {
info!("No command found for: {}", text);
voices::play_not_found();
ipc::send(IpcEvent::Error {
message: format!("Command not found: {}", 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) {
info!("Closing application.");
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);
voices::play_goodbye();
ipc::send(IpcEvent::Stopping);
std::process::exit(code);

View file

@ -1,15 +1,19 @@
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::{ChatMessage, ConversationHistory, LlmClient};
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::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,
}
@ -25,22 +29,18 @@ fn build_state() -> Option<State> {
return None;
}
let client = match LlmClient::from_env() {
Ok(c) => c,
Err(e) => {
warn!("LLM fallback disabled: {}. Set GROQ_TOKEN to enable.", e);
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);
return None;
}
let lang = i18n::get_language();
let prompt = config::get_llm_system_prompt(&lang);
let history = Mutex::new(ConversationHistory::new(prompt, config::LLM_DEFAULT_MAX_HISTORY));
llm::init_history(prompt, config::LLM_DEFAULT_MAX_HISTORY);
info!("LLM fallback enabled (model: {}).", client.model());
info!("LLM fallback enabled (backend: {}).", llm::current_backend_name());
Some(State {
client,
history,
max_tokens: config::LLM_DEFAULT_MAX_TOKENS,
})
}
@ -87,27 +87,58 @@ pub fn handle(prompt: &str) {
info!("LLM prompt: {}", prompt);
let snapshot: Vec<ChatMessage> = {
let mut h = state.history.lock();
h.push_user(prompt);
h.snapshot()
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;
}
};
match state.client.complete(&snapshot, state.max_tokens) {
match client.complete(&snapshot, state.max_tokens) {
Ok(reply) => {
let reply = reply.trim().to_string();
info!("LLM reply: {}", reply);
state.history.lock().push_assistant(reply.clone());
ipc::send(IpcEvent::LlmReply { text: reply });
llm::history_push_assistant(reply.clone());
ipc::send(IpcEvent::LlmReply { text: reply.clone() });
voices::play_ok();
speak_reply(&reply);
}
Err(e) => {
error!("LLM request failed: {}", e);
state.history.lock().pop_last_user();
ipc::send(IpcEvent::LlmReply {
text: config::LLM_FALLBACK_ERROR_RU.to_string(),
});
llm::history_pop_last_user();
let err_text = config::LLM_FALLBACK_ERROR_RU.to_string();
ipc::send(IpcEvent::LlmReply { text: err_text.clone() });
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

@ -0,0 +1,220 @@
//! 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},
ipc::{self, IpcAction, IpcEvent},
i18n, voices, models,
APP_CONFIG_DIR, APP_LOG_DIR, COMMANDS_LIST, DB,
};
@ -19,6 +19,7 @@ mod log;
// include app
mod app;
mod llm_fallback;
mod llm_router;
// include tray
// @TODO. macOS currently not supported for tray functionality.
@ -28,54 +29,102 @@ mod tray;
static SHOULD_STOP: AtomicBool = AtomicBool::new(false);
fn main() -> Result<(), String> {
// initialize directories
let boot_start = std::time::Instant::now();
load_dotenv_near_exe();
eprintln!("[jarvis-app] step: init_dirs");
config::init_dirs()?;
// initialize logging
// 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");
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());
// initialize settings
eprintln!("[jarvis-app] step: db::init");
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");
// init voices
eprintln!("[jarvis-app] step: voices::init");
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);
}
// init i18n
eprintln!("[jarvis-app] step: i18n::init");
i18n::init(&settings.lock().language);
// init LLM fallback (no-op if GROQ_TOKEN missing)
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");
llm_fallback::init();
// init recorder
if recorder::init().is_err() {
app::close(1);
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);
}
// init models registry (scans available AI models)
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");
if recorder::init().is_err() {
notify_mic_problem();
app::close(1, "recorder::init failed");
}
eprintln!("[jarvis-app] step: models::init");
if let Err(e) = models::init() {
warn!("Models registry init failed: {}", e);
}
// init stt engine
eprintln!("[jarvis-app] step: stt::init");
if stt::init().is_err() {
// @TODO. Allow continuing even without STT, if commands is using keywords or smthng?
app::close(1); // cannot continue without stt
app::close(1, "stt::init failed");
}
// init commands
eprintln!("[jarvis-app] step: commands::parse_commands");
info!("Initializing commands.");
let cmds = match commands::parse_commands() {
Ok(c) => c,
@ -87,47 +136,55 @@ fn main() -> Result<(), String> {
info!("Commands initialized. Count: {}, List: {:?}", cmds.len(), commands::list_paths(&cmds));
COMMANDS_LIST.set(cmds).unwrap();
// init audio
eprintln!("[jarvis-app] step: audio::init");
if audio::init().is_err() {
// @TODO. Allow continuing even without audio?
app::close(1); // cannot continue without audio
app::close(1, "audio::init failed");
}
// init wake-word engine
eprintln!("[jarvis-app] step: listener::init");
if let Err(e) = listener::init() {
error!("Wake-word engine init failed: {}", e);
app::close(1);
app::close(1, "listener::init failed");
}
// shared async runtime for intent classification, IPC, etc.
eprintln!("[jarvis-app] step: tokio runtime");
let rt = Arc::new(
tokio::runtime::Runtime::new().expect("Failed to create tokio runtime")
);
// init intent-recognition engine
eprintln!("[jarvis-app] step: intent::init");
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);
app::close(1, "intent::init failed");
}
});
// init slots parsing engine
eprintln!("[jarvis-app] step: slots::init");
slots::init().map_err(|e| error!("Slot extraction init failed: {}", e)).ok();
// init audio processing
eprintln!("[jarvis-app] step: audio_processing::init");
info!("Initializing audio processing...");
if let Err(e) = audio_processing::init() {
warn!("Audio processing init failed: {}", e);
}
// init IPC
eprintln!("[jarvis-app] step: ipc::init");
info!("Initializing IPC...");
ipc::init();
// channel for text commands (manually written in the GUI)
// channel for text commands (manually written in the GUI, OR fed by macro replay)
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 => {
@ -151,7 +208,43 @@ 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()),
});
}
}
});
@ -161,17 +254,67 @@ fn main() -> Result<(), String> {
ipc_rt.block_on(ipc::start_server());
});
// start the app (in the background thread)
eprintln!("[jarvis-app] step: spawn app 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,10 +7,8 @@ use tray_icon::{
use image;
use std::process::Command;
#[cfg(target_os="windows")]
use winit::platform::windows::EventLoopBuilderExtWindows;
use jarvis_core::{config, i18n, voices, ipc::{self, IpcEvent}, SettingsManager};
use jarvis_core::{i18n, voices, ipc::{self, IpcEvent}, SettingsManager};
const TRAY_ICON_BYTES: &[u8] = include_bytes!("../../../resources/icons/32x32.png");
@ -29,6 +27,7 @@ pub fn init_blocking(settings: SettingsManager) {
.unwrap();
let menu_channel = MenuEvent::receiver();
info!("Tray initialized.");
#[cfg(target_os = "linux")]
{
@ -77,8 +76,6 @@ 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,7 +1,6 @@
use tray_icon::menu::{CheckMenuItem, Menu, MenuItem, PredefinedMenuItem, Submenu};
use jarvis_core::{i18n, voices, SettingsManager};
use jarvis_core::config::structs::{WakeWordEngine, NoiseSuppressionBackend};
// RADIO GROUP
@ -48,7 +47,6 @@ pub fn build(settings: &SettingsManager) -> TrayMenu {
let label = match lang {
"ru" => "Русский",
"en" => "English",
"ua" => "Українська",
_ => lang,
};
let item = CheckMenuItem::with_id(

View file

@ -0,0 +1,12 @@
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,11 +9,88 @@ use seqdiff::ratio;
mod structs;
pub use structs::*;
use crate::{config, i18n, APP_DIR};
use crate::{config, i18n, plugins, 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();
@ -51,10 +128,22 @@ 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)", commands.len());
info!(
"Loaded {} command pack(s) total ({} built-in + {} plugin)",
commands.len(),
commands.len() - plugin_count,
plugin_count
);
Ok(commands)
}
}
@ -266,6 +355,112 @@ 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,10 +82,9 @@ 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 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");
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";
/*
Tray.
@ -194,21 +193,47 @@ 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"],
_ => &[],
}
@ -216,7 +241,7 @@ pub fn get_llm_trigger_phrases(lang: &str) -> &'static [&'static str] {
pub fn get_llm_system_prompt(lang: &str) -> &'static str {
match lang {
"ru" | "ua" => LLM_SYSTEM_PROMPT_RU,
"en" => LLM_SYSTEM_PROMPT_EN,
_ => LLM_SYSTEM_PROMPT_RU,
}
}
@ -247,7 +272,6 @@ 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"],
}
@ -261,11 +285,6 @@ 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",
@ -280,10 +299,6 @@ 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,6 +33,25 @@ 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() }
@ -60,6 +79,9 @@ 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,
}
}
@ -120,6 +142,29 @@ 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(())
@ -142,6 +187,9 @@ impl Settings {
"language",
"api_key__picovoice",
"api_key__openai",
"llm_backend",
"tts_backend",
"custom_wake_word",
]
}
}
@ -173,6 +221,10 @@ 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::{FluentBundle, FluentResource, FluentArgs, FluentValue};
use fluent_bundle::{FluentResource, FluentArgs, FluentValue};
use fluent_bundle::concurrent::FluentBundle as ConcurrentFluentBundle;
use once_cell::sync::OnceCell;
use parking_lot::RwLock;
@ -8,28 +8,21 @@ 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", "ua"];
pub const SUPPORTED_LANGUAGES: &[&str] = &["ru", "en"];
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", "uk-UA", etc.
// locale can be "en-US", "ru-RU", etc.
let lang_code = locale.split(&['-', '_'][..]).next().unwrap_or("");
// 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);
if SUPPORTED_LANGUAGES.contains(&lang_code) {
info!("Detected system language: {} (from locale '{}')", lang_code, locale);
return SUPPORTED_LANGUAGES.iter()
.find(|&&l| l == mapped)
.find(|&&l| l == lang_code)
.unwrap();
}
@ -61,8 +54,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
}
@ -173,7 +165,6 @@ 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,10 +42,9 @@ stats-not-selected = Not selected
stats-loading = Loading...
# ### FOOTER
footer-author = Project author
footer-telegram = Our Telegram channel
footer-github = Github repository
footer-support = Support the project on
footer-github = Project repository
footer-issues = Report a bug
footer-fork-of = Fork of
# ### SETTINGS
settings-title = Settings
@ -84,8 +83,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 all bugs to
settings-beta-bot = our Telegram bot
settings-beta-feedback = Report bugs via
settings-beta-bot = GitHub Issues
settings-open-logs = Open logs folder
# settings - picovoice
@ -112,10 +111,12 @@ 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 = [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
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/
# ### ERRORS
error-generic = An error occurred
@ -140,4 +141,27 @@ settings-gliner-models-hint = No GLiNER models found.
# ETC
search-error-not-running = Assistant is not running
search-error-failed = Failed to execute command
settings-no-voices = No voices found
settings-no-voices = No voices found
# AI Backends tab
settings-ai-backends = AI Backends
settings-ai-active = Active backend
settings-ai-auto = Auto
settings-ai-applying = Applying...
settings-ai-error = Switch failed
settings-ai-tips = Tips
settings-llm-backend = LLM backend
settings-llm-backend-desc = Where to run LLM requests. Hot-swap, no restart needed.
settings-tts-backend = TTS backend
settings-tts-backend-desc = 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,10 +42,9 @@ stats-not-selected = Не выбран
stats-loading = Загрузка...
# FOOTER
footer-author = Автор проекта
footer-telegram = Наш телеграм канал
footer-github = Github репозиторий проекта
footer-support = Поддержать проект на
footer-github = Репозиторий проекта
footer-issues = Сообщить о баге
footer-fork-of = Форк
# SETTINGS
settings-title = Настройки
@ -84,8 +83,8 @@ settings-disabled = Отключено
# settings - beta notice
settings-beta-title = БЕТА версия!
settings-beta-desc = Часть функций может работать некорректно.
settings-beta-feedback = Сообщайте обо всех найденных багах в
settings-beta-bot = наш телеграм бот
settings-beta-feedback = Сообщайте обо всех найденных багах через
settings-beta-bot = GitHub Issues
settings-open-logs = Открыть папку с логами
# settings - picovoice
@ -112,10 +111,12 @@ settings-openai-not-supported = В данный момент ChatGPT не под
commands-title = Команды
commands-search = Поиск команд...
commands-count = { $count } команд
commands-wip-title = [404] Этот раздел еще находится в разработке!
commands-wip-desc = Тут будет список команд + полноценный редактор команд.
commands-wip-follow = Следите за обновлениями в
commands-wip-channel = нашем телеграм канале
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/
# ERRORS
error-generic = Произошла ошибка
@ -140,4 +141,27 @@ settings-gliner-models-hint = Модели GLiNER не найдены.
# ETC
search-error-not-running = Ассистент не запущен
search-error-failed = Не удалось выполнить команду
settings-no-voices = Голоса не найдены
settings-no-voices = Голоса не найдены
# AI Backends tab
settings-ai-backends = ИИ-движки
settings-ai-active = Активный движок
settings-ai-auto = Авто
settings-ai-applying = Применяю...
settings-ai-error = Ошибка переключения
settings-ai-tips = Подсказки
settings-llm-backend = LLM движок
settings-llm-backend-desc = Где исполнять LLM-запросы. Hot-swap, без рестарта.
settings-tts-backend = TTS движок
settings-tts-backend-desc = Применяется сразу — без перезапуска.
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

@ -1,143 +0,0 @@
# ### 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

@ -0,0 +1,304 @@
//! 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,5 +1,8 @@
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,6 +36,18 @@ 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
@ -56,4 +68,18 @@ 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

@ -0,0 +1,203 @@
//! 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,6 +48,31 @@ 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;
use crate::{config, APP_CONFIG_DIR, DB};
// store rustpotter instance
static RUSTPOTTER: OnceCell<Mutex<Rustpotter>> = OnceCell::new();
@ -12,38 +12,87 @@ pub fn init() -> Result<(), ()> {
let rustpotter_config = config::RUSTPOTTER_DEFAULT_CONFIG;
// create rustpotter instance
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));
}
let mut rinstance = match Rustpotter::new(&rustpotter_config) {
Ok(r) => r,
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,12 +2,22 @@ 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}")]
@ -70,9 +80,25 @@ 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,
api_key: String, // empty for Ollama
model: String,
http: reqwest::blocking::Client,
}
@ -82,20 +108,64 @@ 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::new(),
http: reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(60))
.build()
.unwrap_or_else(|_| reqwest::blocking::Client::new()),
}
}
pub fn from_env() -> Result<Self, ConfigError> {
pub fn groq() -> 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(base_url, api_key, model))
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
}
pub fn model(&self) -> &str {
@ -103,21 +173,30 @@ 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: 0.7,
top_p: 1.0,
temperature,
top_p,
};
let resp = self
.http
.post(&url)
.bearer_auth(&self.api_key)
.json(&body)
.send()?;
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 status = resp.status();
let text = resp.text()?;
@ -163,10 +242,10 @@ mod tests {
}
#[test]
fn from_env_fails_when_token_missing() {
fn groq_fails_when_token_missing() {
let prev = env::var(ENV_TOKEN).ok();
unsafe { env::remove_var(ENV_TOKEN); }
let result = LlmClient::from_env();
let result = LlmClient::groq();
let err = match result {
Ok(_) => panic!("expected ConfigError when {ENV_TOKEN} is unset"),
Err(e) => e,
@ -179,10 +258,92 @@ 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,9 +1,15 @@
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 {
@ -12,6 +18,7 @@ impl ConversationHistory {
system: Some(ChatMessage::system(system_prompt)),
turns: Vec::new(),
max_turns: max_turns.max(1),
persist_path: None,
}
}
@ -20,17 +27,42 @@ 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> {
@ -48,22 +80,64 @@ 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") {
self.turns.pop()
let popped = self.turns.pop();
self.persist();
popped
} 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)]
@ -135,4 +209,68 @@ 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,7 +2,195 @@ mod client;
mod history;
pub use client::{
ChatMessage, ConfigError, LlmClient, LlmError,
ChatMessage, ConfigError, LlmBackend, 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

@ -0,0 +1,366 @@
//! 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

@ -4,4 +4,15 @@ pub mod context;
pub mod http;
pub mod fs;
pub mod state;
pub mod system;
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

@ -0,0 +1,29 @@
//! `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

@ -0,0 +1,81 @@
//! `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

@ -0,0 +1,50 @@
//! `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

@ -0,0 +1,140 @@
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

@ -0,0 +1,70 @@
//! 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

@ -0,0 +1,77 @@
//! 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

@ -0,0 +1,63 @@
//! 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

@ -0,0 +1,161 @@
//! 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

@ -78,8 +78,11 @@ pub fn register(lua: &Lua, jarvis: &Table, sandbox: SandboxLevel) -> mlua::Resul
#[cfg(target_os = "windows")]
{
use winrt_notification::{Toast, Duration as ToastDuration};
if let Err(e) = Toast::new(Toast::POWERSHELL_APP_ID)
// 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())
.title(&title)
.text1(&message)
.duration(ToastDuration::Short)

View file

@ -0,0 +1,65 @@
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

@ -0,0 +1,35 @@
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

@ -0,0 +1,189 @@
//! 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,10 +76,21 @@ 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

@ -93,7 +93,7 @@ mod tests {
fn test_sandbox_fs_escape() {
let dir = tempdir().unwrap();
let script_path = dir.path().join("test.lua");
fs::write(&script_path, r#"
local ok, err = pcall(function()
jarvis.fs.read("../../../etc/passwd")
@ -103,10 +103,141 @@ mod tests {
end
return true
"#).unwrap();
let context = create_test_context(dir.path().to_path_buf());
let result = execute(&script_path, context, SandboxLevel::Standard, Duration::from_secs(5));
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

@ -0,0 +1,334 @@
//! 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

@ -0,0 +1,379 @@
//! 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

@ -0,0 +1,309 @@
//! 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

@ -0,0 +1,309 @@
//! 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

@ -0,0 +1,232 @@
//! 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

@ -0,0 +1,622 @@
//! 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,7 +132,6 @@ 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

@ -0,0 +1,207 @@
// 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

@ -0,0 +1,89 @@
//! 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

@ -0,0 +1,350 @@
//! 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

@ -0,0 +1,166 @@
//! 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

@ -0,0 +1,60 @@
//! 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

@ -0,0 +1,137 @@
//! 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,7 +160,11 @@ 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);
audio::play_sound(&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);
}
None => {
warn!("Sound not found: {} (lang: {})", sound_name, lang);

View file

@ -0,0 +1,439 @@
//! 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 }
jarvis-core = { path = "../jarvis-core", default-features = false, features = ["llm"] }
tauri = { version = "2", features = [] } # v1: "shell-open", "dialog-message", "path-all"
tauri-plugin-shell = "2"
tauri-plugin-dialog = "2"
@ -21,6 +21,7 @@ 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,9 +1,40 @@
fn main() {
let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap();
let lib_path = std::path::Path::new(&manifest_dir)
.join("..\\..\\lib\\windows\\amd64");
let manifest_path = std::path::Path::new(&manifest_dir);
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,6 +1,9 @@
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,
@ -16,6 +19,7 @@ pub enum EventTypes {
CommandEnd,
}
#[allow(dead_code)]
impl EventTypes {
pub fn get(&self) -> &str {
match self {
@ -29,6 +33,7 @@ impl EventTypes {
}
}
#[allow(dead_code)]
pub fn play(phrase: &str, app_handle: &tauri::AppHandle) {
app_handle
.emit(

View file

@ -3,7 +3,6 @@
use jarvis_core::{config, db, i18n, voices, DB, SettingsManager};
#[macro_use]
extern crate simple_log;
mod events;
@ -16,8 +15,19 @@ 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");
@ -60,10 +70,9 @@ fn main() {
tauri_commands::get_app_version,
tauri_commands::get_author_name,
tauri_commands::get_repository_link,
tauri_commands::get_tg_official_link,
tauri_commands::get_boosty_link,
tauri_commands::get_patreon_link,
tauri_commands::get_feedback_link,
tauri_commands::get_upstream_link,
tauri_commands::get_issues_link,
tauri_commands::get_python_fork_link,
// fs
tauri_commands::get_log_file_path,
@ -99,7 +108,81 @@ 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

@ -37,4 +37,40 @@ pub use commands::*;
// import voices commands
mod voices;
pub use 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

@ -0,0 +1,124 @@
//! 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

@ -0,0 +1,68 @@
//! 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,68 +1,33 @@
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 {
if let Some(res) = config::APP_VERSION {
res.to_string()
} else {
String::from("error")
}
config::APP_VERSION.unwrap_or("error").to_string()
}
#[tauri::command]
pub fn get_author_name() -> String {
if let Some(res) = config::AUTHOR_NAME {
res.to_string()
} else {
String::from("error")
}
config::AUTHOR_NAME.unwrap_or("error").to_string()
}
#[tauri::command]
pub fn get_repository_link() -> String {
if let Some(res) = config::REPOSITORY_LINK {
res.to_string()
} else {
String::from("error")
}
config::REPOSITORY_LINK.unwrap_or("error").to_string()
}
#[tauri::command]
pub fn get_tg_official_link() -> String {
if let Some(ver) = config::TG_OFFICIAL_LINK {
ver.to_string()
} else {
String::from("error")
}
pub fn get_upstream_link() -> String {
config::UPSTREAM_REPOSITORY_LINK.to_string()
}
#[tauri::command]
pub fn get_boosty_link() -> String {
if let Some(ver) = config::SUPPORT_BOOSTY_LINK {
ver.to_string()
} else {
String::from("error")
}
pub fn get_issues_link() -> String {
config::ISSUES_LINK.to_string()
}
#[tauri::command]
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")
}
pub fn get_python_fork_link() -> String {
config::PYTHON_FORK_LINK.to_string()
}
#[tauri::command]
@ -70,4 +35,4 @@ pub fn get_log_file_path() -> String {
APP_LOG_DIR.get()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "unknown".to_string())
}
}

View file

@ -0,0 +1,91 @@
//! 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

@ -0,0 +1,58 @@
//! 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

@ -0,0 +1,45 @@
//! 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

@ -0,0 +1,47 @@
//! 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

@ -0,0 +1,38 @@
//! 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

@ -0,0 +1,59 @@
//! 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,8 +2,6 @@ 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

@ -0,0 +1,50 @@
//! 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 && vite build",
"build": "routify build && (svelte-check --ignore '.routify' --no-tsconfig || echo svelte-check reported issues but build continues) && vite build",
"preview": "vite preview",
"check": "svelte-check --ignore '.routify' --tsconfig ./tsconfig.json",
"tauri": "tauri"

View file

@ -1,62 +1,132 @@
<script lang="ts">
import { onMount } from "svelte"
import { onMount, onDestroy } from "svelte"
import { invoke } from "@tauri-apps/api/core"
import { appInfo, currentLanguage, translations, translate } from "@/stores"
import {
appInfo, translations, translate,
daemonHealth, ipcConnected, queryDaemonHealth,
} from "@/stores"
$: t = (key: string) => translate($translations, key)
let authorName = ""
let tgLink = ""
let repoLink = ""
let boostyLink = ""
let patreonLink = ""
let upstreamLink = ""
let issuesLink = ""
const currentYear = new Date().getFullYear()
appInfo.subscribe(info => {
tgLink = info.tgOfficialLink
repoLink = info.repositoryLink
boostyLink = info.boostySupportLink
patreonLink = info.patreonSupportLink
upstreamLink = info.upstreamLink
issuesLink = info.issuesLink
})
onMount(async () => {
try {
authorName = await invoke<string>("get_author_name")
} catch (err) {
console.error("failed to get author name:", err)
// 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.
try {
const fallback = await invoke<ActiveBackends>("get_active_backends")
if (!backends) backends = fallback
} catch (err) {
// ignore
}
}
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">
<p>© {currentYear}. {t('footer-author')}: <b>{authorName}</b></p>
{#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 class="links">
{#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">
<a href={repoLink} target="_blank" rel="noopener noreferrer">
<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">
{#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}
<small>
{t('footer-fork-of')}
<a href={upstreamLink} target="_blank" rel="noopener noreferrer">Priler/jarvis</a>
· CC BY-NC-SA 4.0
</small>
</p>
</footer>
@ -69,6 +139,48 @@
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;
@ -79,15 +191,17 @@
&.last {
margin-top: -5px;
color: #8a8d90;
font-size: 11px;
}
}
}
a {
color: #555759!important;
color: #555759 !important;
text-decoration: none;
transition: 0.3s;
& > span {
color: #185876;
border-bottom: 1px solid #185876;
@ -101,7 +215,7 @@
}
&:hover {
color: #777a7d!important;
color: #777a7d !important;
& > span {
color: #2A9CD0;
@ -111,30 +225,6 @@
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,9 +1,9 @@
<script lang="ts">
import { goto } from "@roxi/routify"
import { invoke } from "@tauri-apps/api/core"
import { onMount } from "svelte"
import { onMount, onDestroy } from "svelte"
import { currentLanguage, setLanguage, translations, translate } from "@/stores"
let appVersion = ""
let commandsCount = 0
@ -13,15 +13,54 @@
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
@ -29,6 +68,13 @@
} catch {
commandsCount = 0
}
refreshProfile()
loadProfileNames()
profilePoll = setInterval(refreshProfile, 5000)
})
onDestroy(() => {
if (profilePoll !== null) clearInterval(profilePoll)
})
async function selectLanguage(code: string) {
@ -40,18 +86,21 @@
langDropdownOpen = !langDropdownOpen
}
function closeLangDropdown(e: MouseEvent) {
function closeDropdowns(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={closeLangDropdown} />
<svelte:window on:click={closeDropdowns} />
<header id="header" class="header">
<div class="header-left">
@ -67,11 +116,80 @@
</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>
@ -100,6 +218,79 @@
</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,9 +61,30 @@ 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", "ua"]
return ["ru", "en"]
}
}

View file

@ -12,6 +12,21 @@ 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"
@ -133,6 +148,19 @@ 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
}
}
@ -175,6 +203,30 @@ 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,41 +1,249 @@
<script lang="ts">
import { Notification, Space } from "@svelteuidev/core"
import { InfoCircled } from "radix-icons-svelte"
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 HDivider from "@/components/elements/HDivider.svelte"
import Footer from "@/components/Footer.svelte"
import { appInfo, translations, translate } from "@/stores"
import { currentLanguage, translations, translate } from "@/stores"
$: t = (key: string) => translate($translations, key)
let tgLink = ""
appInfo.subscribe(info => {
tgLink = info.tgOfficialLink
})
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}`
</script>
<Space h="xl" />
<Space h="md" />
<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 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>
</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>
.placeholder-image {
text-align: center;
margin-top: 25px;
<style lang="scss">
.toolbar {
display: flex;
align-items: center;
gap: 8px;
padding: 0 10px;
}
</style>
.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;
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;
}
}
</style>

View file

@ -0,0 +1,313 @@
<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

@ -0,0 +1,306 @@
<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

@ -0,0 +1,249 @@
<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

@ -0,0 +1,237 @@
<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

@ -0,0 +1,238 @@
<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,7 +5,11 @@
import { setTimeout } from "worker-timers"
import { showInExplorer } from "@/functions"
import { appInfo, assistantVoice, translations, translate } from "@/stores"
import {
appInfo, assistantVoice, translations, translate,
switchDaemonLlm, reloadDaemonLlm, switchDaemonTts,
daemonHealth, queryDaemonHealth,
} from "@/stores"
import HDivider from "@/components/elements/HDivider.svelte"
import Footer from "@/components/Footer.svelte"
@ -20,7 +24,8 @@
Input,
InputWrapper,
NativeSelect,
Switch
Switch,
Badge,
} from "@svelteuidev/core"
import {
@ -30,7 +35,8 @@
Code,
Gear,
QuestionMarkCircled,
CrossCircled
CrossCircled,
ChatBubble,
} from "radix-icons-svelte"
$: t = (key: string) => translate($translations, key)
@ -85,15 +91,117 @@
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 feedbackLink = ""
let issuesLink = ""
let logFilePath = ""
appInfo.subscribe(info => {
feedbackLink = info.feedbackLink
issuesLink = info.issuesLink
logFilePath = info.logFilePath
})
@ -166,7 +274,6 @@
const languageNames: Record<string, string> = {
us: 'English',
ru: 'Русский',
uk: 'Українська',
de: 'German',
fr: 'French',
es: 'Spanish',
@ -215,6 +322,19 @@
gainNormalizerEnabled = gainNormalizer === "true"
apiKeyPicovoice = pico
apiKeyOpenai = openai
// load AI backend prefs
try {
const [llm_pref, tts_pref] = await Promise.all([
invoke<string>("db_read", { key: "llm_backend" }),
invoke<string>("db_read", { key: "tts_backend" }),
])
selectedLlmBackend = llm_pref ?? ""
selectedTtsBackend = tts_pref ?? ""
} catch (err) {
console.error("Failed to load backend prefs:", err)
}
await refreshActiveBackends()
} catch (err) {
console.error("failed to load settings:", err)
}
@ -230,7 +350,7 @@
withCloseButton={false}
>
{t('settings-beta-desc')}<br />
{t('settings-beta-feedback')} <a href={feedbackLink} target="_blank">{t('settings-beta-bot')}</a>.
{t('settings-beta-feedback')} <a href={issuesLink} target="_blank" rel="noopener noreferrer">{t('settings-beta-bot')}</a>.
<Space h="sm" />
<Button
color="gray"
@ -307,6 +427,145 @@
/>
</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
@ -495,6 +754,39 @@
<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

@ -0,0 +1,334 @@
<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,6 +8,7 @@ export {
lastRecognizedText,
lastExecutedCommand,
lastError,
daemonHealth,
connectIpc,
enableIpc,
disableIpc,
@ -16,9 +17,15 @@ export {
sendIpcMessage,
sendTextCommand,
stopJarvisApp,
reloadCommands
reloadCommands,
switchDaemonLlm,
reloadDaemonLlm,
switchDaemonTts,
queryDaemonHealth,
} from "./lib/ipc"
export type { DaemonHealth } from "./lib/ipc"
// re-export i18n
export {
translations,
@ -39,12 +46,21 @@ 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({
tgOfficialLink: "",
feedbackLink: "",
repositoryLink: "",
boostySupportLink: "",
patreonSupportLink: "",
repositoryLink: BRANDING_FALLBACK.repositoryLink,
upstreamLink: BRANDING_FALLBACK.upstreamLink,
issuesLink: BRANDING_FALLBACK.issuesLink,
pythonForkLink: BRANDING_FALLBACK.pythonForkLink,
logFilePath: ""
})
@ -59,27 +75,38 @@ export async function loadVoiceSetting() {
}
export async function loadAppInfo() {
try {
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,
boostySupportLink: boosty,
patreonSupportLink: patreon,
logFilePath: logPath
})
} catch (err) {
console.error("failed to load app info:", err)
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", "")
])
appInfo.set({
repositoryLink: repo,
upstreamLink: upstream,
issuesLink: issues,
pythonForkLink: pythonFork,
logFilePath: logPath
})
}
export async function updateJarvisStats() {
@ -98,7 +125,7 @@ let statsInterval: ReturnType<typeof setInterval> | null = null
export function startStatsPolling(intervalMs = 5000) {
if (statsInterval) return // already running
updateJarvisStats()
statsInterval = setInterval(updateJarvisStats, intervalMs)
}
@ -108,4 +135,4 @@ export function stopStatsPolling() {
clearInterval(statsInterval)
statsInterval = null
}
}
}

8
make_ico.py Normal file
View file

@ -0,0 +1,8 @@
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

@ -0,0 +1,191 @@
[[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

@ -0,0 +1,8 @@
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

@ -0,0 +1,9 @@
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