feat: add Hailo Ollama client and CLI
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
/target
|
||||
.env
|
||||
*.log
|
||||
Generated
+1745
File diff suppressed because it is too large
Load Diff
+12
@@ -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"]
|
||||
@@ -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"] }
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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,
|
||||
}
|
||||
Reference in New Issue
Block a user