128 lines
4.9 KiB
Kotlin
128 lines
4.9 KiB
Kotlin
package com.kazeia.dist
|
|
|
|
import android.content.Context
|
|
import android.os.StatFs
|
|
import android.util.Log
|
|
import com.kazeia.KazeiaPaths
|
|
import kotlinx.coroutines.Dispatchers
|
|
import kotlinx.coroutines.withContext
|
|
import java.io.File
|
|
|
|
/**
|
|
* Orchestrateur du provisionnement modèles : récupère le catalogue, calcule
|
|
* le diff avec l'état local, télécharge les composants manquants ou périmés,
|
|
* et met à jour [ProvisioningState].
|
|
*
|
|
* Les fichiers sont écrits sous `getExternalFilesDir("kazeia")` ; les chemins
|
|
* `RemoteFile.local` sont relatifs à cette racine (`models/...`, `llm/...`)
|
|
* et correspondent à la résolution de [KazeiaPaths] sur tablette de production.
|
|
*/
|
|
class ProvisioningManager(context: Context) {
|
|
|
|
private val appContext = context.applicationContext
|
|
private val client = KazeiaDistClient(appContext)
|
|
|
|
/** Racine d'écriture des modèles téléchargés. */
|
|
val root: File = File(appContext.getExternalFilesDir(null), "kazeia")
|
|
|
|
private val downloader = ModelDownloader(client, root)
|
|
|
|
/** Progression rapportée pendant le provisionnement. */
|
|
data class Progress(
|
|
val componentLabel: String,
|
|
val currentFile: String,
|
|
val overallDone: Long,
|
|
val overallTotal: Long
|
|
)
|
|
|
|
sealed class Outcome {
|
|
object Success : Outcome()
|
|
data class Failed(val reason: String) : Outcome()
|
|
}
|
|
|
|
fun probe(): Boolean = client.probe()
|
|
|
|
suspend fun fetchCatalog(): Catalog? = withContext(Dispatchers.IO) { client.fetchCatalog() }
|
|
|
|
/**
|
|
* Catalogue voix — optionnel et dynamique (le clinicien ajoute des voix
|
|
* sans toucher au catalogue modèles figé). `null` si absent du Nextcloud.
|
|
*/
|
|
suspend fun fetchVoicesCatalog(): Catalog? = withContext(Dispatchers.IO) {
|
|
client.fetchCatalog(KazeiaDistClient.VOICES_CATALOG_PATH)
|
|
}
|
|
|
|
/** Espace libre (octets) sur le volume de stockage des modèles. */
|
|
fun freeBytes(): Long = runCatching {
|
|
root.mkdirs()
|
|
StatFs(root.path).availableBytes
|
|
}.getOrDefault(0L)
|
|
|
|
/**
|
|
* Heuristique hors-ligne : les modèles cœur sont-ils présents sur le
|
|
* disque résolu par [KazeiaPaths] ? Vrai sur tablette de dev (legacy
|
|
* `/data/local/tmp` peuplé) comme sur tablette de production déjà
|
|
* provisionnée — permet à l'onboarding de court-circuiter sans réseau.
|
|
*/
|
|
fun hasCoreModels(): Boolean {
|
|
// Cœur post-refonte 2026-06-17 : Speaker GGUF (q35-lmq4) + Whisper STT.
|
|
// (Le LLM .pte est devenu une alternative admin ; le TTS est provisionné
|
|
// à part — cf #286 productisation CosyVoice.)
|
|
val llm = File(KazeiaPaths.modelsDir, "q35-lmq4.gguf").exists()
|
|
val whisper = File(KazeiaPaths.modelsDir, "whisper-small-sm8750").isDirectory
|
|
return llm && whisper
|
|
}
|
|
|
|
/** Composants à (re)télécharger selon le diff catalogue ↔ état local. */
|
|
fun pending(catalog: Catalog): List<Component> =
|
|
ProvisioningState.load(appContext).missingOrOutdated(catalog)
|
|
|
|
/**
|
|
* Télécharge tous les composants manquants/périmés du catalogue.
|
|
* Met à jour [ProvisioningState] après CHAQUE composant complet — un
|
|
* arrêt en cours de route ne reperd que le composant courant.
|
|
*/
|
|
suspend fun provision(
|
|
catalog: Catalog,
|
|
onProgress: (Progress) -> Unit
|
|
): Outcome = withContext(Dispatchers.IO) {
|
|
val state = ProvisioningState.load(appContext)
|
|
val todo = state.missingOrOutdated(catalog)
|
|
if (todo.isEmpty()) return@withContext Outcome.Success
|
|
|
|
val overallTotal = todo.sumOf { it.totalSize }
|
|
var overallBase = 0L
|
|
|
|
for (comp in todo) {
|
|
for (rf in comp.files) {
|
|
val res = downloader.download(rf) { done, _ ->
|
|
onProgress(
|
|
Progress(
|
|
componentLabel = comp.label,
|
|
currentFile = rf.local.substringAfterLast('/'),
|
|
overallDone = overallBase + done,
|
|
overallTotal = overallTotal
|
|
)
|
|
)
|
|
}
|
|
if (res is ModelDownloader.Result.Failed) {
|
|
Log.w(TAG, "provision échec sur ${rf.local}: ${res.reason}")
|
|
return@withContext Outcome.Failed("${rf.local} : ${res.reason}")
|
|
}
|
|
overallBase += rf.size
|
|
}
|
|
// Composant complet et vérifié → état persisté.
|
|
state.markInstalled(comp.id, comp.version)
|
|
state.catalogVersion = catalog.catalogVersion
|
|
state.lastCheck = System.currentTimeMillis()
|
|
ProvisioningState.save(appContext, state)
|
|
Log.i(TAG, "composant « ${comp.id} » v${comp.version} installé")
|
|
}
|
|
Outcome.Success
|
|
}
|
|
|
|
companion object {
|
|
private const val TAG = "ProvisioningManager"
|
|
}
|
|
}
|