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:
parent
c7ab751e39
commit
c22a24ccd8
11 changed files with 998 additions and 167 deletions
435
README.md
435
README.md
|
|
@ -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. как британского дворецкого Тони Старка — короткие реплики (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 сохранена.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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 = Расписание
|
||||
|
|
@ -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 = Розклад
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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::*;
|
||||
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()
|
||||
}
|
||||
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()
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
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, 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>
|
||||
|
||||
<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)}
|
||||
>
|
||||
<TrashIcon 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>
|
||||
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, 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} /> Отменить
|
||||
</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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue