fix: livrable tickets #70 #100 #102 — UX surface scoping

- #70: implémentation suppression modèles locaux téléchargés
- #100: correction scroll OpenCode
- #102: correction fit TUI après switch/layout
- memory note scoping UX
This commit is contained in:
2026-07-26 19:42:06 +02:00
parent 4321d048ac
commit 02603441c1
31 changed files with 1589 additions and 152 deletions

View File

@ -2,7 +2,7 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use async_trait::async_trait;
@ -10,6 +10,7 @@ use futures_util::StreamExt;
use serde::{Deserialize, Serialize};
use tokio::io::AsyncWriteExt;
use tokio::process::{Child, Command};
use tokio::sync::Mutex as AsyncMutex;
use domain::model_server::{
ExecutablePath, HfModelRef, LlamaCppOptions, LocalModelRef, LocalModelServerConfig,
@ -17,9 +18,9 @@ use domain::model_server::{
};
use domain::ports::{
FileSystem, ManagedProcess, ManagedProcessHandle, ModelArtifactCancel, ModelArtifactDownloader,
ModelArtifactProgress, ModelArtifactResolution, ModelServerArgv, ModelServerError,
ModelServerProbe, ModelServerRegistry, ModelServerRuntime, ProcessStatus, RemotePath,
SpawnSpec,
ModelArtifactProgress, ModelArtifactResolution, ModelArtifactState, ModelServerArgv,
ModelServerError, ModelServerProbe, ModelServerRegistry, ModelServerRuntime, ProcessStatus,
RemotePath, SpawnSpec,
};
use domain::{LocalModelServerId, ProjectPath, StopPolicy};
@ -76,6 +77,7 @@ fn is_ready(result: Result<reqwest::Response, reqwest::Error>) -> bool {
pub struct HfModelArtifactDownloader {
cache_dir: PathBuf,
client: reqwest::Client,
repo_locks: Arc<Mutex<HashMap<String, Arc<AsyncMutex<()>>>>>,
}
impl HfModelArtifactDownloader {
@ -85,6 +87,7 @@ impl HfModelArtifactDownloader {
Self {
cache_dir: cache_dir.into(),
client: reqwest::Client::new(),
repo_locks: Arc::new(Mutex::new(HashMap::new())),
}
}
@ -149,6 +152,62 @@ impl HfModelArtifactDownloader {
std::fs::write(manifest_path, json)
}
fn lock_for(&self, repo: &HfModelRef) -> Arc<AsyncMutex<()>> {
let mut locks = self.repo_locks.lock().expect("repo locks mutex poisoned");
Arc::clone(
locks
.entry(repo.as_str().to_owned())
.or_insert_with(|| Arc::new(AsyncMutex::new(()))),
)
}
fn cached_state(&self, repo: &HfModelRef) -> Result<ModelArtifactState, ModelServerError> {
let merged_path = self.cache_path_for(repo);
if merged_path.is_file() {
return Ok(ModelArtifactState::Downloaded {
size_bytes: Some(file_size(&merged_path)?),
path: model_path_from_pathbuf(merged_path)?,
});
}
if let Some(paths) = self.cached_shard_set(repo) {
if let Some(first) = paths.first() {
return Ok(ModelArtifactState::Downloaded {
size_bytes: Some(paths_size(&paths)?),
path: model_path_from_pathbuf(first.clone())?,
});
}
}
Ok(ModelArtifactState::Missing)
}
fn delete_cached(&self, repo: &HfModelRef) -> Result<(), ModelServerError> {
let merged_path = self.cache_path_for(repo);
if merged_path.is_file() {
remove_file_if_exists(&merged_path)?;
}
let manifest_path = self.manifest_path_for(repo);
if let Ok(raw) = std::fs::read(&manifest_path) {
let manifest: ShardManifest =
serde_json::from_slice(&raw).map_err(|e| ModelServerError::Store(e.to_string()))?;
let dir = self.cache_dir_for(repo);
for filename in manifest.files {
remove_file_if_exists(&dir.join(filename))?;
}
remove_file_if_exists(&manifest_path)?;
}
let repo_dir = self.cache_dir_for(repo);
if repo_dir.is_dir()
&& std::fs::read_dir(&repo_dir)
.map(is_empty_dir)
.unwrap_or(false)
{
std::fs::remove_dir(&repo_dir).map_err(|e| ModelServerError::Store(e.to_string()))?;
}
Ok(())
}
async fn resolve_remote_filenames(
&self,
repo: &HfModelRef,
@ -189,12 +248,23 @@ struct ShardManifest {
#[async_trait]
impl ModelArtifactDownloader for HfModelArtifactDownloader {
async fn hf_model_state(
&self,
repo: &HfModelRef,
) -> Result<ModelArtifactState, ModelServerError> {
let lock = self.lock_for(repo);
let _guard = lock.lock().await;
self.cached_state(repo)
}
async fn resolve_hf_model(
&self,
repo: &HfModelRef,
progress: std::sync::Arc<dyn Fn(ModelArtifactProgress) + Send + Sync>,
cancel: ModelArtifactCancel,
) -> Result<ModelArtifactResolution, ModelServerError> {
let lock = self.lock_for(repo);
let _guard = lock.lock().await;
if cancel.is_cancelled() {
return Err(ModelServerError::Cancelled);
}
@ -300,6 +370,12 @@ impl ModelArtifactDownloader for HfModelArtifactDownloader {
cache_hit: false,
})
}
async fn delete_hf_model(&self, repo: &HfModelRef) -> Result<(), ModelServerError> {
let lock = self.lock_for(repo);
let _guard = lock.lock().await;
self.delete_cached(repo)
}
}
#[derive(Debug, Deserialize)]
@ -409,6 +485,31 @@ fn model_path_from_pathbuf(path: PathBuf) -> Result<ModelPath, ModelServerError>
.map_err(|e| ModelServerError::Invalid(e.to_string()))
}
fn file_size(path: &Path) -> Result<u64, ModelServerError> {
std::fs::metadata(path)
.map(|metadata| metadata.len())
.map_err(|e| ModelServerError::Store(e.to_string()))
}
fn paths_size(paths: &[PathBuf]) -> Result<u64, ModelServerError> {
paths
.iter()
.map(|path| file_size(path))
.try_fold(0_u64, |acc, size| size.map(|size| acc.saturating_add(size)))
}
fn remove_file_if_exists(path: &Path) -> Result<(), ModelServerError> {
match std::fs::remove_file(path) {
Ok(()) => Ok(()),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(err) => Err(ModelServerError::Store(err.to_string())),
}
}
fn is_empty_dir(entries: std::fs::ReadDir) -> bool {
entries.into_iter().next().is_none()
}
/// Builds `llama-server` argv without shell interpolation.
#[derive(Debug, Default, Clone, Copy)]
pub struct LlamaCppRuntime;