Compare commits
30 commits
v0.0.1-imp
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b055fa08c | ||
|
|
9a1c047282 | ||
|
|
d23c7fbd12 | ||
|
|
acebb02a8e | ||
|
|
a3389f8bf7 | ||
|
|
37c8371f3f | ||
|
|
3f19366ad0 | ||
|
|
ff70e6019f | ||
|
|
3429d934e3 | ||
|
|
8a2b3f6206 | ||
|
|
e5aaa7e064 | ||
|
|
176f405310 | ||
|
|
1e2c883e68 | ||
|
|
40f2f8b5f9 | ||
|
|
b2045cf950 | ||
|
|
23c57b855b | ||
|
|
5b7a2d15d4 | ||
|
|
3d9a95a2db | ||
|
|
02f5829aaa | ||
|
|
d6792fc038 | ||
|
|
3b4835935f | ||
|
|
a58b832b44 | ||
|
|
cb140b1c64 | ||
|
|
fb5c096327 | ||
|
|
893a83d197 | ||
|
|
08245d7de6 | ||
|
|
3c81525ab6 | ||
|
|
54f90bb7c8 | ||
|
|
8883492715 | ||
|
|
61009fd871 |
27 changed files with 1005 additions and 12403 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -53,3 +53,6 @@ tree.txt
|
|||
|
||||
# Ignore AI models
|
||||
/resources/models/*
|
||||
|
||||
# Tauri-generated platform schemas (regenerated on every build)
|
||||
crates/jarvis-gui/gen/schemas/*-schema.json
|
||||
|
|
|
|||
11
Cargo.lock
generated
11
Cargo.lock
generated
|
|
@ -3324,12 +3324,14 @@ dependencies = [
|
|||
"sha2",
|
||||
"sys-locale",
|
||||
"tempfile",
|
||||
"thiserror 2.0.17",
|
||||
"tokenizers",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
"toml 0.9.11+spec-1.1.0",
|
||||
"unic-langid",
|
||||
"vosk",
|
||||
"webrtc-vad",
|
||||
"winrt-notification",
|
||||
]
|
||||
|
||||
|
|
@ -8597,6 +8599,15 @@ dependencies = [
|
|||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webrtc-vad"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39a1e40fd6ca90be95459152a2537f2ba4286ee1b13073f7ebcaa74fc94e3008"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webview2-com"
|
||||
version = "0.38.2"
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ resolver = "2"
|
|||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
authors = ["Abraham Tugalov"]
|
||||
authors = ["Abraham Tugalov (original)", "Bossiara13 (fork)"]
|
||||
license = "GPL-3.0-only"
|
||||
repository = "https://github.com/Priler/jarvis"
|
||||
edition = "2021"
|
||||
|
|
@ -43,6 +43,7 @@ unic-langid = "0.9"
|
|||
chrono = "0.4"
|
||||
mlua = { version = "0.11.5", features = ["lua55", "vendored", "async", "serde"] }
|
||||
reqwest = { version = "0.13.1", features = ["blocking", "json"] }
|
||||
thiserror = "2"
|
||||
tempfile = "^3.24"
|
||||
winrt-notification = "0.5"
|
||||
|
||||
|
|
@ -52,4 +53,5 @@ ndarray = "0.17"
|
|||
tokenizers = { version = "0.22", default-features = false }
|
||||
regex = "1"
|
||||
sys-locale = "0.3"
|
||||
webrtc-vad = "0.4"
|
||||
|
||||
|
|
|
|||
206
README.md
206
README.md
|
|
@ -1,65 +1,177 @@
|
|||
# JARVIS Voice Assistant (this readme is outdated)
|
||||
# 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>.
|
||||
|
||||
`Jarvis` - is a voice assistant made as an experiment using neural networks for things like **STT/TTS/Wake Word/NLU** etc.
|
||||
## Что это
|
||||
|
||||
The main project challenges we try to achieve is:
|
||||
- 100% offline *(no cloud)*
|
||||
- Open source *(full transparency)*
|
||||
- No data collection *(we respect your privacy)*
|
||||
Голосовой ассистент, написанный на Rust, работающий локально (без облака).
|
||||
Текущий стек:
|
||||
|
||||
Our backend stack is 🦀 **[Rust](https://www.rust-lang.org/)** with ❤️ **[Tauri](https://tauri.app/)**.<br>
|
||||
For the frontend we use ⚡️ **[Vite](https://vitejs.dev/)** + 🛠️ **[Svelte](https://svelte.dev/)**.
|
||||
- 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`).
|
||||
|
||||
*Other libraries, tools and packages can be found in source code.*
|
||||
**LLM-клиент (Groq / OpenAI-совместимый) добавлен в `jarvis-core::llm` и подключён к голосовому циклу.** Если фраза начинается с триггера («скажи», «ответь», «произнеси»), она уходит в Groq и ответ возвращается через IPC-событие `LlmReply`. Без триггера всё работает как раньше — wake-word + intent + Lua. Это следующий шаг после CLI-only LLM из v0.2.0.
|
||||
|
||||
## Neural Networks
|
||||
## Это форк
|
||||
|
||||
This are the neural networks we are currently using:
|
||||
Оригинальный автор — Abraham Tugalov (Priler).
|
||||
Апстрим: <https://github.com/Priler/jarvis>.
|
||||
Лицензия сохранена: **CC BY-NC-SA 4.0** (см. `LICENSE.txt`).
|
||||
Атрибуция в `Cargo.toml` и `voice.toml` пакетов озвучки не изменена.
|
||||
|
||||
- Speech-To-Text
|
||||
- [Vosk Speech Recognition Toolkit](https://github.com/alphacep/vosk-api) via [Vosk-rs](https://github.com/Bear-03/vosk-rs)
|
||||
- Text-To-Speech
|
||||
- [~~Silero TTS~~](https://github.com/snakers4/silero-models) *(currently not used)*
|
||||
- [~~Coqui TTS~~](https://github.com/coqui-ai/TTS) *(currently not used)*
|
||||
- [~~WinRT~~](https://github.com/ndarilek/tts-rs) *(currently not used)*
|
||||
- [~gTTS~](https://github.com/nightlyistaken/tts_rust) *(currently not used)*
|
||||
- [~~SAM~~](https://github.com/s-macke/SAM) *(currently not used)*
|
||||
- Wake Word
|
||||
- [Rustpotter](https://github.com/GiviMAD/rustpotter) *(Partially implemented, still WIP)*
|
||||
- [Picovoice Porcupine](https://github.com/Picovoice/porcupine) via [official SDK](https://github.com/Picovoice/porcupine#rust) *(requires API key)*
|
||||
- [Vosk Speech Recognition Toolkit](https://github.com/alphacep/vosk-api) via [Vosk-rs](https://github.com/Bear-03/vosk-rs) *(very slow)*
|
||||
- [~~Snowboy~~](https://github.com/Kitt-AI/snowboy) *(currently not used)*
|
||||
- NLU
|
||||
- Nothing yet.
|
||||
- Chat
|
||||
- [~~ChatGPT~~](https://chat.openai.com/) (coming soon)
|
||||
## Что отличается от апстрима
|
||||
|
||||
## Supported Languages
|
||||
- Обновлён список авторов в `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) и запушены в форк.
|
||||
|
||||
Currently, only Russian language is supported.<br>
|
||||
But soon, Ukranian and English will be added for the interface, wake-word detection and speech recognition.
|
||||
## Структура репозитория
|
||||
|
||||
## How to build?
|
||||
Cargo workspace из четырёх крейтов:
|
||||
|
||||
Nothing special was used to build this project.<br>
|
||||
You need only Rust and NodeJS installed on your system.<br>
|
||||
Other than that, all you need is to install all the dependencies and then compile the code with `cargo tauri build` command.<br>
|
||||
Or run dev with `cargo tauri dev`.
|
||||
| Крейт | Назначение |
|
||||
|----------------|----------------------------------------------------------------------------|
|
||||
| `jarvis-core` | Библиотека: конфиг, intent, STT, wake-word, аудио, Lua-бэкенд, i18n. |
|
||||
| `jarvis-app` | Бинарь-«демон»: собирает всё вместе, tray, IPC. |
|
||||
| `jarvis-gui` | Tauri-приложение (использует `frontend/dist/client`). |
|
||||
| `jarvis-cli` | CLI для отладки: классификация intent, список команд, dump конфига. |
|
||||
|
||||
<br><br>
|
||||
*Thought you might need some of the platform specific libraries for [PvRecorder](https://github.com/Picovoice/pvrecorder) and [Vosk](https://github.com/alphacep/vosk-api).*
|
||||
Прочее:
|
||||
|
||||
## Author
|
||||
- `frontend/` — Vite + Svelte UI для `jarvis-gui`. Собирается отдельно.
|
||||
- `lib/windows/amd64/` — нативные DLL/LIB для Vosk, Porcupine, PvRecorder.
|
||||
- `resources/` — голоса, модели, конфиги по умолчанию. ONNX-модели хранятся в Git LFS.
|
||||
- `post_build.py` — постпроцессинг артефактов сборки (Python 3).
|
||||
|
||||
Abraham Tugalov
|
||||
## Сборка
|
||||
|
||||
## Python version?
|
||||
Old version of Jarvis was built with Python.<br>
|
||||
The last Python version commit can be found [here](https://github.com/Priler/jarvis/tree/943efbfbdb8aeb5889fa5e2dc7348ca4ea0b81df).
|
||||
Требования:
|
||||
|
||||
## License
|
||||
- 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/` (уже в репозитории).
|
||||
|
||||
[Attribution-NonCommercial-ShareAlike 4.0 International](https://creativecommons.org/licenses/by-nc-sa/4.0/)<br>
|
||||
See LICENSE.txt file for more details.
|
||||
Перед сборкой `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`.
|
||||
|
||||
## Python-версия
|
||||
|
||||
Старая версия ассистента была на Python.
|
||||
Последний коммит с Python-кодом в апстриме — [943efbf](https://github.com/Priler/jarvis/tree/943efbfbdb8aeb5889fa5e2dc7348ca4ea0b81df).
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
use std::sync::mpsc::Receiver;
|
||||
use std::time::SystemTime;
|
||||
|
||||
use jarvis_core::{audio_buffer::AudioRingBuffer, audio_processing, commands, config, listener, recorder, stt, COMMANDS_LIST, intent, voices, ipc::{self, IpcEvent}, i18n, slots};
|
||||
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;
|
||||
|
|
@ -160,8 +160,9 @@ fn recognize_command(
|
|||
let mut start = SystemTime::now();
|
||||
let mut first_recognition = prefed_audio;
|
||||
|
||||
// longer silence threshold for commands (user might pause to think)
|
||||
// 5 seconds
|
||||
let mut webrtc_vad = WebRtcVad::with_aggressiveness(config::VAD_AGGRESSIVENESS);
|
||||
let mut window = ListenWindow::new(jarvis_core::audio_processing::vad::webrtc::FRAME_MS);
|
||||
|
||||
let silence_threshold: u32 = ((5.0 * sample_rate as f32) / frame_length as f32) as u32;
|
||||
|
||||
loop {
|
||||
|
|
@ -172,6 +173,35 @@ fn recognize_command(
|
|||
recorder::read_microphone(frame_buffer);
|
||||
let processed = audio_processing::process(frame_buffer);
|
||||
|
||||
let mut vad_finalized: Option<String> = None;
|
||||
let mut vad_hard_cap = false;
|
||||
for is_speech in webrtc_vad.push_samples(frame_buffer) {
|
||||
match window.push(is_speech) {
|
||||
WindowDecision::KeepListening => {}
|
||||
WindowDecision::Close => {
|
||||
if window.had_speech() {
|
||||
info!(
|
||||
"VAD: silence after speech ({} ms speech, {} ms silence), finalizing.",
|
||||
window.speech_ms(), window.silence_ms()
|
||||
);
|
||||
vad_finalized = stt::finalize_speech();
|
||||
if vad_finalized.is_none() {
|
||||
return;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
WindowDecision::HardCap => {
|
||||
info!("VAD: hard cap reached ({} ms), returning to wake word mode.", window.elapsed_ms());
|
||||
vad_hard_cap = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if vad_hard_cap {
|
||||
return;
|
||||
}
|
||||
|
||||
match vad_state {
|
||||
VadState::WaitingForVoice => {
|
||||
audio_buffer.push(frame_buffer);
|
||||
|
|
@ -194,8 +224,9 @@ fn recognize_command(
|
|||
}
|
||||
|
||||
VadState::VoiceActive => {
|
||||
// feed to STT
|
||||
if let Some(mut recognized_voice) = stt::recognize(frame_buffer, false) {
|
||||
// feed to STT (or use VAD-forced finalization)
|
||||
let recognized = vad_finalized.take().or_else(|| stt::recognize(frame_buffer, false));
|
||||
if let Some(mut recognized_voice) = recognized {
|
||||
info!("Recognized voice: {}", recognized_voice);
|
||||
|
||||
ipc::send(IpcEvent::SpeechRecognized {
|
||||
|
|
@ -227,6 +258,8 @@ fn recognize_command(
|
|||
silence_frames = 0;
|
||||
start = SystemTime::now();
|
||||
audio_buffer.clear();
|
||||
webrtc_vad.reset();
|
||||
window = ListenWindow::new(jarvis_core::audio_processing::vad::webrtc::FRAME_MS);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -240,6 +273,8 @@ fn recognize_command(
|
|||
silence_frames = 0;
|
||||
start = SystemTime::now();
|
||||
audio_buffer.clear();
|
||||
webrtc_vad.reset();
|
||||
window = ListenWindow::new(jarvis_core::audio_processing::vad::webrtc::FRAME_MS);
|
||||
continue;
|
||||
} else {
|
||||
// wake word + command in one phrase - execute the command part
|
||||
|
|
@ -251,6 +286,21 @@ fn recognize_command(
|
|||
|
||||
first_recognition = false;
|
||||
|
||||
if crate::llm_fallback::is_enabled() {
|
||||
if let Some(prompt) = crate::llm_fallback::extract_prompt(&recognized_voice) {
|
||||
crate::llm_fallback::handle(&prompt);
|
||||
stt::reset_speech_recognizer();
|
||||
vad_state = VadState::WaitingForVoice;
|
||||
silence_frames = 0;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// filter activation phrases
|
||||
// for tbr in config::ASSISTANT_PHRASES_TBR {
|
||||
// recognized_voice = recognized_voice.replace(tbr, "");
|
||||
|
|
@ -281,6 +331,8 @@ fn recognize_command(
|
|||
silence_frames = 0;
|
||||
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 {
|
||||
|
|
@ -320,6 +372,14 @@ fn process_text_command(text: &str, rt: &tokio::runtime::Runtime) {
|
|||
|
||||
ipc::send(IpcEvent::SpeechRecognized { text: text.to_string() });
|
||||
|
||||
if crate::llm_fallback::is_enabled() {
|
||||
if let Some(prompt) = crate::llm_fallback::extract_prompt(text) {
|
||||
crate::llm_fallback::handle(&prompt);
|
||||
ipc::send(IpcEvent::Idle);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let mut filtered = text.to_lowercase();
|
||||
// for tbr in config::ASSISTANT_PHRASES_TBR {
|
||||
// filtered = filtered.replace(tbr, "");
|
||||
|
|
|
|||
113
crates/jarvis-app/src/llm_fallback.rs
Normal file
113
crates/jarvis-app/src/llm_fallback.rs
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
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::voices;
|
||||
|
||||
struct State {
|
||||
client: LlmClient,
|
||||
history: Mutex<ConversationHistory>,
|
||||
max_tokens: u32,
|
||||
}
|
||||
|
||||
static STATE: OnceCell<Option<State>> = OnceCell::new();
|
||||
|
||||
pub fn init() {
|
||||
let _ = STATE.set(build_state());
|
||||
}
|
||||
|
||||
fn build_state() -> Option<State> {
|
||||
if !config::LLM_DEFAULT_ENABLED {
|
||||
info!("LLM fallback disabled by config.");
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
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));
|
||||
|
||||
info!("LLM fallback enabled (model: {}).", client.model());
|
||||
Some(State {
|
||||
client,
|
||||
history,
|
||||
max_tokens: config::LLM_DEFAULT_MAX_TOKENS,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn is_enabled() -> bool {
|
||||
STATE.get().and_then(|s| s.as_ref()).is_some()
|
||||
}
|
||||
|
||||
pub fn extract_prompt(text: &str) -> Option<String> {
|
||||
let lang = i18n::get_language();
|
||||
let triggers = config::get_llm_trigger_phrases(&lang);
|
||||
let lowered = text.to_lowercase();
|
||||
|
||||
for trig in triggers {
|
||||
if let Some(rest) = strip_trigger(&lowered, trig) {
|
||||
let trimmed = rest.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return Some(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn strip_trigger(text: &str, trigger: &str) -> Option<String> {
|
||||
let t = text.trim_start();
|
||||
if let Some(after) = t.strip_prefix(trigger) {
|
||||
let next = after.chars().next();
|
||||
if next.map_or(true, |c| !c.is_alphanumeric()) {
|
||||
return Some(after.to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn handle(prompt: &str) {
|
||||
let state = match STATE.get().and_then(|s| s.as_ref()) {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
warn!("LLM fallback called while disabled — ignoring.");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
info!("LLM prompt: {}", prompt);
|
||||
|
||||
let snapshot: Vec<ChatMessage> = {
|
||||
let mut h = state.history.lock();
|
||||
h.push_user(prompt);
|
||||
h.snapshot()
|
||||
};
|
||||
|
||||
match state.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 });
|
||||
voices::play_ok();
|
||||
}
|
||||
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(),
|
||||
});
|
||||
voices::play_error();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -18,6 +18,7 @@ mod log;
|
|||
|
||||
// include app
|
||||
mod app;
|
||||
mod llm_fallback;
|
||||
|
||||
// include tray
|
||||
// @TODO. macOS currently not supported for tray functionality.
|
||||
|
|
@ -55,6 +56,9 @@ fn main() -> Result<(), String> {
|
|||
// init i18n
|
||||
i18n::init(&settings.lock().language);
|
||||
|
||||
// init LLM fallback (no-op if GROQ_TOKEN missing)
|
||||
llm_fallback::init();
|
||||
|
||||
// init recorder
|
||||
if recorder::init().is_err() {
|
||||
app::close(1);
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ repository.workspace = true
|
|||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
jarvis-core = { path = "../jarvis-core", default-features = false, features = ["intent"]}
|
||||
jarvis-core = { path = "../jarvis-core", default-features = false, features = ["intent", "llm"]}
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "sync", "macros"] }
|
||||
log.workspace = true
|
||||
env_logger = "0.11"
|
||||
|
|
|
|||
7
crates/jarvis-cli/build.rs
Normal file
7
crates/jarvis-cli/build.rs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
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");
|
||||
|
||||
println!("cargo:rustc-link-search=native={}", lib_path.display());
|
||||
}
|
||||
|
|
@ -1,7 +1,10 @@
|
|||
use std::io::{self, Write};
|
||||
|
||||
use jarvis_core::llm::{ChatMessage, LlmClient};
|
||||
use jarvis_core::{COMMANDS_LIST, DB, JCommandsList, commands, config, db, intent};
|
||||
|
||||
const ASK_MAX_TOKENS: u32 = 512;
|
||||
|
||||
fn print_help() {
|
||||
println!("
|
||||
--## Jarvis CLI - Testing Tool ##--
|
||||
|
|
@ -9,6 +12,7 @@ fn print_help() {
|
|||
Commands:
|
||||
classify <text> - Test intent classification
|
||||
execute <text> - Simulate voice input and execute command
|
||||
ask <prompt> - Send prompt to Groq LLM and print reply
|
||||
list - List all loaded commands
|
||||
phrases - List all training phrases
|
||||
hash - Show commands hash
|
||||
|
|
@ -92,8 +96,40 @@ async fn execute_text(commands: &[JCommandsList], text: &str) {
|
|||
}
|
||||
}
|
||||
|
||||
fn ask_llm(prompt: &str) -> i32 {
|
||||
let client = match LlmClient::from_env() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
eprintln!("LLM not configured: {}", e);
|
||||
eprintln!("Set GROQ_TOKEN (and optionally GROQ_BASE_URL, GROQ_MODEL).");
|
||||
return 2;
|
||||
}
|
||||
};
|
||||
let messages = [ChatMessage::user(prompt)];
|
||||
match client.complete(&messages, ASK_MAX_TOKENS) {
|
||||
Ok(reply) => {
|
||||
println!("{}", reply);
|
||||
0
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("LLM request failed: {}", e);
|
||||
1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let argv: Vec<String> = std::env::args().collect();
|
||||
if argv.len() >= 2 && argv[1] == "ask" {
|
||||
if argv.len() < 3 {
|
||||
eprintln!("Usage: jarvis-cli ask <prompt>");
|
||||
std::process::exit(2);
|
||||
}
|
||||
let prompt = argv[2..].join(" ");
|
||||
std::process::exit(ask_llm(&prompt));
|
||||
}
|
||||
|
||||
// init logging
|
||||
env_logger::Builder::from_env(
|
||||
env_logger::Env::default().default_filter_or("info")
|
||||
|
|
@ -189,6 +225,13 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||
execute_text(COMMANDS_LIST.get().unwrap(), arg).await;
|
||||
}
|
||||
}
|
||||
"ask" | "a" => {
|
||||
if arg.is_empty() {
|
||||
println!(" Usage: ask <prompt>");
|
||||
} else {
|
||||
ask_llm(arg);
|
||||
}
|
||||
}
|
||||
"reload" => {
|
||||
println!(" Note: Reload requires app restart (statics can't be reset)");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ parking_lot.workspace = true
|
|||
toml.workspace = true
|
||||
sha2.workspace = true
|
||||
nnnoiseless = { workspace = true, optional = true }
|
||||
webrtc-vad = { workspace = true, optional = true }
|
||||
tokio-tungstenite = { workspace = true, optional = true }
|
||||
futures-util = { workspace = true, optional = true }
|
||||
fluent.workspace = true
|
||||
|
|
@ -41,6 +42,7 @@ tokio = { version = "1", features = ["sync"], optional = true }
|
|||
|
||||
mlua = { workspace = true, optional = true }
|
||||
reqwest = { workspace = true, optional = true }
|
||||
thiserror = { workspace = true, optional = true }
|
||||
tempfile.workspace = true
|
||||
|
||||
fastembed = { workspace = true, optional = true }
|
||||
|
|
@ -56,9 +58,12 @@ winrt-notification = { workspace = true, optional = true }
|
|||
default = ["jarvis_app"]
|
||||
jarvis_app = [
|
||||
"vosk", "intent-classifier", "fastembed", "tokio", "nnnoiseless", "tokio-tungstenite", "futures-util",
|
||||
"lua",
|
||||
"ort", "ndarray", "tokenizers", "regex",]
|
||||
"lua", "llm",
|
||||
"ort", "ndarray", "tokenizers", "regex",
|
||||
"webrtc-vad",
|
||||
]
|
||||
|
||||
intent = ["intent-classifier", "tokio"]
|
||||
lua = ["mlua", "reqwest", "winrt-notification"]
|
||||
lua_only = ["lua", "tokio"]
|
||||
llm = ["reqwest", "thiserror"]
|
||||
|
|
@ -1,5 +1,8 @@
|
|||
mod none;
|
||||
mod energy;
|
||||
pub mod listen_window;
|
||||
#[cfg(feature = "webrtc-vad")]
|
||||
pub mod webrtc;
|
||||
|
||||
use once_cell::sync::OnceCell;
|
||||
use parking_lot::Mutex;
|
||||
|
|
|
|||
135
crates/jarvis-core/src/audio_processing/vad/listen_window.rs
Normal file
135
crates/jarvis-core/src/audio_processing/vad/listen_window.rs
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
use crate::config;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum WindowDecision {
|
||||
KeepListening,
|
||||
Close,
|
||||
HardCap,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ListenWindow {
|
||||
frame_ms: u32,
|
||||
elapsed_ms: u32,
|
||||
speech_ms: u32,
|
||||
silence_ms: u32,
|
||||
end_silence_ms: u32,
|
||||
min_speech_ms: u32,
|
||||
min_listen_ms: u32,
|
||||
max_listen_ms: u32,
|
||||
}
|
||||
|
||||
impl ListenWindow {
|
||||
pub fn new(frame_ms: u32) -> Self {
|
||||
Self::with_params(
|
||||
frame_ms,
|
||||
config::VAD_COMMAND_END_SILENCE_MS,
|
||||
config::VAD_COMMAND_MIN_SPEECH_MS,
|
||||
config::VAD_COMMAND_MIN_LISTEN_MS,
|
||||
config::VAD_COMMAND_MAX_LISTEN_MS,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn with_params(
|
||||
frame_ms: u32,
|
||||
end_silence_ms: u32,
|
||||
min_speech_ms: u32,
|
||||
min_listen_ms: u32,
|
||||
max_listen_ms: u32,
|
||||
) -> Self {
|
||||
Self {
|
||||
frame_ms,
|
||||
elapsed_ms: 0,
|
||||
speech_ms: 0,
|
||||
silence_ms: 0,
|
||||
end_silence_ms,
|
||||
min_speech_ms,
|
||||
min_listen_ms,
|
||||
max_listen_ms,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push(&mut self, is_speech: bool) -> WindowDecision {
|
||||
self.elapsed_ms = self.elapsed_ms.saturating_add(self.frame_ms);
|
||||
|
||||
if is_speech {
|
||||
self.speech_ms = self.speech_ms.saturating_add(self.frame_ms);
|
||||
self.silence_ms = 0;
|
||||
} else {
|
||||
self.silence_ms = self.silence_ms.saturating_add(self.frame_ms);
|
||||
}
|
||||
|
||||
if self.elapsed_ms >= self.max_listen_ms {
|
||||
return WindowDecision::HardCap;
|
||||
}
|
||||
if self.elapsed_ms < self.min_listen_ms {
|
||||
return WindowDecision::KeepListening;
|
||||
}
|
||||
if self.speech_ms >= self.min_speech_ms && self.silence_ms >= self.end_silence_ms {
|
||||
return WindowDecision::Close;
|
||||
}
|
||||
WindowDecision::KeepListening
|
||||
}
|
||||
|
||||
pub fn elapsed_ms(&self) -> u32 { self.elapsed_ms }
|
||||
pub fn speech_ms(&self) -> u32 { self.speech_ms }
|
||||
pub fn silence_ms(&self) -> u32 { self.silence_ms }
|
||||
|
||||
pub fn had_speech(&self) -> bool { self.speech_ms > 0 }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn run(verdicts: &[bool]) -> (WindowDecision, usize) {
|
||||
let mut w = ListenWindow::with_params(30, 1200, 500, 1000, 15000);
|
||||
for (i, &v) in verdicts.iter().enumerate() {
|
||||
let d = w.push(v);
|
||||
if d != WindowDecision::KeepListening {
|
||||
return (d, i + 1);
|
||||
}
|
||||
}
|
||||
(WindowDecision::KeepListening, verdicts.len())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closes_after_speech_then_silence() {
|
||||
let speech: Vec<bool> = std::iter::repeat(true).take(40).collect();
|
||||
let silence: Vec<bool> = std::iter::repeat(false).take(60).collect();
|
||||
let mut v = speech;
|
||||
v.extend(silence);
|
||||
|
||||
let (decision, frame_idx) = run(&v);
|
||||
assert_eq!(decision, WindowDecision::Close);
|
||||
let elapsed_ms = (frame_idx as u32) * 30;
|
||||
let speech_ms = 40 * 30;
|
||||
assert!(elapsed_ms >= speech_ms + 1200, "closed at {} ms, expected >= {}", elapsed_ms, speech_ms + 1200);
|
||||
assert!(elapsed_ms <= speech_ms + 1200 + 30, "closed too late at {} ms", elapsed_ms);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_close_before_min_listen_even_with_long_silence() {
|
||||
let v: Vec<bool> = std::iter::repeat(false).take(100).collect();
|
||||
let mut w = ListenWindow::with_params(30, 1200, 500, 1000, 15000);
|
||||
for &x in v.iter().take(33) {
|
||||
assert_eq!(w.push(x), WindowDecision::KeepListening);
|
||||
}
|
||||
assert!(w.elapsed_ms() <= 1000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hard_cap_fires_at_max_listen() {
|
||||
let v: Vec<bool> = std::iter::repeat(true).take(1000).collect();
|
||||
let (decision, frame_idx) = run(&v);
|
||||
assert_eq!(decision, WindowDecision::HardCap);
|
||||
assert_eq!((frame_idx as u32) * 30, 15000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn silence_only_does_not_close_keeps_waiting_until_hard_cap() {
|
||||
let v: Vec<bool> = std::iter::repeat(false).take(600).collect();
|
||||
let (decision, _) = run(&v);
|
||||
assert_eq!(decision, WindowDecision::HardCap);
|
||||
}
|
||||
}
|
||||
49
crates/jarvis-core/src/audio_processing/vad/webrtc.rs
Normal file
49
crates/jarvis-core/src/audio_processing/vad/webrtc.rs
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
use webrtc_vad::{SampleRate, Vad, VadMode};
|
||||
|
||||
use crate::config;
|
||||
|
||||
pub const FRAME_SAMPLES: usize = 480;
|
||||
pub const FRAME_MS: u32 = 30;
|
||||
|
||||
pub struct WebRtcVad {
|
||||
inner: Vad,
|
||||
buf: Vec<i16>,
|
||||
}
|
||||
|
||||
impl WebRtcVad {
|
||||
pub fn new() -> Self {
|
||||
Self::with_aggressiveness(config::VAD_AGGRESSIVENESS)
|
||||
}
|
||||
|
||||
pub fn with_aggressiveness(level: u8) -> Self {
|
||||
let mode = match level {
|
||||
0 => VadMode::Quality,
|
||||
1 => VadMode::LowBitrate,
|
||||
2 => VadMode::Aggressive,
|
||||
_ => VadMode::VeryAggressive,
|
||||
};
|
||||
Self {
|
||||
inner: Vad::new_with_rate_and_mode(SampleRate::Rate16kHz, mode),
|
||||
buf: Vec::with_capacity(FRAME_SAMPLES * 2),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push_samples(&mut self, samples: &[i16]) -> Vec<bool> {
|
||||
self.buf.extend_from_slice(samples);
|
||||
let mut out = Vec::new();
|
||||
while self.buf.len() >= FRAME_SAMPLES {
|
||||
let frame: Vec<i16> = self.buf.drain(..FRAME_SAMPLES).collect();
|
||||
let is_speech = self.inner.is_voice_segment(&frame).unwrap_or(false);
|
||||
out.push(is_speech);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn reset(&mut self) {
|
||||
self.buf.clear();
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for WebRtcVad {
|
||||
fn default() -> Self { Self::new() }
|
||||
}
|
||||
|
|
@ -171,6 +171,13 @@ pub const VAD_ENERGY_THRESHOLD: f32 = 100.0; // RMS threshold for energy-based
|
|||
pub const VAD_NNNOISELESS_THRESHOLD: f32 = 0.8; // probability threshold for nnnoiseless
|
||||
pub const VAD_SILENCE_FRAMES: u32 = 15; // frames of silence before speech end (~480ms)
|
||||
|
||||
// post-wake command listening window (паритет с Python v0.2.0, webrtcvad)
|
||||
pub const VAD_AGGRESSIVENESS: u8 = 2;
|
||||
pub const VAD_COMMAND_END_SILENCE_MS: u32 = 1200;
|
||||
pub const VAD_COMMAND_MIN_SPEECH_MS: u32 = 500;
|
||||
pub const VAD_COMMAND_MIN_LISTEN_MS: u32 = 1000;
|
||||
pub const VAD_COMMAND_MAX_LISTEN_MS: u32 = 15000;
|
||||
|
||||
// gain normalizer settings
|
||||
pub const GAIN_TARGET_RMS: f32 = 3000.0; // target RMS level
|
||||
pub const GAIN_MIN: f32 = 0.5; // minimum gain multiplier
|
||||
|
|
@ -187,6 +194,33 @@ 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);
|
||||
|
||||
// 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;
|
||||
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 предложения), как живой человек. \
|
||||
Излишней цензуры не нужно — мат разрешён, если уместен.";
|
||||
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"],
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_llm_system_prompt(lang: &str) -> &'static str {
|
||||
match lang {
|
||||
"ru" | "ua" => LLM_SYSTEM_PROMPT_RU,
|
||||
_ => LLM_SYSTEM_PROMPT_RU,
|
||||
}
|
||||
}
|
||||
|
||||
// pub const ASSISTANT_GREET_PHRASES: [&str; 3] = ["greet1", "greet2", "greet3"];
|
||||
// pub const ASSISTANT_PHRASES_TBR: [&str; 17] = [
|
||||
// "джарвис",
|
||||
|
|
|
|||
|
|
@ -13,6 +13,9 @@ pub enum IpcEvent {
|
|||
// Speech recognized
|
||||
SpeechRecognized { text: String },
|
||||
|
||||
// LLM produced a reply for a free-form question
|
||||
LlmReply { text: String },
|
||||
|
||||
// Command was executed
|
||||
CommandExecuted { id: String, success: bool },
|
||||
|
||||
|
|
|
|||
|
|
@ -48,6 +48,9 @@ pub mod audio_buffer;
|
|||
#[cfg(feature = "lua")]
|
||||
pub mod lua;
|
||||
|
||||
#[cfg(feature = "llm")]
|
||||
pub mod llm;
|
||||
|
||||
// shared statics
|
||||
// pub static APP_DIR: Lazy<PathBuf> = Lazy::new(|| std::env::current_dir().unwrap());
|
||||
pub static APP_DIR: Lazy<PathBuf> = Lazy::new(|| {
|
||||
|
|
|
|||
188
crates/jarvis-core/src/llm/client.rs
Normal file
188
crates/jarvis-core/src/llm/client.rs
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use std::env;
|
||||
use thiserror::Error;
|
||||
|
||||
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";
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ConfigError {
|
||||
#[error("missing environment variable: {0}")]
|
||||
MissingEnv(&'static str),
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum LlmError {
|
||||
#[error("HTTP request failed: {0}")]
|
||||
Http(#[from] reqwest::Error),
|
||||
#[error("failed to parse response JSON: {0}")]
|
||||
Deserialize(String),
|
||||
#[error("API returned error (status {status}): {body}")]
|
||||
Api { status: u16, body: String },
|
||||
#[error("response contained no choices")]
|
||||
EmptyResponse,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ChatMessage {
|
||||
pub role: String,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
impl ChatMessage {
|
||||
pub fn user(content: impl Into<String>) -> Self {
|
||||
Self { role: "user".to_string(), content: content.into() }
|
||||
}
|
||||
|
||||
pub fn system(content: impl Into<String>) -> Self {
|
||||
Self { role: "system".to_string(), content: content.into() }
|
||||
}
|
||||
|
||||
pub fn assistant(content: impl Into<String>) -> Self {
|
||||
Self { role: "assistant".to_string(), content: content.into() }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ChatRequest<'a> {
|
||||
model: &'a str,
|
||||
messages: &'a [ChatMessage],
|
||||
max_tokens: u32,
|
||||
temperature: f32,
|
||||
top_p: f32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ChatResponse {
|
||||
choices: Vec<Choice>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Choice {
|
||||
message: ResponseMessage,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ResponseMessage {
|
||||
content: String,
|
||||
}
|
||||
|
||||
pub struct LlmClient {
|
||||
base_url: String,
|
||||
api_key: String,
|
||||
model: String,
|
||||
http: reqwest::blocking::Client,
|
||||
}
|
||||
|
||||
impl LlmClient {
|
||||
pub fn new(
|
||||
base_url: impl Into<String>,
|
||||
api_key: impl Into<String>,
|
||||
model: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
base_url: base_url.into(),
|
||||
api_key: api_key.into(),
|
||||
model: model.into(),
|
||||
http: reqwest::blocking::Client::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_env() -> Result<Self, ConfigError> {
|
||||
let api_key = env::var(ENV_TOKEN).map_err(|_| ConfigError::MissingEnv(ENV_TOKEN))?;
|
||||
let base_url = env::var(ENV_BASE_URL).unwrap_or_else(|_| DEFAULT_BASE_URL.to_string());
|
||||
let model = env::var(ENV_MODEL).unwrap_or_else(|_| DEFAULT_MODEL.to_string());
|
||||
Ok(Self::new(base_url, api_key, model))
|
||||
}
|
||||
|
||||
pub fn model(&self) -> &str {
|
||||
&self.model
|
||||
}
|
||||
|
||||
pub fn complete(&self, messages: &[ChatMessage], max_tokens: u32) -> 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,
|
||||
};
|
||||
|
||||
let resp = self
|
||||
.http
|
||||
.post(&url)
|
||||
.bearer_auth(&self.api_key)
|
||||
.json(&body)
|
||||
.send()?;
|
||||
|
||||
let status = resp.status();
|
||||
let text = resp.text()?;
|
||||
|
||||
if !status.is_success() {
|
||||
return Err(LlmError::Api { status: status.as_u16(), body: text });
|
||||
}
|
||||
|
||||
let parsed: ChatResponse =
|
||||
serde_json::from_str(&text).map_err(|e| LlmError::Deserialize(e.to_string()))?;
|
||||
parsed
|
||||
.choices
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|c| c.message.content)
|
||||
.ok_or(LlmError::EmptyResponse)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_valid_chat_response() {
|
||||
let raw = r#"{
|
||||
"id": "chatcmpl-xyz",
|
||||
"object": "chat.completion",
|
||||
"model": "llama-3.3-70b-versatile",
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"message": { "role": "assistant", "content": "Hello there!" },
|
||||
"finish_reason": "stop"
|
||||
}
|
||||
],
|
||||
"usage": { "prompt_tokens": 5, "completion_tokens": 3, "total_tokens": 8 }
|
||||
}"#;
|
||||
|
||||
let parsed: ChatResponse = serde_json::from_str(raw).expect("valid JSON should parse");
|
||||
assert_eq!(parsed.choices.len(), 1);
|
||||
assert_eq!(parsed.choices[0].message.content, "Hello there!");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_env_fails_when_token_missing() {
|
||||
let prev = env::var(ENV_TOKEN).ok();
|
||||
unsafe { env::remove_var(ENV_TOKEN); }
|
||||
let result = LlmClient::from_env();
|
||||
let err = match result {
|
||||
Ok(_) => panic!("expected ConfigError when {ENV_TOKEN} is unset"),
|
||||
Err(e) => e,
|
||||
};
|
||||
match err {
|
||||
ConfigError::MissingEnv(name) => assert_eq!(name, ENV_TOKEN),
|
||||
}
|
||||
if let Some(v) = prev {
|
||||
unsafe { env::set_var(ENV_TOKEN, v); }
|
||||
}
|
||||
}
|
||||
|
||||
#[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");
|
||||
}
|
||||
}
|
||||
138
crates/jarvis-core/src/llm/history.rs
Normal file
138
crates/jarvis-core/src/llm/history.rs
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
use super::client::ChatMessage;
|
||||
|
||||
pub struct ConversationHistory {
|
||||
system: Option<ChatMessage>,
|
||||
turns: Vec<ChatMessage>,
|
||||
max_turns: usize,
|
||||
}
|
||||
|
||||
impl ConversationHistory {
|
||||
pub fn new(system_prompt: impl Into<String>, max_turns: usize) -> Self {
|
||||
Self {
|
||||
system: Some(ChatMessage::system(system_prompt)),
|
||||
turns: Vec::new(),
|
||||
max_turns: max_turns.max(1),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn without_system(max_turns: usize) -> Self {
|
||||
Self {
|
||||
system: None,
|
||||
turns: Vec::new(),
|
||||
max_turns: max_turns.max(1),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push_user(&mut self, content: impl Into<String>) {
|
||||
self.turns.push(ChatMessage::user(content));
|
||||
self.truncate();
|
||||
}
|
||||
|
||||
pub fn push_assistant(&mut self, content: impl Into<String>) {
|
||||
self.turns.push(ChatMessage::assistant(content));
|
||||
self.truncate();
|
||||
}
|
||||
|
||||
pub fn snapshot(&self) -> Vec<ChatMessage> {
|
||||
let mut out = Vec::with_capacity(self.turns.len() + 1);
|
||||
if let Some(s) = &self.system {
|
||||
out.push(s.clone());
|
||||
}
|
||||
out.extend(self.turns.iter().cloned());
|
||||
out
|
||||
}
|
||||
|
||||
pub fn turns(&self) -> &[ChatMessage] {
|
||||
&self.turns
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
self.turns.clear();
|
||||
}
|
||||
|
||||
pub fn pop_last_user(&mut self) -> Option<ChatMessage> {
|
||||
if matches!(self.turns.last(), Some(m) if m.role == "user") {
|
||||
self.turns.pop()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn truncate(&mut self) {
|
||||
if self.turns.len() > self.max_turns {
|
||||
let drop = self.turns.len() - self.max_turns;
|
||||
self.turns.drain(0..drop);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn evicts_oldest_turns_but_keeps_system_prompt() {
|
||||
let mut h = ConversationHistory::new("you are jarvis", 4);
|
||||
h.push_user("u1");
|
||||
h.push_assistant("a1");
|
||||
h.push_user("u2");
|
||||
h.push_assistant("a2");
|
||||
h.push_user("u3");
|
||||
|
||||
let snap = h.snapshot();
|
||||
assert_eq!(snap[0].role, "system");
|
||||
assert_eq!(snap[0].content, "you are jarvis");
|
||||
assert_eq!(snap.len(), 1 + 4);
|
||||
|
||||
let contents: Vec<&str> = snap.iter().skip(1).map(|m| m.content.as_str()).collect();
|
||||
assert_eq!(contents, vec!["a1", "u2", "a2", "u3"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_with_no_system_returns_only_turns() {
|
||||
let mut h = ConversationHistory::without_system(3);
|
||||
h.push_user("hi");
|
||||
h.push_assistant("hello");
|
||||
let snap = h.snapshot();
|
||||
assert_eq!(snap.len(), 2);
|
||||
assert_eq!(snap[0].role, "user");
|
||||
assert_eq!(snap[1].role, "assistant");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cap_of_zero_is_clamped_to_one() {
|
||||
let mut h = ConversationHistory::new("sys", 0);
|
||||
h.push_user("a");
|
||||
h.push_user("b");
|
||||
let snap = h.snapshot();
|
||||
assert_eq!(snap.len(), 2);
|
||||
assert_eq!(snap[0].role, "system");
|
||||
assert_eq!(snap[1].content, "b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pop_last_user_only_pops_a_trailing_user_turn() {
|
||||
let mut h = ConversationHistory::new("sys", 4);
|
||||
h.push_user("u1");
|
||||
h.push_assistant("a1");
|
||||
assert!(h.pop_last_user().is_none());
|
||||
h.push_user("u2");
|
||||
let popped = h.pop_last_user().expect("trailing user turn should pop");
|
||||
assert_eq!(popped.role, "user");
|
||||
assert_eq!(popped.content, "u2");
|
||||
let snap = h.snapshot();
|
||||
assert_eq!(snap.len(), 1 + 2);
|
||||
assert_eq!(snap[2].content, "a1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_removes_turns_but_not_system() {
|
||||
let mut h = ConversationHistory::new("sys", 4);
|
||||
h.push_user("u");
|
||||
h.push_assistant("a");
|
||||
h.clear();
|
||||
let snap = h.snapshot();
|
||||
assert_eq!(snap.len(), 1);
|
||||
assert_eq!(snap[0].role, "system");
|
||||
}
|
||||
}
|
||||
8
crates/jarvis-core/src/llm/mod.rs
Normal file
8
crates/jarvis-core/src/llm/mod.rs
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
mod client;
|
||||
mod history;
|
||||
|
||||
pub use client::{
|
||||
ChatMessage, ConfigError, LlmClient, LlmError,
|
||||
DEFAULT_BASE_URL, DEFAULT_MODEL, ENV_BASE_URL, ENV_MODEL, ENV_TOKEN,
|
||||
};
|
||||
pub use history::ConversationHistory;
|
||||
|
|
@ -10,6 +10,7 @@ pub use self::vosk::recognize_wake_word;
|
|||
pub use self::vosk::recognize_speech;
|
||||
pub use self::vosk::reset_speech_recognizer;
|
||||
pub use self::vosk::reset_wake_recognizer;
|
||||
pub use self::vosk::finalize_speech;
|
||||
|
||||
static STT_TYPE: OnceCell<SpeechToTextEngine> = OnceCell::new();
|
||||
|
||||
|
|
|
|||
|
|
@ -97,6 +97,16 @@ pub fn reset_speech_recognizer() {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn finalize_speech() -> Option<String> {
|
||||
let mut recognizer = SPEECH_RECOGNIZER.get()?.lock();
|
||||
let result = recognizer.final_result()
|
||||
.multiple()
|
||||
.and_then(|m| m.alternatives.first().map(|a| a.text.to_string()))
|
||||
.filter(|s| !s.trim().is_empty());
|
||||
recognizer.reset();
|
||||
result
|
||||
}
|
||||
|
||||
pub fn reset_wake_recognizer() {
|
||||
if let Some(recognizer) = WAKE_RECOGNIZER.get() {
|
||||
recognizer.lock().reset();
|
||||
|
|
|
|||
|
|
@ -1,4 +1,9 @@
|
|||
fn main() {
|
||||
// Tauri build
|
||||
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());
|
||||
|
||||
tauri_build::build()
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
19
frontend/package-lock.json
generated
19
frontend/package-lock.json
generated
|
|
@ -40,7 +40,6 @@
|
|||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
|
||||
"integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@jridgewell/gen-mapping": "^0.3.5",
|
||||
|
|
@ -478,7 +477,6 @@
|
|||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||
|
|
@ -489,7 +487,6 @@
|
|||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
|
|
@ -499,14 +496,12 @@
|
|||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jridgewell/trace-mapping": {
|
||||
"version": "0.3.31",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
|
||||
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/resolve-uri": "^3.1.0",
|
||||
|
|
@ -1566,7 +1561,6 @@
|
|||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/howler": {
|
||||
|
|
@ -1597,7 +1591,6 @@
|
|||
"version": "8.15.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
|
|
@ -1624,7 +1617,6 @@
|
|||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
|
||||
"integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
|
|
@ -1634,7 +1626,6 @@
|
|||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
||||
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
|
|
@ -1794,7 +1785,6 @@
|
|||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/code-red/-/code-red-1.0.4.tgz",
|
||||
"integrity": "sha512-7qJWqItLA8/VPVlKJlFXU+NBlo/qyfs39aJcuMT/2ere32ZqvF5OSxgdM5xOfJJ7O429gg2HM47y8v9P+9wrNw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.4.15",
|
||||
|
|
@ -1867,7 +1857,6 @@
|
|||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz",
|
||||
"integrity": "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mdn-data": "2.0.30",
|
||||
|
|
@ -2127,7 +2116,6 @@
|
|||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
||||
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "^1.0.0"
|
||||
|
|
@ -2417,7 +2405,6 @@
|
|||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
|
||||
"integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "^1.0.6"
|
||||
|
|
@ -2450,14 +2437,12 @@
|
|||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
|
||||
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
|
|
@ -2467,7 +2452,6 @@
|
|||
"version": "2.0.30",
|
||||
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz",
|
||||
"integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==",
|
||||
"dev": true,
|
||||
"license": "CC0-1.0"
|
||||
},
|
||||
"node_modules/micromatch": {
|
||||
|
|
@ -2715,7 +2699,6 @@
|
|||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/periscopic/-/periscopic-3.1.0.tgz",
|
||||
"integrity": "sha512-vKiQ8RRtkl9P+r/+oefh25C3fhybptkHKCZSPlcXiJux2tJF55GnEj3BVn4A5gKfq9NWWXXrxkHBwVPUfH0opw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "^1.0.0",
|
||||
|
|
@ -2973,7 +2956,6 @@
|
|||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
|
|
@ -2996,7 +2978,6 @@
|
|||
"version": "4.2.20",
|
||||
"resolved": "https://registry.npmjs.org/svelte/-/svelte-4.2.20.tgz",
|
||||
"integrity": "sha512-eeEgGc2DtiUil5ANdtd8vPwt9AgaMdnuUFnPft9F5oMvU/FHu5IHFic+p1dR/UOB7XU2mX2yHW+NcTch4DCh5Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@ampproject/remapping": "^2.2.1",
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "svelte-check --ignore '.routify' --no-tsconfig && vite build",
|
||||
"build": "routify build && svelte-check --ignore '.routify' --no-tsconfig && vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-check --ignore '.routify' --tsconfig ./tsconfig.json",
|
||||
"tauri": "tauri"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue