Merge feature/groq-llm-rust into dev
Adds Groq (OpenAI-compatible) LlmClient in jarvis-core behind the new 'llm' cargo feature, plus a jarvis-cli 'ask <prompt>' subcommand for one-shot testing. Not yet wired into the wake-word/intent loop — that's the next feature. thiserror added to workspace deps.
This commit is contained in:
commit
23c57b855b
8 changed files with 276 additions and 5 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -3324,6 +3324,7 @@ dependencies = [
|
|||
"sha2",
|
||||
"sys-locale",
|
||||
"tempfile",
|
||||
"thiserror 2.0.17",
|
||||
"tokenizers",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
35
README.md
35
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).
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
llm = ["reqwest", "thiserror"]
|
||||
|
|
@ -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.rs
Normal file
188
crates/jarvis-core/src/llm.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");
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue