Compare commits

...

3 commits

5 changed files with 663 additions and 342 deletions

View file

@ -10,8 +10,8 @@ android {
applicationId = "de.ody" applicationId = "de.ody"
minSdk = 28 minSdk = 28
targetSdk = 36 targetSdk = 36
versionCode = 4 versionCode = 6
versionName = "0.4.0" versionName = "0.6.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
signingConfigs { signingConfigs {

File diff suppressed because it is too large Load diff

View file

@ -22,22 +22,7 @@ import kotlinx.coroutines.launch
import java.io.File import java.io.File
/** /**
* ODY AssistantEngine mmap12 / ODY-RUNTIME-STATEFLOW-BRIDGE-FIX-1 * ODY AssistantEngine mmap21
*
* Root Cause des bisherigen Bugs:
* val runtimeState: StateFlow<RuntimeState>
* get() = runtime.state
*
* collectAsState() in Compose abonniert den Flow beim ersten Compose-Lauf.
* Wenn runtime danach intern ausgetauscht wird (SimulationRuntime LlamaCppRuntime),
* weiß Compose nicht, dass es einen anderen Flow sammeln muss.
* Ergebnis: UI lauscht auf altem SimulationRuntime.state, Engine nutzt neue Runtime.
*
* Fix:
* - Stabiler engine-owned _runtimeState MutableStateFlow wird NIEMALS ausgetauscht
* - bindRuntime() forwarded newRuntime.state _runtimeState via runtimeStateJob
* - runtimeStateJob wird bei jedem Runtime-Wechsel gecancelt und neu gestartet
* - Lifecycle-Cleanup in release(): runtimeStateJob canceln, Runtime freigeben
*/ */
class AssistantEngine(private val context: Context) { class AssistantEngine(private val context: Context) {
@ -57,8 +42,10 @@ class AssistantEngine(private val context: Context) {
private val _runtimeState = MutableStateFlow<RuntimeState>(RuntimeState.UNLOADED) private val _runtimeState = MutableStateFlow<RuntimeState>(RuntimeState.UNLOADED)
val runtimeState: StateFlow<RuntimeState> = _runtimeState.asStateFlow() val runtimeState: StateFlow<RuntimeState> = _runtimeState.asStateFlow()
private val _currentModelId = MutableStateFlow<String?>(null)
val currentModelId: StateFlow<String?> = _currentModelId.asStateFlow()
// Job der newRuntime.state → _runtimeState forwarded // Job der newRuntime.state → _runtimeState forwarded
// Wird bei jedem Runtime-Wechsel gecancelt und neu gestartet
private var runtimeStateJob: Job? = null private var runtimeStateJob: Job? = null
val isSimulation: Boolean val isSimulation: Boolean
@ -95,6 +82,7 @@ class AssistantEngine(private val context: Context) {
contextLength = 4096 contextLength = 4096
) )
) )
_currentModelId.value = modelFile.nameWithoutExtension
} }
} else { } else {
Log.i(TAG, "start: Kein Modell → SimulationRuntime aktiv") Log.i(TAG, "start: Kein Modell → SimulationRuntime aktiv")
@ -107,6 +95,34 @@ class AssistantEngine(private val context: Context) {
scope.launch { runtime.unload() } scope.launch { runtime.unload() }
} }
/**
* Wechselt das aktive Modell zur Laufzeit.
* Entlädt die aktuelle Runtime, bindet eine neue LlamaCppRuntime und lädt das neue Modell.
*/
fun loadModel(modelFile: File) {
scope.launch {
Log.i(TAG, "loadModel: ${modelFile.name}")
try {
_currentModelId.value = null
runtime.unload()
val llamaRuntime = LlamaCppRuntime(context)
bindRuntime(llamaRuntime)
llamaRuntime.loadModel(
modelPath = modelFile.absolutePath,
config = ModelConfig(
modelPath = modelFile.absolutePath,
modelId = modelFile.nameWithoutExtension,
contextLength = 4096
)
)
_currentModelId.value = modelFile.nameWithoutExtension
Log.i(TAG, "loadModel: ${modelFile.name} erfolgreich geladen")
} catch (e: Throwable) {
Log.e(TAG, "loadModel: Fehler beim Laden von ${modelFile.name}", e)
}
}
}
suspend fun generate( suspend fun generate(
request: GenerationRequest, request: GenerationRequest,
onToken: (String) -> Unit onToken: (String) -> Unit
@ -118,9 +134,7 @@ class AssistantEngine(private val context: Context) {
fun cancel() = runtime.cancel() fun cancel() = runtime.cancel()
/** /**
* Vollständiges Lifecycle-Cleanup: * Vollständiges Lifecycle-Cleanup.
* - runtimeStateJob canceln (kein weiteres Forwarding)
* - Runtime via unload() freigeben
*/ */
fun release() { fun release() {
runtimeStateJob?.cancel() runtimeStateJob?.cancel()

View file

@ -0,0 +1,55 @@
package de.ody.model
import java.io.File
/**
* Repräsentiert ein lokal verfügbares GGUF-Modell.
* Vorbereitet für ODY-DEVICE-PROFILER-0.1 (supportsNoThink, family, estimatedRamBytes).
*/
enum class ModelFamily { QWEN3, MINISTRAL, LFM, UNKNOWN }
data class LocalModel(
val id: String, // z.B. "Qwen3-1.7B-Q4_K_M"
val displayName: String, // z.B. "Qwen3 1.7B Q4_K_M"
val file: File,
val sizeBytes: Long,
val family: ModelFamily,
val supportsNoThink: Boolean, // /no_think Token unterstützt (Qwen3)
val estimatedRamBytes: Long, // Schätzung: Dateigröße * 1.2
val lastLoadSuccessful: Boolean = true,
) {
val sizeMb: Int get() = (sizeBytes / 1024 / 1024).toInt()
val sizeDisplay: String get() = when {
sizeBytes >= 1_073_741_824L -> "%.1f GB".format(sizeBytes / 1_073_741_824.0)
else -> "%d MB".format(sizeMb)
}
companion object {
/** Erstellt ein LocalModel aus einer GGUF-Datei mit automatischer Metadaten-Erkennung */
fun fromFile(file: File): LocalModel {
val name = file.nameWithoutExtension
val family = when {
name.contains("qwen3", ignoreCase = true) -> ModelFamily.QWEN3
name.contains("ministral", ignoreCase = true) -> ModelFamily.MINISTRAL
name.contains("lfm", ignoreCase = true) -> ModelFamily.LFM
else -> ModelFamily.UNKNOWN
}
val displayName = name
.replace("-", " ")
.replace("_", " ")
.split(" ")
.joinToString(" ") { it.replaceFirstChar { c -> c.uppercase() } }
return LocalModel(
id = name,
displayName = displayName,
file = file,
sizeBytes = file.length(),
family = family,
supportsNoThink = family == ModelFamily.QWEN3,
estimatedRamBytes = (file.length() * 1.2).toLong(),
lastLoadSuccessful = true,
)
}
}
}

View file

@ -0,0 +1,113 @@
package de.ody.model
import android.content.Context
import android.util.Log
import java.io.File
import java.io.InputStream
/**
* ModelManager scannt ODYs eigenen models/-Ordner und verwaltet Modellwechsel.
* Import über Android Storage Access Framework (ACTION_OPEN_DOCUMENT) wird in
* MainActivity.kt gehandhabt; der URI-Stream wird hier per importFromStream() abgelegt.
*/
class ModelManager(private val context: Context) {
companion object {
private const val TAG = "ModelManager"
private val GGUF_MAGIC = byteArrayOf(0x47, 0x47, 0x55, 0x46) // "GGUF"
private const val MIN_SIZE_BYTES = 50L * 1024 * 1024 // 50 MB Mindestgröße
}
private fun modelsDir(): File {
val dir = File(context.filesDir, "models")
dir.mkdirs()
return dir
}
/** Scannt den models/-Ordner und gibt alle validen GGUF-Dateien zurück */
fun scanModels(): List<LocalModel> {
val dir = modelsDir()
val files = dir.listFiles() ?: return emptyList()
return files
.filter { it.isFile && it.extension.equals("gguf", ignoreCase = true) }
.filter { it.length() >= MIN_SIZE_BYTES }
.filter { isValidGguf(it) }
.sortedByDescending { it.length() }
.map { LocalModel.fromFile(it) }
.also { Log.i(TAG, "scanModels: ${it.size} Modelle gefunden in ${dir.absolutePath}") }
}
/** Prüft GGUF-Magic-Bytes */
private fun isValidGguf(file: File): Boolean {
return try {
val magic = file.inputStream().use { it.readNBytes(4) }
magic.contentEquals(GGUF_MAGIC)
} catch (e: Exception) {
Log.w(TAG, "isValidGguf: Fehler bei ${file.name}: ${e.message}")
false
}
}
/**
* Importiert ein Modell aus einem InputStream (z.B. von ACTION_OPEN_DOCUMENT URI).
* Schreibt in models/<fileName>.gguf.part, dann atomar umbenennen.
* Gibt die fertige File zurück oder null bei Fehler.
*/
fun importFromStream(
inputStream: InputStream,
fileName: String,
onProgress: (Long, Long) -> Unit = { _, _ -> },
): File? {
val safeName = fileName.replace(Regex("[^a-zA-Z0-9._ -]"), "_")
.let { if (it.endsWith(".gguf")) it else "$it.gguf" }
val partFile = File(modelsDir(), "$safeName.part")
val finalFile = File(modelsDir(), safeName)
if (finalFile.exists() && isValidGguf(finalFile)) {
Log.i(TAG, "importFromStream: $safeName bereits vorhanden")
return finalFile
}
return try {
val buffer = ByteArray(65_536)
var written = 0L
partFile.outputStream().use { out ->
var n: Int
while (inputStream.read(buffer).also { n = it } != -1) {
out.write(buffer, 0, n)
written += n
onProgress(written, -1L)
}
out.flush()
}
if (!isValidGguf(partFile)) {
partFile.delete()
Log.e(TAG, "importFromStream: Ungültige GGUF-Datei nach Import")
null
} else {
partFile.renameTo(finalFile)
Log.i(TAG, "importFromStream: $safeName erfolgreich importiert (${written / 1024 / 1024} MB)")
finalFile
}
} catch (e: Exception) {
Log.e(TAG, "importFromStream: Fehler", e)
partFile.delete()
null
}
}
/** Löscht ein Modell aus dem models/-Ordner */
fun deleteModel(model: LocalModel): Boolean {
return try {
model.file.delete().also {
Log.i(TAG, "deleteModel: ${model.id} gelöscht=$it")
}
} catch (e: Exception) {
Log.e(TAG, "deleteModel: Fehler", e)
false
}
}
/** Gibt den models/-Pfad zurück (für ADB-Hinweise) */
fun getModelsDirPath(): String = modelsDir().absolutePath
}