feat(gui): /macros + /scheduler pages + README rewrite

Surfaces voice macros and scheduled tasks in the GUI, so the user doesn't
have to remember voice commands or grep schedule.json by hand.

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

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

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

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

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

Build: cargo build --release -p jarvis-gui green (2m). 55/55 tests pass.
This commit is contained in:
Bossiara13 2026-05-15 17:28:22 +03:00
parent c7ab751e39
commit c22a24ccd8
11 changed files with 998 additions and 167 deletions

435
README.md
View file

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

View file

@ -157,4 +157,8 @@ settings-tts-backend-desc = Takes effect on next jarvis-app launch.
settings-llm-context = Conversation context
settings-llm-context-desc = LLM remembers previous turns. Reset if answers get weird.
settings-llm-reset = Reset context
settings-profile = Profile
settings-profile = Profile
# Header buttons
header-macros = Macros
header-scheduler = Schedule

View file

@ -157,4 +157,8 @@ settings-tts-backend-desc = Применится при следующем за
settings-llm-context = Контекст разговора
settings-llm-context-desc = LLM помнит предыдущие реплики. Сбросить, если ответы становятся странными.
settings-llm-reset = Сбросить контекст
settings-profile = Профиль
settings-profile = Профиль
# Header buttons
header-macros = Макросы
header-scheduler = Расписание

View file

@ -141,4 +141,24 @@ settings-gliner-models-hint = Моделі GLiNER не знайдено.
# ETC
search-error-not-running = Асистент не запущено
search-error-failed = Не вдалося виконати команду
settings-no-voices = Голоси не знайдено
settings-no-voices = Голоси не знайдено
# AI Backends tab
settings-ai-backends = ШІ-рушії
settings-ai-active = Активний рушій
settings-ai-auto = Авто
settings-ai-applying = Застосовую...
settings-ai-error = Помилка перемикання
settings-ai-tips = Підказки
settings-llm-backend = LLM рушій
settings-llm-backend-desc = Де виконувати LLM-запити. Hot-swap, без перезапуску.
settings-tts-backend = TTS рушій
settings-tts-backend-desc = Застосується при наступному запуску jarvis-app.
settings-llm-context = Контекст розмови
settings-llm-context-desc = LLM пам'ятає попередні репліки. Скиньте, якщо відповіді дивні.
settings-llm-reset = Скинути контекст
settings-profile = Профіль
# Header buttons
header-macros = Макроси
header-scheduler = Розклад

View file

@ -107,6 +107,21 @@ fn main() {
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,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");

View file

@ -41,4 +41,12 @@ pub use voices::*;
// LLM/TTS backend introspection + hot-swap
mod backends;
pub use backends::*;
pub use backends::*;
// Voice macros
mod macros;
pub use macros::*;
// Scheduler tasks
mod scheduler;
pub use scheduler::*;

View file

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

View file

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

View file

@ -71,7 +71,15 @@
<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('/settings')}>
<span class="btn-text">{t('header-settings')}</span>
</button>

View file

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

View file

@ -0,0 +1,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, TrashIcon, 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)}
>
<TrashIcon size={14} /> &nbsp; Отменить
</Button>
</div>
</div>
{/each}
</div>
<Space h="md" />
<Button color="red" radius="md" size="sm" uppercase fullSize on:click={clearAll}>
Очистить всё ({tasks.length})
</Button>
{/if}
<Space h="xl" />
<Button color="gray" radius="md" size="sm" uppercase fullSize on:click={() => $goto("/")}>
Назад
</Button>
<HDivider />
<Footer />
<style lang="scss">
.page-title {
margin: 0 0 4px 0;
color: #fff;
font-size: 1.3rem;
font-weight: 600;
}
.task-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.task-card {
background: rgba(30, 40, 45, 0.75);
border: 1px solid rgba(255, 168, 60, 0.18);
border-radius: 10px;
padding: 0.85rem 1rem;
}
.task-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
gap: 0.5rem;
}
.task-title {
display: flex;
gap: 0.5rem;
align-items: center;
}
.task-name {
font-size: 0.95rem;
font-weight: 600;
color: #fff;
}
.task-body {
background: rgba(0, 0, 0, 0.2);
border-radius: 6px;
padding: 0.4rem 0.6rem;
margin-bottom: 0.5rem;
color: rgba(255, 255, 255, 0.85);
font-size: 0.85rem;
line-height: 1.4em;
}
.task-meta {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
color: rgba(255, 255, 255, 0.4);
font-size: 0.7rem;
margin-bottom: 0.5rem;
code {
background: rgba(255, 255, 255, 0.05);
padding: 1px 5px;
border-radius: 3px;
font-size: 0.65rem;
color: rgba(255, 255, 255, 0.6);
}
}
.task-actions {
display: flex;
gap: 0.4rem;
}
</style>