Compare commits
59 commits
master
...
feature/pc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ff87f3096 | ||
|
|
73fc404ec7 | ||
|
|
965441d4db | ||
|
|
944cfcc891 | ||
|
|
6729e53be6 | ||
|
|
05b75feee4 | ||
|
|
87bb186e94 | ||
|
|
bb3b4f5942 | ||
|
|
bb2799ed8c | ||
|
|
7e1d1c3cc7 | ||
|
|
8f46bd735a | ||
|
|
df799b6cd2 | ||
|
|
919d565879 | ||
|
|
3f8300dc6f | ||
|
|
4e21024509 | ||
|
|
c4b22618f8 | ||
|
|
4d3d664abd | ||
|
|
4efe306b3a | ||
|
|
ea4341fbc9 | ||
|
|
4f83062815 | ||
|
|
d3180b7d78 | ||
|
|
84d3b57ddc | ||
|
|
20b6b06b5d | ||
|
|
41eb47724c | ||
|
|
77063fed86 | ||
|
|
a2dfadf5c1 | ||
|
|
a6a098de15 | ||
|
|
9488190d35 | ||
|
|
c22a24ccd8 | ||
|
|
c7ab751e39 | ||
|
|
dbb4a6b888 | ||
|
|
d63399f4c9 | ||
|
|
b243e67870 | ||
|
|
385bd5c8ce | ||
|
|
5c7245012e | ||
|
|
6225198821 | ||
|
|
45243c3e3c | ||
|
|
12b1ed4ccb | ||
|
|
0b1f1d4480 | ||
|
|
80b54af1ee | ||
|
|
9911b6ec40 | ||
|
|
a3bdf90237 | ||
|
|
a7c002c9d4 | ||
|
|
a16d2401e7 | ||
|
|
9d8b917149 | ||
|
|
dcee98bddf | ||
|
|
ae279aeef7 | ||
|
|
100db82bd6 | ||
|
|
01ea46c091 | ||
|
|
16ef413962 | ||
|
|
3d127f05c0 | ||
|
|
69a38b0d89 | ||
|
|
429113175a | ||
|
|
b257f03f65 | ||
|
|
24457ad6c9 | ||
|
|
85a004e263 | ||
|
|
9c25108356 | ||
|
|
16240609de | ||
|
|
c234c46642 |
410 changed files with 25060 additions and 697 deletions
67
.github/workflows/ci.yml
vendored
Normal file
67
.github/workflows/ci.yml
vendored
Normal 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
18
.gitignore
vendored
|
|
@ -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
295
ARCHITECTURE.md
Normal 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
12
Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
458
README.md
|
|
@ -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 — мастер: запишите 5–30 примеров своего голоса,
|
||||
обучите персональную `.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. как британского дворецкого Тони Старка — короткие реплики (1–3 предложения), обращение «сэр», без излишней цензуры. Меняется в `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
307
USAGE.md
Normal 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 | Мастер: запись 5–30 сэмплов → 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).
|
||||
|
|
@ -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"
|
||||
|
||||
[target.'cfg(target_os = "windows")'.build-dependencies]
|
||||
embed-resource = "3"
|
||||
60
crates/jarvis-app/app.manifest
Normal file
60
crates/jarvis-app/app.manifest
Normal 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>
|
||||
4
crates/jarvis-app/app.manifest.rc
Normal file
4
crates/jarvis-app/app.manifest.rc
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
#define RT_MANIFEST 24
|
||||
#define CREATEPROCESS_MANIFEST_RESOURCE_ID 1
|
||||
|
||||
CREATEPROCESS_MANIFEST_RESOURCE_ID RT_MANIFEST "app.manifest"
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)) =
|
||||
// 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,10 +510,48 @@ 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);
|
||||
|
|
@ -470,8 +559,9 @@ fn execute_command(text: &str, rt: &tokio::runtime::Runtime) -> bool {
|
|||
}
|
||||
|
||||
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
|
|
|
|||
220
crates/jarvis-app/src/llm_router.rs
Normal file
220
crates/jarvis-app/src/llm_router.rs
Normal 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()
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
12
crates/jarvis-core/build.rs
Normal file
12
crates/jarvis-core/build.rs
Normal 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());
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,7 +54,6 @@ 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,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -141,3 +142,26 @@ settings-gliner-models-hint = No GLiNER models found.
|
|||
search-error-not-running = Assistant is not running
|
||||
search-error-failed = Failed to execute command
|
||||
settings-no-voices = No voices found
|
||||
|
||||
# AI Backends tab
|
||||
settings-ai-backends = AI Backends
|
||||
settings-ai-active = Active backend
|
||||
settings-ai-auto = Auto
|
||||
settings-ai-applying = Applying...
|
||||
settings-ai-error = Switch failed
|
||||
settings-ai-tips = Tips
|
||||
settings-llm-backend = LLM backend
|
||||
settings-llm-backend-desc = Where to run LLM requests. Hot-swap, no restart needed.
|
||||
settings-tts-backend = TTS backend
|
||||
settings-tts-backend-desc = Applied immediately — no restart needed.
|
||||
settings-llm-context = Conversation context
|
||||
settings-llm-context-desc = LLM remembers previous turns. Reset if answers get weird.
|
||||
settings-llm-reset = Reset context
|
||||
settings-profile = Profile
|
||||
|
||||
# Header buttons
|
||||
header-macros = Macros
|
||||
header-scheduler = Schedule
|
||||
header-memory = Memory
|
||||
header-plugins = Plugins
|
||||
header-history = History
|
||||
|
|
@ -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 = Произошла ошибка
|
||||
|
|
@ -141,3 +142,26 @@ settings-gliner-models-hint = Модели GLiNER не найдены.
|
|||
search-error-not-running = Ассистент не запущен
|
||||
search-error-failed = Не удалось выполнить команду
|
||||
settings-no-voices = Голоса не найдены
|
||||
|
||||
# AI Backends tab
|
||||
settings-ai-backends = ИИ-движки
|
||||
settings-ai-active = Активный движок
|
||||
settings-ai-auto = Авто
|
||||
settings-ai-applying = Применяю...
|
||||
settings-ai-error = Ошибка переключения
|
||||
settings-ai-tips = Подсказки
|
||||
settings-llm-backend = LLM движок
|
||||
settings-llm-backend-desc = Где исполнять LLM-запросы. Hot-swap, без рестарта.
|
||||
settings-tts-backend = TTS движок
|
||||
settings-tts-backend-desc = Применяется сразу — без перезапуска.
|
||||
settings-llm-context = Контекст разговора
|
||||
settings-llm-context-desc = LLM помнит предыдущие реплики. Сбросить, если ответы становятся странными.
|
||||
settings-llm-reset = Сбросить контекст
|
||||
settings-profile = Профиль
|
||||
|
||||
# Header buttons
|
||||
header-macros = Макросы
|
||||
header-scheduler = Расписание
|
||||
header-memory = Память
|
||||
header-plugins = Плагины
|
||||
header-history = История
|
||||
|
|
@ -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 = Голоси не знайдено
|
||||
304
crates/jarvis-core/src/idle_banter.rs
Normal file
304
crates/jarvis-core/src/idle_banter.rs
Normal 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:00–07: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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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};
|
||||
|
|
@ -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,
|
||||
}
|
||||
203
crates/jarvis-core/src/ipc/tests.rs
Normal file
203
crates/jarvis-core/src/ipc/tests.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
366
crates/jarvis-core/src/long_term_memory.rs
Normal file
366
crates/jarvis-core/src/long_term_memory.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -5,3 +5,14 @@ pub mod http;
|
|||
pub mod fs;
|
||||
pub mod state;
|
||||
pub mod system;
|
||||
pub mod tts;
|
||||
pub mod llm;
|
||||
pub mod text;
|
||||
pub mod memory;
|
||||
pub mod profile;
|
||||
pub mod vision;
|
||||
pub mod scheduler;
|
||||
pub mod cmd;
|
||||
pub mod health;
|
||||
pub mod macros;
|
||||
pub mod banter;
|
||||
29
crates/jarvis-core/src/lua/api/banter.rs
Normal file
29
crates/jarvis-core/src/lua/api/banter.rs
Normal 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(())
|
||||
}
|
||||
81
crates/jarvis-core/src/lua/api/cmd.rs
Normal file
81
crates/jarvis-core/src/lua/api/cmd.rs
Normal 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)
|
||||
}
|
||||
50
crates/jarvis-core/src/lua/api/health.rs
Normal file
50
crates/jarvis-core/src/lua/api/health.rs
Normal 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(())
|
||||
}
|
||||
140
crates/jarvis-core/src/lua/api/llm.rs
Normal file
140
crates/jarvis-core/src/lua/api/llm.rs
Normal 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(())
|
||||
}
|
||||
70
crates/jarvis-core/src/lua/api/macros.rs
Normal file
70
crates/jarvis-core/src/lua/api/macros.rs
Normal 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(())
|
||||
}
|
||||
77
crates/jarvis-core/src/lua/api/memory.rs
Normal file
77
crates/jarvis-core/src/lua/api/memory.rs
Normal 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(())
|
||||
}
|
||||
63
crates/jarvis-core/src/lua/api/profile.rs
Normal file
63
crates/jarvis-core/src/lua/api/profile.rs
Normal 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(())
|
||||
}
|
||||
161
crates/jarvis-core/src/lua/api/scheduler.rs
Normal file
161
crates/jarvis-core/src/lua/api/scheduler.rs
Normal 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -79,7 +79,10 @@ pub fn register(lua: &Lua, jarvis: &Table, sandbox: SandboxLevel) -> mlua::Resul
|
|||
{
|
||||
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)
|
||||
|
|
|
|||
65
crates/jarvis-core/src/lua/api/text.rs
Normal file
65
crates/jarvis-core/src/lua/api/text.rs
Normal 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(())
|
||||
}
|
||||
35
crates/jarvis-core/src/lua/api/tts.rs
Normal file
35
crates/jarvis-core/src/lua/api/tts.rs
Normal 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(())
|
||||
}
|
||||
189
crates/jarvis-core/src/lua/api/vision.rs
Normal file
189
crates/jarvis-core/src/lua/api/vision.rs
Normal 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))
|
||||
}
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -109,4 +109,135 @@ mod tests {
|
|||
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
/// Drive the shunting-yard evaluator from math.lua via a tiny Lua harness
|
||||
/// that inlines just the parser. This lets us assert offline arithmetic
|
||||
/// works without spinning up the LLM or network. We don't include the full
|
||||
/// 250-line math.lua here — just the eval kernel — because the wider
|
||||
/// script reaches into the audio/notify modules.
|
||||
#[test]
|
||||
fn test_math_shunting_yard_basics() {
|
||||
let dir = tempdir().unwrap();
|
||||
let script_path = dir.path().join("eval.lua");
|
||||
|
||||
let script = r#"
|
||||
local function tokenize(s)
|
||||
local tokens = {}
|
||||
local i = 1
|
||||
while i <= #s do
|
||||
local c = s:sub(i, i)
|
||||
if c:match("[%s]") then i = i + 1
|
||||
elseif c:match("[%d%.]") then
|
||||
local j = i
|
||||
while j <= #s and s:sub(j, j):match("[%d%.]") do j = j + 1 end
|
||||
table.insert(tokens, { kind = "num", val = tonumber(s:sub(i, j - 1)) })
|
||||
i = j
|
||||
elseif c == "+" or c == "-" or c == "*" or c == "/" or c == "^" then
|
||||
if (c == "-" or c == "+") and (#tokens == 0 or tokens[#tokens].kind == "op" or tokens[#tokens].kind == "lparen") then
|
||||
local j = i + 1
|
||||
while j <= #s and s:sub(j, j):match("[%s]") do j = j + 1 end
|
||||
if j <= #s and s:sub(j, j):match("[%d%.]") then
|
||||
local k = j
|
||||
while k <= #s and s:sub(k, k):match("[%d%.]") do k = k + 1 end
|
||||
local v = tonumber(s:sub(j, k - 1))
|
||||
if v then
|
||||
table.insert(tokens, { kind = "num", val = (c == "-" and -v or v) })
|
||||
i = k
|
||||
else return nil end
|
||||
else return nil end
|
||||
else
|
||||
table.insert(tokens, { kind = "op", val = c }); i = i + 1
|
||||
end
|
||||
elseif c == "(" then table.insert(tokens, { kind = "lparen" }); i = i + 1
|
||||
elseif c == ")" then table.insert(tokens, { kind = "rparen" }); i = i + 1
|
||||
else return nil end
|
||||
end
|
||||
return tokens
|
||||
end
|
||||
|
||||
local function precedence(op)
|
||||
if op == "+" or op == "-" then return 1 end
|
||||
if op == "*" or op == "/" then return 2 end
|
||||
if op == "^" then return 3 end
|
||||
return 0
|
||||
end
|
||||
|
||||
local function eval_op(a, b, op)
|
||||
if op == "+" then return a + b end
|
||||
if op == "-" then return a - b end
|
||||
if op == "*" then return a * b end
|
||||
if op == "/" then if b == 0 then return nil end; return a / b end
|
||||
if op == "^" then return a ^ b end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function eval(tokens)
|
||||
if not tokens or #tokens == 0 then return nil end
|
||||
local values, ops = {}, {}
|
||||
local function apply()
|
||||
local op = table.remove(ops)
|
||||
local b = table.remove(values)
|
||||
local a = table.remove(values)
|
||||
if a == nil or b == nil then return false end
|
||||
local r = eval_op(a, b, op)
|
||||
if r == nil then return false end
|
||||
table.insert(values, r)
|
||||
return true
|
||||
end
|
||||
for _, t in ipairs(tokens) do
|
||||
if t.kind == "num" then table.insert(values, t.val)
|
||||
elseif t.kind == "op" then
|
||||
while #ops > 0 and ops[#ops] ~= "(" and precedence(ops[#ops]) >= precedence(t.val) do
|
||||
if not apply() then return nil end
|
||||
end
|
||||
table.insert(ops, t.val)
|
||||
elseif t.kind == "lparen" then table.insert(ops, "(")
|
||||
elseif t.kind == "rparen" then
|
||||
while #ops > 0 and ops[#ops] ~= "(" do
|
||||
if not apply() then return nil end
|
||||
end
|
||||
if ops[#ops] ~= "(" then return nil end
|
||||
table.remove(ops)
|
||||
end
|
||||
end
|
||||
while #ops > 0 do
|
||||
if ops[#ops] == "(" then return nil end
|
||||
if not apply() then return nil end
|
||||
end
|
||||
if #values ~= 1 then return nil end
|
||||
return values[1]
|
||||
end
|
||||
|
||||
local cases = {
|
||||
{ "2 + 2", 4 },
|
||||
{ "10 - 4", 6 },
|
||||
{ "3 * 5", 15 },
|
||||
{ "20 / 4", 5 },
|
||||
{ "2 ^ 10", 1024 },
|
||||
{ "1 + 2 * 3", 7 },
|
||||
{ "(1 + 2) * 3", 9 },
|
||||
{ "-5 + 10", 5 },
|
||||
{ "100 / 0", nil },
|
||||
{ "abc", nil },
|
||||
}
|
||||
for _, c in ipairs(cases) do
|
||||
local got = eval(tokenize(c[1]))
|
||||
if got ~= c[2] then
|
||||
error(string.format("case %q: expected %s, got %s",
|
||||
c[1], tostring(c[2]), tostring(got)))
|
||||
end
|
||||
end
|
||||
return true
|
||||
"#;
|
||||
|
||||
fs::write(&script_path, script).unwrap();
|
||||
let context = create_test_context(dir.path().to_path_buf());
|
||||
let result = execute(
|
||||
&script_path,
|
||||
context,
|
||||
SandboxLevel::Minimal,
|
||||
Duration::from_secs(5),
|
||||
);
|
||||
assert!(result.is_ok(), "shunting yard test failed: {:?}", result);
|
||||
}
|
||||
}
|
||||
334
crates/jarvis-core/src/macros.rs
Normal file
334
crates/jarvis-core/src/macros.rs
Normal 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("запись макроса"));
|
||||
}
|
||||
}
|
||||
379
crates/jarvis-core/src/plugins.rs
Normal file
379
crates/jarvis-core/src/plugins.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
309
crates/jarvis-core/src/profiles.rs
Normal file
309
crates/jarvis-core/src/profiles.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
309
crates/jarvis-core/src/recognition_log.rs
Normal file
309
crates/jarvis-core/src/recognition_log.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
232
crates/jarvis-core/src/runtime_config.rs
Normal file
232
crates/jarvis-core/src/runtime_config.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
622
crates/jarvis-core/src/scheduler.rs
Normal file
622
crates/jarvis-core/src/scheduler.rs
Normal 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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
|||
207
crates/jarvis-core/src/text_utils.rs
Normal file
207
crates/jarvis-core/src/text_utils.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
89
crates/jarvis-core/src/toast.rs
Normal file
89
crates/jarvis-core/src/toast.rs
Normal 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
|
||||
}
|
||||
350
crates/jarvis-core/src/tts/mod.rs
Normal file
350
crates/jarvis-core/src/tts/mod.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
166
crates/jarvis-core/src/tts/piper.rs
Normal file
166
crates/jarvis-core/src/tts/piper.rs
Normal 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
|
||||
}
|
||||
60
crates/jarvis-core/src/tts/sapi.rs
Normal file
60
crates/jarvis-core/src/tts/sapi.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
137
crates/jarvis-core/src/tts/silero.rs
Normal file
137
crates/jarvis-core/src/tts/silero.rs
Normal 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
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
439
crates/jarvis-core/src/wake_trainer.rs
Normal file
439
crates/jarvis-core/src/wake_trainer.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,3 +38,39 @@ pub use commands::*;
|
|||
// import voices commands
|
||||
mod voices;
|
||||
pub use voices::*;
|
||||
|
||||
// LLM/TTS backend introspection + hot-swap
|
||||
mod backends;
|
||||
pub use backends::*;
|
||||
|
||||
// Voice macros
|
||||
mod macros;
|
||||
pub use macros::*;
|
||||
|
||||
// Scheduler tasks
|
||||
mod scheduler;
|
||||
pub use scheduler::*;
|
||||
|
||||
// Long-term memory facts
|
||||
mod memory;
|
||||
pub use memory::*;
|
||||
|
||||
// Profile switching
|
||||
mod profile;
|
||||
pub use profile::*;
|
||||
|
||||
// User-installed plugins (APP_CONFIG_DIR/plugins/<name>/)
|
||||
mod plugins;
|
||||
pub use plugins::*;
|
||||
|
||||
// Python command builder launcher
|
||||
mod builder;
|
||||
pub use builder::*;
|
||||
|
||||
// Recognition history page
|
||||
mod history;
|
||||
pub use history::*;
|
||||
|
||||
// Wake-word training wizard
|
||||
mod wake_trainer;
|
||||
pub use wake_trainer::*;
|
||||
124
crates/jarvis-gui/src/tauri_commands/backends.rs
Normal file
124
crates/jarvis-gui/src/tauri_commands/backends.rs
Normal 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()
|
||||
}
|
||||
68
crates/jarvis-gui/src/tauri_commands/builder.rs
Normal file
68
crates/jarvis-gui/src/tauri_commands/builder.rs
Normal 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()))
|
||||
}
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
91
crates/jarvis-gui/src/tauri_commands/history.rs
Normal file
91
crates/jarvis-gui/src/tauri_commands/history.rs
Normal 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()
|
||||
}
|
||||
58
crates/jarvis-gui/src/tauri_commands/macros.rs
Normal file
58
crates/jarvis-gui/src/tauri_commands/macros.rs
Normal 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()
|
||||
}
|
||||
45
crates/jarvis-gui/src/tauri_commands/memory.rs
Normal file
45
crates/jarvis-gui/src/tauri_commands/memory.rs
Normal 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()
|
||||
}
|
||||
47
crates/jarvis-gui/src/tauri_commands/plugins.rs
Normal file
47
crates/jarvis-gui/src/tauri_commands/plugins.rs
Normal 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())
|
||||
}
|
||||
38
crates/jarvis-gui/src/tauri_commands/profile.rs
Normal file
38
crates/jarvis-gui/src/tauri_commands/profile.rs
Normal 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,
|
||||
})
|
||||
}
|
||||
59
crates/jarvis-gui/src/tauri_commands/scheduler.rs
Normal file
59
crates/jarvis-gui/src/tauri_commands/scheduler.rs
Normal 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()
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
50
crates/jarvis-gui/src/tauri_commands/wake_trainer.rs
Normal file
50
crates/jarvis-gui/src/tauri_commands/wake_trainer.rs
Normal 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)
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
<span>{t('footer-telegram')}</span>
|
||||
</a>
|
||||
|
||||
{/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" />
|
||||
<span>{t('footer-github')}</span>
|
||||
</a>
|
||||
·
|
||||
<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,12 +191,14 @@
|
|||
|
||||
&.last {
|
||||
margin-top: -5px;
|
||||
color: #8a8d90;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
color: #555759!important;
|
||||
color: #555759 !important;
|
||||
text-decoration: none;
|
||||
transition: 0.3s;
|
||||
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<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 = ""
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
<style lang="scss">
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
.reload-btn {
|
||||
background: transparent;
|
||||
border: 1px solid #3a3d40;
|
||||
color: #c8cacc;
|
||||
border-radius: 8px;
|
||||
height: 34px;
|
||||
width: 34px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: 0.2s;
|
||||
&:hover {
|
||||
border-color: #5b8def;
|
||||
color: #5b8def;
|
||||
}
|
||||
}
|
||||
.meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px 12px;
|
||||
font-size: 12px;
|
||||
color: #8a8d90;
|
||||
.count {
|
||||
background: #5b8def;
|
||||
color: #fff;
|
||||
padding: 1px 8px;
|
||||
border-radius: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.dot { color: #46484b; }
|
||||
}
|
||||
.state {
|
||||
padding: 30px 12px;
|
||||
text-align: center;
|
||||
margin-top: 25px;
|
||||
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>
|
||||
313
frontend/src/routes/history/index.svelte
Normal file
313
frontend/src/routes/history/index.svelte
Normal 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>
|
||||
|
||||
<Badge color="orange" size="md" variant="filled">✗ {misses}</Badge>
|
||||
|
||||
<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}
|
||||
/>
|
||||
|
||||
<Button color="gray" radius="md" size="sm" on:click={load}>
|
||||
<Reload size={14} /> Обновить
|
||||
</Button>
|
||||
|
||||
<Button color="red" radius="md" size="sm" variant="outline" on:click={clearAll}>
|
||||
<Trash size={14} /> Очистить
|
||||
</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>
|
||||
306
frontend/src/routes/macros/index.svelte
Normal file
306
frontend/src/routes/macros/index.svelte
Normal 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>
|
||||
|
||||
<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} /> Начать запись
|
||||
</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} /> Запустить
|
||||
{/if}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
color="red" radius="md" size="xs" uppercase
|
||||
on:click={() => deleteMacro(m.name)}
|
||||
>
|
||||
<Trash size={14} /> Удалить
|
||||
</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>
|
||||
249
frontend/src/routes/memory/index.svelte
Normal file
249
frontend/src/routes/memory/index.svelte
Normal 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} /> Добавить
|
||||
</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} /> Забыть
|
||||
</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>
|
||||
237
frontend/src/routes/plugins/index.svelte
Normal file
237
frontend/src/routes/plugins/index.svelte
Normal 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} /> Открыть папку
|
||||
</Button>
|
||||
|
||||
<Button color="gray" radius="md" size="sm" on:click={load}>
|
||||
<Reload size={14} /> Обновить список
|
||||
</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>
|
||||
238
frontend/src/routes/scheduler/index.svelte
Normal file
238
frontend/src/routes/scheduler/index.svelte
Normal 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} /> Отменить
|
||||
</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>
|
||||
|
|
@ -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}
|
||||
· TTS: <strong>{$daemonHealth.tts_backend}</strong>
|
||||
· {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}
|
||||
· TTS: <strong>{activeBackends.tts}</strong>
|
||||
· {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"
|
||||
|
|
|
|||
334
frontend/src/routes/wake-trainer/index.svelte
Normal file
334
frontend/src/routes/wake-trainer/index.svelte
Normal 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">
|
||||
Запишите 5–30 примеров своего голоса, и Jarvis обучит персональный
|
||||
Rustpotter-детектор. Файл сохраняется как
|
||||
<code>%APPDATA%\com.priler.jarvis\wake_words\<имя>.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} />
|
||||
{busy ? "Запись..." : `Записать сэмпл (${status.collected + 1})`}
|
||||
</Button>
|
||||
|
||||
<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} /> Обучить и сохранить
|
||||
</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 минимум, 10–15 рекомендовано"
|
||||
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} /> Начать запись
|
||||
</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} /> Удалить
|
||||
</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>
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
8
make_ico.py
Normal file
8
make_ico.py
Normal 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})")
|
||||
191
resources/commands/apps/command.toml
Normal file
191
resources/commands/apps/command.toml
Normal 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",
|
||||
]
|
||||
8
resources/commands/apps/lock_screen.lua
Normal file
8
resources/commands/apps/lock_screen.lua
Normal 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 }
|
||||
9
resources/commands/apps/open_browser.lua
Normal file
9
resources/commands/apps/open_browser.lua
Normal 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
Loading…
Add table
Add a link
Reference in a new issue