feat: add Hailo Ollama client and CLI

This commit is contained in:
2026-06-20 08:59:25 +00:00
commit cae0a9a717
7 changed files with 2023 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
/target
.env
*.log
Generated
+1745
View File
File diff suppressed because it is too large Load Diff
+12
View File
@@ -0,0 +1,12 @@
[workspace]
members = [
"apps/enuxia-ai-cli",
"crates/enuxia-hailo-client",
]
resolver = "3"
[workspace.package]
version = "0.1.0"
edition = "2024"
license = "Apache-2.0"
authors = ["Enuxia"]
+9
View File
@@ -0,0 +1,9 @@
[package]
name = "enuxia-ai-cli"
version = "0.1.0"
edition = "2024"
[dependencies]
clap = { version = "4.6.1", features = ["derive"] }
enuxia-hailo-client = { version = "0.1.0", path = "../../crates/enuxia-hailo-client" }
tokio = { version = "1.52.3", features = ["macros", "rt-multi-thread"] }
+73
View File
@@ -0,0 +1,73 @@
use clap::{Parser, Subcommand};
use enuxia_hailo_client::HailoClient;
use std::error::Error;
#[derive(Debug, Parser)]
#[command(
name = "enuxia-ai",
version,
about = "Assistant IA local pour l'écosystème Frappe"
)]
struct Cli {
/// Adresse HTTP de Hailo-Ollama.
#[arg(long, default_value = "http://127.0.0.1:8000", global = true)]
base_url: String,
/// Modèle utilisé pour la génération.
#[arg(long, default_value = "qwen2.5-instruct:1.5b", global = true)]
model: String,
#[command(subcommand)]
command: Command,
}
#[derive(Debug, Subcommand)]
enum Command {
/// Affiche les modèles disponibles.
Models,
/// Pose une question à l'assistant local.
Ask {
/// Question envoyée au modèle.
question: String,
},
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
let cli = Cli::parse();
let client = HailoClient::new(cli.base_url, cli.model)?;
match cli.command {
Command::Models => {
let models = client.list_models().await?;
println!("Modèles Hailo disponibles :");
for model in models {
let marker = if model == client.model() { "*" } else { "-" };
println!("{marker} {model}");
}
}
Command::Ask { question } => {
let result = client.ask(&question).await?;
println!("{}", result.content);
eprintln!();
eprintln!("---");
eprintln!("Modèle : {}", client.model());
eprintln!("Tokens générés : {}", result.eval_count);
eprintln!("Durée interne : {:.2} s", result.total_duration_seconds());
if let Some(rate) = result.tokens_per_second() {
eprintln!("Débit moyen : {rate:.2} tokens/s");
}
}
}
Ok(())
}
+9
View File
@@ -0,0 +1,9 @@
[package]
name = "enuxia-hailo-client"
version = "0.1.0"
edition = "2024"
[dependencies]
reqwest = { version = "0.13.4", default-features = false, features = ["json", "rustls"] }
serde = { version = "1.0.228", features = ["derive"] }
thiserror = "2.0.18"
+172
View File
@@ -0,0 +1,172 @@
use reqwest::{Client, StatusCode};
use serde::{Deserialize, Serialize};
use std::time::Duration;
use thiserror::Error;
const DEFAULT_SYSTEM_PROMPT: &str = concat!(
"Tu es Enuxia AI, un assistant local pour les organisations utilisant Frappe. ",
"Réponds exclusivement en français. ",
"Sois clair et factuel. ",
"Ne présente jamais une supposition comme un fait. ",
"Lorsque des informations manquent, indique-le explicitement."
);
/// Erreurs pouvant survenir lors d'un appel à Hailo-Ollama.
#[derive(Debug, Error)]
pub enum HailoError {
#[error("impossible de communiquer avec Hailo-Ollama : {0}")]
Http(#[from] reqwest::Error),
#[error("Hailo-Ollama a répondu avec le statut HTTP {status} : {body}")]
Api { status: StatusCode, body: String },
#[error("le modèle a retourné une réponse vide")]
EmptyResponse,
}
/// Client HTTP permettant d'interroger Hailo-Ollama.
#[derive(Debug, Clone)]
pub struct HailoClient {
http: Client,
base_url: String,
model: String,
}
/// Résultat normalisé d'une génération.
#[derive(Debug, Clone)]
pub struct ChatResult {
pub content: String,
pub eval_count: u64,
pub total_duration_ns: u64,
}
impl ChatResult {
/// Débit moyen annoncé par Hailo-Ollama.
pub fn tokens_per_second(&self) -> Option<f64> {
if self.total_duration_ns == 0 || self.eval_count == 0 {
return None;
}
let seconds = self.total_duration_ns as f64 / 1_000_000_000.0;
Some(self.eval_count as f64 / seconds)
}
pub fn total_duration_seconds(&self) -> f64 {
self.total_duration_ns as f64 / 1_000_000_000.0
}
}
impl HailoClient {
pub fn new(base_url: impl Into<String>, model: impl Into<String>) -> Result<Self, HailoError> {
let http = Client::builder()
.timeout(Duration::from_secs(180))
.build()?;
Ok(Self {
http,
base_url: base_url.into().trim_end_matches('/').to_owned(),
model: model.into(),
})
}
pub fn model(&self) -> &str {
&self.model
}
/// Retourne les modèles déclarés par Hailo-Ollama.
pub async fn list_models(&self) -> Result<Vec<String>, HailoError> {
let url = format!("{}/hailo/v1/list", self.base_url);
let response = self.http.get(url).send().await?;
let response = ensure_success(response).await?;
let payload = response.json::<ModelsResponse>().await?;
Ok(payload.models)
}
/// Envoie une question au modèle configuré.
pub async fn ask(&self, prompt: &str) -> Result<ChatResult, HailoError> {
let request = ChatRequest {
model: self.model.clone(),
stream: false,
messages: vec![
RequestMessage {
role: "system".to_owned(),
content: DEFAULT_SYSTEM_PROMPT.to_owned(),
},
RequestMessage {
role: "user".to_owned(),
content: prompt.to_owned(),
},
],
};
let url = format!("{}/api/chat", self.base_url);
let response = self.http.post(url).json(&request).send().await?;
let response = ensure_success(response).await?;
let payload = response.json::<ChatResponse>().await?;
let content = payload.message.content.trim().to_owned();
if content.is_empty() {
return Err(HailoError::EmptyResponse);
}
Ok(ChatResult {
content,
eval_count: payload.eval_count,
total_duration_ns: payload.total_duration,
})
}
}
async fn ensure_success(response: reqwest::Response) -> Result<reqwest::Response, HailoError> {
let status = response.status();
if status.is_success() {
return Ok(response);
}
let body = response
.text()
.await
.unwrap_or_else(|_| "<corps illisible>".to_owned());
Err(HailoError::Api { status, body })
}
#[derive(Debug, Deserialize)]
struct ModelsResponse {
models: Vec<String>,
}
#[derive(Debug, Serialize)]
struct ChatRequest {
model: String,
stream: bool,
messages: Vec<RequestMessage>,
}
#[derive(Debug, Serialize)]
struct RequestMessage {
role: String,
content: String,
}
#[derive(Debug, Deserialize)]
struct ChatResponse {
message: ResponseMessage,
#[serde(default)]
total_duration: u64,
#[serde(default)]
eval_count: u64,
}
#[derive(Debug, Deserialize)]
struct ResponseMessage {
content: String,
}