diff --git a/Cargo.lock b/Cargo.lock index 9361469..cd9980a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3324,6 +3324,7 @@ dependencies = [ "sha2", "sys-locale", "tempfile", + "thiserror 2.0.17", "tokenizers", "tokio", "tokio-tungstenite", diff --git a/Cargo.toml b/Cargo.toml index 19f2ce9..4d540c3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/README.md b/README.md index a151dce..f6d6e08 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ - nnnoiseless — подавление шума. - fluent / unic-langid — i18n (`ru`, `ua`, `en`). -**LLM-интеграции в коде пока нет.** В апстримном README у Priler значится `ChatGPT (coming soon)`, но в исходниках этого нет. В данном форке тоже не добавлялось. +**LLM-клиент (Groq / OpenAI-совместимый) добавлен в `jarvis-core::llm`.** Пока не подключён к wake-word/intent-циклу — только как библиотечный модуль и отдельная CLI-команда для проверки. Подключение в голосовой поток запланировано на v0.3. ## Это форк @@ -94,6 +94,39 @@ cargo build --workspace Для 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); +``` + +На текущем этапе (v0.2.x) модуль **не подключён** к wake-word-циклу, intent-классификации и Lua-скриптам — это только фундамент. Интеграция в голосовой поток — задача v0.3. + ## Лицензия Creative Commons **Attribution-NonCommercial-ShareAlike 4.0 International** (CC BY-NC-SA 4.0). diff --git a/crates/jarvis-cli/Cargo.toml b/crates/jarvis-cli/Cargo.toml index 64644a9..72fba9d 100644 --- a/crates/jarvis-cli/Cargo.toml +++ b/crates/jarvis-cli/Cargo.toml @@ -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" diff --git a/crates/jarvis-cli/src/main.rs b/crates/jarvis-cli/src/main.rs index d161b5d..376b141 100644 --- a/crates/jarvis-cli/src/main.rs +++ b/crates/jarvis-cli/src/main.rs @@ -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 - Test intent classification execute - Simulate voice input and execute command + ask - Send prompt to Groq LLM and print reply list - List all loaded commands phrases - List all training phrases hash - Show commands hash @@ -92,13 +96,45 @@ 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> { + let argv: Vec = std::env::args().collect(); + if argv.len() >= 2 && argv[1] == "ask" { + if argv.len() < 3 { + eprintln!("Usage: jarvis-cli ask "); + 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") ).init(); - + println!("Jarvis CLI v{}", config::APP_VERSION.unwrap_or("unknown")); // init dirs @@ -189,6 +225,13 @@ async fn main() -> Result<(), Box> { execute_text(COMMANDS_LIST.get().unwrap(), arg).await; } } + "ask" | "a" => { + if arg.is_empty() { + println!(" Usage: ask "); + } else { + ask_llm(arg); + } + } "reload" => { println!(" Note: Reload requires app restart (statics can't be reset)"); } diff --git a/crates/jarvis-core/Cargo.toml b/crates/jarvis-core/Cargo.toml index 0033a9f..f315975 100644 --- a/crates/jarvis-core/Cargo.toml +++ b/crates/jarvis-core/Cargo.toml @@ -41,6 +41,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 +57,10 @@ winrt-notification = { workspace = true, optional = true } default = ["jarvis_app"] jarvis_app = [ "vosk", "intent-classifier", "fastembed", "tokio", "nnnoiseless", "tokio-tungstenite", "futures-util", - "lua", + "lua", "llm", "ort", "ndarray", "tokenizers", "regex",] intent = ["intent-classifier", "tokio"] lua = ["mlua", "reqwest", "winrt-notification"] -lua_only = ["lua", "tokio"] \ No newline at end of file +lua_only = ["lua", "tokio"] +llm = ["reqwest", "thiserror"] \ No newline at end of file diff --git a/crates/jarvis-core/src/lib.rs b/crates/jarvis-core/src/lib.rs index 41f8c04..1b1968c 100644 --- a/crates/jarvis-core/src/lib.rs +++ b/crates/jarvis-core/src/lib.rs @@ -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 = Lazy::new(|| std::env::current_dir().unwrap()); pub static APP_DIR: Lazy = Lazy::new(|| { diff --git a/crates/jarvis-core/src/llm.rs b/crates/jarvis-core/src/llm.rs new file mode 100644 index 0000000..7c0ce91 --- /dev/null +++ b/crates/jarvis-core/src/llm.rs @@ -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) -> Self { + Self { role: "user".to_string(), content: content.into() } + } + + pub fn system(content: impl Into) -> Self { + Self { role: "system".to_string(), content: content.into() } + } + + pub fn assistant(content: impl Into) -> 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, +} + +#[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, + api_key: impl Into, + model: impl Into, + ) -> 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 { + 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 { + 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"); + } +}