feat(model-server): afficher le téléchargement du modèle llamacpp au démarrage (#54)

Ajoute un handle du téléchargement des modèles lors du démarrage de
llamacpp : le domaine et l'application émettent la progression de
téléchargement du modèle, relayée en événement côté app-tauri, et l'UI
l'affiche via un badge de lancement et un overlay de cellule pendant que
le serveur de modèle démarre.

Backend (B1) : progression de téléchargement dans domain/application,
relais d'événement app-tauri, couverture de tests.
Frontend (F1) : modelServerLaunch, badge et overlay LayoutGrid, tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-13 23:14:05 +02:00
parent c67ec4f7bd
commit fe7ed0aa20
12 changed files with 891 additions and 29 deletions

View File

@ -15,6 +15,7 @@ use domain::ports::{
};
use domain::{LocalModelServerId, StopPolicy};
use tokio::sync::{Mutex as AsyncMutex, Notify};
use tokio::time::Instant;
use crate::error::AppError;
@ -161,6 +162,13 @@ impl Default for ReadinessPolicy {
}
}
/// Default upper bound for Hugging Face model download/preparation.
///
/// llama.cpp may download multi-GB models before binding the OpenAI-compatible
/// endpoint. The short readiness window remains for local files; HF sources use
/// this separate deadline while the process is still alive.
pub const DEFAULT_HF_DOWNLOAD_DEADLINE: Duration = Duration::from_secs(30 * 60);
#[derive(Debug, Clone)]
struct ActiveServer {
handle: ManagedProcessHandle,
@ -208,6 +216,7 @@ pub struct EnsureLocalModelServer {
active: Mutex<HashMap<LocalModelServerId, ActiveServer>>,
inflight: AsyncMutex<HashMap<LocalModelServerId, Arc<InflightEnsure>>>,
readiness: ReadinessPolicy,
hf_download_deadline: Duration,
}
impl EnsureLocalModelServer {
@ -232,6 +241,7 @@ impl EnsureLocalModelServer {
active: Mutex::new(HashMap::new()),
inflight: AsyncMutex::new(HashMap::new()),
readiness: ReadinessPolicy::default(),
hf_download_deadline: DEFAULT_HF_DOWNLOAD_DEADLINE,
}
}
@ -242,6 +252,13 @@ impl EnsureLocalModelServer {
self
}
/// Overrides the long Hugging Face download/preparation deadline.
#[must_use]
pub fn with_hf_download_deadline(mut self, deadline: Duration) -> Self {
self.hf_download_deadline = deadline;
self
}
/// Ensures the server is reachable.
///
/// # Errors
@ -333,10 +350,21 @@ impl EnsureLocalModelServer {
},
);
let hf_source = hf_source(&config);
self.wait_for_started_server(&config, &handle, hf_source)
.await
}
async fn wait_for_started_server(
&self,
config: &LocalModelServerConfig,
handle: &ManagedProcessHandle,
hf_source: Option<String>,
) -> Result<EnsureLocalModelServerOutput, AppError> {
for attempt in 0..self.readiness.attempts {
match self.probe.probe(&config.endpoint).await {
Err(err) => {
self.stop_started_server(config.id, &handle).await;
self.stop_started_server(config.id, handle).await;
return self.fail(config.id, err);
}
Ok(ModelServerStatus::ReadyReused | ModelServerStatus::ReadyStarted) => {
@ -345,7 +373,7 @@ impl EnsureLocalModelServer {
ModelServerLifecycleStatus::Ready { reused: false },
);
return Ok(EnsureLocalModelServerOutput {
ready: ready(&config, ModelServerStatus::ReadyStarted),
ready: ready(config, ModelServerStatus::ReadyStarted),
});
}
Ok(ModelServerStatus::Unreachable) => {
@ -356,9 +384,83 @@ impl EnsureLocalModelServer {
}
}
let err = ModelServerError::Timeout;
self.stop_started_server(config.id, &handle).await;
self.fail(config.id, err)
let Some(source) = hf_source else {
let err = ModelServerError::Timeout;
self.stop_started_server(config.id, handle).await;
return self.fail(config.id, err);
};
match self.process.status(handle).await {
Ok(ProcessStatus::Running) => {
self.publish(
config.id,
ModelServerLifecycleStatus::Downloading {
downloaded_bytes: None,
total_bytes: None,
percent: None,
source: Some(source.clone()),
},
);
}
Ok(ProcessStatus::Exited { code }) => {
self.active.lock().unwrap().remove(&config.id);
return self.fail(config.id, premature_exit_error(code));
}
Ok(ProcessStatus::Unknown) => {
self.active.lock().unwrap().remove(&config.id);
return self.fail(
config.id,
ModelServerError::Process("process status unknown".to_owned()),
);
}
Err(err) => return self.fail(config.id, err),
}
let deadline = Instant::now() + self.hf_download_deadline;
loop {
if Instant::now() >= deadline {
let err = ModelServerError::Timeout;
self.stop_started_server(config.id, handle).await;
return self.fail(config.id, err);
}
match self.probe.probe(&config.endpoint).await {
Err(err) => {
self.stop_started_server(config.id, handle).await;
return self.fail(config.id, err);
}
Ok(ModelServerStatus::ReadyReused | ModelServerStatus::ReadyStarted) => {
self.publish(
config.id,
ModelServerLifecycleStatus::Ready { reused: false },
);
return Ok(EnsureLocalModelServerOutput {
ready: ready(config, ModelServerStatus::ReadyStarted),
});
}
Ok(ModelServerStatus::Unreachable) => {}
}
match self.process.status(handle).await {
Ok(ProcessStatus::Running) => {
if !self.readiness.backoff.is_zero() {
tokio::time::sleep(self.readiness.backoff).await;
} else {
tokio::task::yield_now().await;
}
}
Ok(ProcessStatus::Exited { code }) => {
self.active.lock().unwrap().remove(&config.id);
return self.fail(config.id, premature_exit_error(code));
}
Ok(ProcessStatus::Unknown) => {
self.active.lock().unwrap().remove(&config.id);
return self.fail(
config.id,
ModelServerError::Process("process status unknown".to_owned()),
);
}
Err(err) => return self.fail(config.id, err),
}
}
}
/// Stops active servers whose policy is [`StopPolicy::StopOnAppExit`].
@ -497,6 +599,20 @@ fn ready(config: &LocalModelServerConfig, status: ModelServerStatus) -> ModelSer
}
}
fn hf_source(config: &LocalModelServerConfig) -> Option<String> {
match config.model.source.as_ref()? {
ModelSource::HuggingFace { repo } => Some(repo.as_str().to_owned()),
ModelSource::LocalPath { .. } => None,
}
}
fn premature_exit_error(code: Option<i32>) -> ModelServerError {
ModelServerError::Process(match code {
Some(code) => format!("model server exited before readiness with code {code}"),
None => "model server exited before readiness".to_owned(),
})
}
/// Stable model-server error code for event/DTO mapping.
#[must_use]
pub fn model_server_error_code(err: &ModelServerError) -> &'static str {