Compare commits
No commits in common. "main" and "v0.4-mmap20-stable" have entirely different histories.
main
...
v0.4-mmap2
5 changed files with 338 additions and 659 deletions
|
|
@ -10,8 +10,8 @@ android {
|
||||||
applicationId = "de.ody"
|
applicationId = "de.ody"
|
||||||
minSdk = 28
|
minSdk = 28
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 6
|
versionCode = 4
|
||||||
versionName = "0.6.0"
|
versionName = "0.4.0"
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -22,7 +22,22 @@ import kotlinx.coroutines.launch
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ODY AssistantEngine — mmap21
|
* ODY AssistantEngine — mmap12 / ODY-RUNTIME-STATEFLOW-BRIDGE-FIX-1
|
||||||
|
*
|
||||||
|
* 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) {
|
||||||
|
|
||||||
|
|
@ -42,10 +57,8 @@ 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
|
||||||
|
|
@ -82,7 +95,6 @@ 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")
|
||||||
|
|
@ -95,34 +107,6 @@ 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
|
||||||
|
|
@ -134,7 +118,9 @@ 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()
|
||||||
|
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,113 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
Loading…
Add table
Reference in a new issue