From 274e73cb4d64912bf2cf5e5c5091d72623888f38 Mon Sep 17 00:00:00 2001 From: ODY Build Date: Tue, 21 Jul 2026 09:14:40 +0000 Subject: [PATCH] mmap21: Modell-Picker, Thinking-Indikator, LocalModel, ModelManager, versionCode=5 --- app/build.gradle.kts | 4 +- app/src/main/java/de/ody/app/MainActivity.kt | 778 +++++++++++------- app/src/main/java/de/ody/model/LocalModel.kt | 55 ++ .../main/java/de/ody/model/ModelManager.kt | 113 +++ 4 files changed, 628 insertions(+), 322 deletions(-) create mode 100644 app/src/main/java/de/ody/model/LocalModel.kt create mode 100644 app/src/main/java/de/ody/model/ModelManager.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9d5cb51..79e9243 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -10,8 +10,8 @@ android { applicationId = "de.ody" minSdk = 28 targetSdk = 36 - versionCode = 4 - versionName = "0.4.0" + versionCode = 5 + versionName = "0.5.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } signingConfigs { diff --git a/app/src/main/java/de/ody/app/MainActivity.kt b/app/src/main/java/de/ody/app/MainActivity.kt index b90a8e0..7d23dc5 100644 --- a/app/src/main/java/de/ody/app/MainActivity.kt +++ b/app/src/main/java/de/ody/app/MainActivity.kt @@ -1,26 +1,32 @@ package de.ody.app import android.content.Context +import android.content.Intent import android.net.ConnectivityManager import android.net.NetworkCapabilities +import android.net.Uri import android.os.BatteryManager import android.os.Bundle import androidx.activity.ComponentActivity +import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.core.* import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Send -import androidx.compose.material.icons.filled.Stop +import androidx.compose.material.icons.filled.* import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.SpanStyle @@ -37,10 +43,11 @@ import de.ody.app.engine.AssistantEngine import de.ody.guards.GuardAlert import de.ody.guards.GuardType import de.ody.guards.GuardSeverity +import de.ody.model.LocalModel +import de.ody.model.ModelManager import de.ody.runtime.GenerationRequest import de.ody.runtime.RuntimeState import kotlinx.coroutines.Dispatchers -import androidx.lifecycle.lifecycleScope import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -50,14 +57,13 @@ import kotlinx.coroutines.withContext enum class MessageState { GENERATING, COMPLETE, ERROR } data class ChatMessage( - val role: String, // "user" | "assistant" + val role: String, val content: String, val state: MessageState = MessageState.COMPLETE, ) // ───────────────────────────────────────────────────────────────────────────── -// Markdown-Parser — robuster Subset: **fett**, # ## ###, 1. Listen, - Listen -// Kein Crash bei fehlerhafter Eingabe. +// Markdown-Renderer (Subset: **fett**, # ## ###, 1. Listen, - Listen) // ───────────────────────────────────────────────────────────────────────────── @Composable fun MarkdownText( @@ -70,35 +76,24 @@ fun MarkdownText( val lines = text.split("\n") lines.forEachIndexed { lineIdx, rawLine -> val line = rawLine.trimEnd() - - // Überschriften: # ## ### val headingMatch = Regex("^(#{1,3})\\s+(.*)").find(line) if (headingMatch != null) { val level = headingMatch.groupValues[1].length val headingText = headingMatch.groupValues[2] val headingSize = when (level) { 1 -> 1.25f; 2 -> 1.1f; else -> 1.0f } - withStyle(SpanStyle( - fontWeight = FontWeight.Bold, - fontSize = (fontSize.value * headingSize).sp, - )) { + withStyle(SpanStyle(fontWeight = FontWeight.Bold, fontSize = (fontSize.value * headingSize).sp)) { appendInlineBold(headingText) } if (lineIdx < lines.lastIndex) append("\n") return@forEachIndexed } - - // Nummerierte Liste: "1. " "2. " etc. val numberedMatch = Regex("^(\\d+\\.\\s+)(.*)").find(line) if (numberedMatch != null) { - withStyle(SpanStyle(fontWeight = FontWeight.Medium)) { - append(numberedMatch.groupValues[1]) - } + withStyle(SpanStyle(fontWeight = FontWeight.Medium)) { append(numberedMatch.groupValues[1]) } appendInlineBold(numberedMatch.groupValues[2]) if (lineIdx < lines.lastIndex) append("\n") return@forEachIndexed } - - // Aufzählung: "- " oder "* " val bulletMatch = Regex("^[-*]\\s+(.*)").find(line) if (bulletMatch != null) { append("• ") @@ -106,37 +101,332 @@ fun MarkdownText( if (lineIdx < lines.lastIndex) append("\n") return@forEachIndexed } - - // Normaler Text mit **fett** appendInlineBold(line) if (lineIdx < lines.lastIndex) append("\n") } } - - Text( - text = annotated, - color = color, - fontSize = fontSize, - lineHeight = lineHeight, - ) + Text(text = annotated, color = color, fontSize = fontSize, lineHeight = lineHeight) } -// Inline **fett** parsen — nie crashen private fun androidx.compose.ui.text.AnnotatedString.Builder.appendInlineBold(text: String) { - // Teile den Text an ** auf val parts = text.split("**") parts.forEachIndexed { idx, part -> - if (idx % 2 == 1) { - // Ungerade Index = zwischen ** ** = fett - withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { - append(part) - } - } else { - append(part) + if (idx % 2 == 1) withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(part) } + else append(part) + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Thinking-Indikator (pulsierende Punkte) +// ───────────────────────────────────────────────────────────────────────────── +@Composable +fun ThinkingIndicator(color: Color) { + val infiniteTransition = rememberInfiniteTransition(label = "thinking") + val dot1 = infiniteTransition.animateFloat( + initialValue = 0.3f, targetValue = 1f, label = "d1", + animationSpec = infiniteRepeatable(tween(600), RepeatMode.Reverse) + ) + val dot2 = infiniteTransition.animateFloat( + initialValue = 0.3f, targetValue = 1f, label = "d2", + animationSpec = infiniteRepeatable(tween(600, delayMillis = 200), RepeatMode.Reverse) + ) + val dot3 = infiniteTransition.animateFloat( + initialValue = 0.3f, targetValue = 1f, label = "d3", + animationSpec = infiniteRepeatable(tween(600, delayMillis = 400), RepeatMode.Reverse) + ) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { + Text("ODY denkt", color = color, fontSize = 13.sp, fontWeight = FontWeight.Medium) + Spacer(Modifier.width(4.dp)) + listOf(dot1.value, dot2.value, dot3.value).forEach { alpha -> + Box(Modifier.size(6.dp).alpha(alpha).background(color, RoundedCornerShape(50))) } } } +// ───────────────────────────────────────────────────────────────────────────── +// Modell-Picker Screen +// ───────────────────────────────────────────────────────────────────────────── +@Composable +fun ModelPickerScreen( + engine: AssistantEngine, + modelManager: ModelManager, + currentModelId: String?, + onDismiss: () -> Unit, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + var models by remember { mutableStateOf>(emptyList()) } + var isLoading by remember { mutableStateOf(false) } + var importProgress by remember { mutableStateOf(null) } + var errorMsg by remember { mutableStateOf(null) } + + val bgColor = Color(0xFF0D0D0F) + val surfaceColor = Color(0xFF1A1A1E) + val accentColor = Color(0xFF4A9EFF) + val textPrimary = Color(0xFFE8E8EC) + val textSecondary= Color(0xFF8A8A9A) + val successColor = Color(0xFF2ECC71) + val warningColor = Color(0xFFE8A020) + val errorColor = Color(0xFFFF6B6B) + + // Modelle beim Öffnen scannen + LaunchedEffect(Unit) { + models = withContext(Dispatchers.IO) { modelManager.scanModels() } + } + + // Datei-Picker für Import + val importLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument() + ) { uri: Uri? -> + if (uri == null) return@rememberLauncherForActivityResult + scope.launch { + importProgress = "Importiere..." + errorMsg = null + val fileName = uri.lastPathSegment?.substringAfterLast('/') ?: "model.gguf" + val result = withContext(Dispatchers.IO) { + try { + context.contentResolver.openInputStream(uri)?.use { stream -> + modelManager.importFromStream(stream, fileName) { written, _ -> + val mb = written / 1024 / 1024 + importProgress = "Importiere... $mb MB" + } + } + } catch (e: Exception) { + null + } + } + importProgress = null + if (result != null) { + models = withContext(Dispatchers.IO) { modelManager.scanModels() } + } else { + errorMsg = "Import fehlgeschlagen. Bitte eine gültige .gguf-Datei wählen." + } + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .background(bgColor) + .systemBarsPadding() + ) { + // Header + Row( + modifier = Modifier + .fillMaxWidth() + .background(surfaceColor) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = onDismiss) { + Icon(Icons.Default.ArrowBack, contentDescription = "Zurück", tint = textPrimary) + } + Text( + text = "Lokale Modelle", + color = textPrimary, + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f), + ) + IconButton(onClick = { + scope.launch { + models = withContext(Dispatchers.IO) { modelManager.scanModels() } + } + }) { + Icon(Icons.Default.Refresh, contentDescription = "Aktualisieren", tint = accentColor) + } + } + + // Modell-Pfad Info + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 6.dp) + .background(Color(0xFF0A1020), RoundedCornerShape(8.dp)) + .padding(horizontal = 12.dp, vertical = 8.dp) + ) { + Text( + text = "📁 ${modelManager.getModelsDirPath()}", + color = textSecondary, + fontSize = 11.sp, + ) + } + + // Fehler + errorMsg?.let { msg -> + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 4.dp) + .background(Color(0xFF2A0000), RoundedCornerShape(8.dp)) + .padding(12.dp) + ) { + Text(text = "⚠ $msg", color = errorColor, fontSize = 13.sp) + } + } + + // Import-Fortschritt + importProgress?.let { progress -> + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 4.dp) + .background(Color(0xFF0A1F2A), RoundedCornerShape(8.dp)) + .padding(12.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + color = accentColor, + strokeWidth = 2.dp, + ) + Spacer(Modifier.width(8.dp)) + Text(text = progress, color = accentColor, fontSize = 13.sp) + } + } + } + + // Modell-Liste + if (models.isEmpty()) { + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .padding(32.dp), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text("🧠", fontSize = 40.sp, textAlign = TextAlign.Center) + Spacer(Modifier.height(12.dp)) + Text( + text = "Keine Modelle gefunden", + color = textPrimary, + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(8.dp)) + Text( + text = "Importiere eine .gguf-Datei oder kopiere sie per ADB in den Modellordner.", + color = textSecondary, + fontSize = 13.sp, + textAlign = TextAlign.Center, + lineHeight = 20.sp, + ) + } + } + } else { + LazyColumn( + modifier = Modifier.weight(1f).padding(horizontal = 12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(vertical = 12.dp), + ) { + items(models) { model -> + val isActive = model.id == currentModelId + Box( + modifier = Modifier + .fillMaxWidth() + .background( + if (isActive) Color(0xFF0A2040) else surfaceColor, + RoundedCornerShape(12.dp), + ) + .clickable(enabled = !isActive && !isLoading) { + scope.launch { + isLoading = true + engine.loadModel(model.file) + isLoading = false + onDismiss() + } + } + .padding(16.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + // Status-Indikator + Box( + modifier = Modifier + .size(10.dp) + .background( + if (isActive) successColor else textSecondary, + RoundedCornerShape(50), + ) + ) + Spacer(Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = model.displayName, + color = textPrimary, + fontSize = 15.sp, + fontWeight = if (isActive) FontWeight.Bold else FontWeight.Normal, + ) + Spacer(Modifier.height(2.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = model.sizeDisplay, + color = textSecondary, + fontSize = 12.sp, + ) + if (model.supportsNoThink) { + Text( + text = "⚡ Qwen3", + color = accentColor, + fontSize = 12.sp, + ) + } + Text( + text = model.family.name, + color = textSecondary, + fontSize = 12.sp, + ) + } + } + if (isActive) { + Text( + text = "Aktiv", + color = successColor, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + ) + } else if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + color = accentColor, + strokeWidth = 2.dp, + ) + } else { + Text( + text = "Laden", + color = accentColor, + fontSize = 12.sp, + ) + } + } + } + } + } + } + + // Import-Button + Button( + onClick = { importLauncher.launch(arrayOf("*/*")) }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 12.dp), + colors = ButtonDefaults.buttonColors(containerColor = accentColor), + shape = RoundedCornerShape(12.dp), + ) { + Icon(Icons.Default.Add, contentDescription = null, tint = Color.White) + Spacer(Modifier.width(8.dp)) + Text("Modell importieren (.gguf)", color = Color.White, fontSize = 15.sp) + } + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// MainActivity +// ───────────────────────────────────────────────────────────────────────────── class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -144,25 +434,14 @@ class MainActivity : ComponentActivity() { WindowCompat.setDecorFitsSystemWindows(window, false) setContent { ODYTheme { - Surface( - modifier = Modifier.fillMaxSize(), - color = Color(0xFF0D0D0F), - ) { - ODYChatScreen() + Surface(modifier = Modifier.fillMaxSize(), color = Color(0xFF0D0D0F)) { + ODYApp() } } } } companion object { - fun isDeviceOffline(context: Context): Boolean { - val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager - val net = cm.activeNetwork ?: return true - val cap = cm.getNetworkCapabilities(net) ?: return true - return !cap.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) || - !cap.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) - } - fun getBatteryLevel(context: Context): Int { val bm = context.getSystemService(Context.BATTERY_SERVICE) as BatteryManager return bm.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY) @@ -170,29 +449,52 @@ class MainActivity : ComponentActivity() { } } +@Composable +fun ODYApp() { + val context = LocalContext.current + val app = context.applicationContext as ODYApplication + val engine: AssistantEngine = remember { app.engine } + val modelManager = remember { ModelManager(context) } + val currentModelId by engine.currentModelId.collectAsState() + + var showModelPicker by remember { mutableStateOf(false) } + + if (showModelPicker) { + ModelPickerScreen( + engine = engine, + modelManager = modelManager, + currentModelId = currentModelId, + onDismiss = { showModelPicker = false }, + ) + } else { + ODYChatScreen( + engine = engine, + onOpenModelPicker = { showModelPicker = true }, + ) + } +} + // ───────────────────────────────────────────────────────────────────────────── // ODY Chat Screen // ───────────────────────────────────────────────────────────────────────────── - @Composable -fun ODYChatScreen() { +fun ODYChatScreen( + engine: AssistantEngine, + onOpenModelPicker: () -> Unit, +) { val context = LocalContext.current - val app = context.applicationContext as ODYApplication - - val engine: AssistantEngine = remember { app.engine } val runtimeState by engine.runtimeState.collectAsState() - - val scope = (LocalContext.current as? androidx.activity.ComponentActivity)?.lifecycleScope - ?: rememberCoroutineScope() + val currentModelId by engine.currentModelId.collectAsState() + val scope = rememberCoroutineScope() var textInput by remember { mutableStateOf("") } var messages by remember { mutableStateOf>(emptyList()) } var currentOutput by remember { mutableStateOf("") } + var isThinking by remember { mutableStateOf(false) } var batteryLevel by remember { mutableStateOf(0) } var guardMsg by remember { mutableStateOf(null) } val listState = rememberLazyListState() - // Farben val bgColor = Color(0xFF0D0D0F) val surfaceColor = Color(0xFF1A1A1E) val userBubbleColor = Color(0xFF1E3A5F) @@ -215,27 +517,17 @@ fun ODYChatScreen() { runtimeState == RuntimeState.UNLOADED -> "Bereit · Kein Modell" runtimeState == RuntimeState.VALIDATING -> "Prüfe Modell..." runtimeState == RuntimeState.LOADING -> "Lädt Modell..." - runtimeState == RuntimeState.MODEL_LOAD_FAILED -> "Ladefehler — Modell ungültig" + runtimeState == RuntimeState.MODEL_LOAD_FAILED -> "Ladefehler" runtimeState == RuntimeState.READY -> "Autark · Lokal aktiv" runtimeState == RuntimeState.GENERATING -> "Autark · Generiert" runtimeState == RuntimeState.ERROR -> "Fehler" else -> "Bereit" } - val statusColor = when (runtimeState) { RuntimeState.READY, RuntimeState.GENERATING -> successColor RuntimeState.LOADING -> warningColor RuntimeState.ERROR -> guardColor - RuntimeState.UNLOADED -> - if (engine.isSimulation) warningColor else textSecondary - else -> textSecondary - } - - val modelLabel = when { - engine.isSimulation -> "🧠 Kein Modell" - runtimeState == RuntimeState.UNLOADED -> "🧠 Kein Modell" - runtimeState == RuntimeState.LOADING -> "🧠 Lädt..." - else -> "🧠 Lokal" + else -> if (engine.isSimulation) warningColor else textSecondary } Column( @@ -245,7 +537,7 @@ fun ODYChatScreen() { .systemBarsPadding() .imePadding() ) { - // ── Header ────────────────────────────────────────────────────────── + // Header Column( modifier = Modifier .fillMaxWidth() @@ -258,83 +550,33 @@ fun ODYChatScreen() { horizontalArrangement = Arrangement.SpaceBetween, ) { Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = "ODY", - color = textPrimary, - fontSize = 22.sp, - fontWeight = FontWeight.Bold, - letterSpacing = 1.sp, - ) - Spacer(modifier = Modifier.width(8.dp)) - Box( - modifier = Modifier - .size(8.dp) - .background(statusColor, RoundedCornerShape(50)) - ) - Spacer(modifier = Modifier.width(6.dp)) - Text( - text = "🛡 $statusText", - color = statusColor, - fontSize = 13.sp, - fontWeight = FontWeight.Medium, - ) + Text("ODY", color = textPrimary, fontSize = 22.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.sp) + Spacer(Modifier.width(8.dp)) + Box(Modifier.size(8.dp).background(statusColor, RoundedCornerShape(50))) + Spacer(Modifier.width(6.dp)) + Text("🛡 $statusText", color = statusColor, fontSize = 13.sp, fontWeight = FontWeight.Medium) } Row(verticalAlignment = Alignment.CenterVertically) { - Text(text = modelLabel, color = textSecondary, fontSize = 12.sp) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = "🔋 $batteryLevel%", - color = if (batteryLevel < 20) warningColor else textSecondary, - fontSize = 12.sp, - ) + // Modell-Picker Button + TextButton( + onClick = onOpenModelPicker, + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp), + ) { + Text( + text = "🧠 ${currentModelId?.take(12) ?: "Kein Modell"}", + color = accentColor, + fontSize = 11.sp, + ) + Spacer(Modifier.width(2.dp)) + Icon(Icons.Default.KeyboardArrowDown, contentDescription = null, tint = accentColor, modifier = Modifier.size(14.dp)) + } + Text("🔋 $batteryLevel%", color = if (batteryLevel < 20) warningColor else textSecondary, fontSize = 12.sp) } } - HorizontalDivider( - color = Color(0xFF2A2A2E), - thickness = 0.5.dp, - modifier = Modifier.padding(top = 8.dp), - ) + HorizontalDivider(color = Color(0xFF2A2A2E), thickness = 0.5.dp, modifier = Modifier.padding(top = 8.dp)) } - // ── Simulation-Banner ──────────────────────────────────────────────── - if (engine.isSimulation) { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 3.dp) - .background(Color(0xFF2A1F00), RoundedCornerShape(8.dp)) - .padding(horizontal = 14.dp, vertical = 6.dp) - ) { - Text( - text = "⚠ [Simulation] · Kein Modell geladen", - color = warningColor, - fontSize = 12.sp, - ) - } - } - - // ── Lade-Indikator ─────────────────────────────────────────────────── - if (runtimeState == RuntimeState.LOADING) { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 3.dp) - .background(Color(0xFF0A1F2A), RoundedCornerShape(8.dp)) - .padding(horizontal = 14.dp, vertical = 8.dp) - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - CircularProgressIndicator( - modifier = Modifier.size(14.dp), - color = accentColor, - strokeWidth = 2.dp, - ) - Spacer(modifier = Modifier.width(8.dp)) - Text(text = "Modell wird geladen...", color = accentColor, fontSize = 13.sp) - } - } - } - - // ── Guard-Alert ────────────────────────────────────────────────────── + // Guard-Alert guardMsg?.let { alert -> Box( modifier = Modifier @@ -343,71 +585,41 @@ fun ODYChatScreen() { .background(Color(0xFF2A0000), RoundedCornerShape(8.dp)) .padding(horizontal = 14.dp, vertical = 8.dp) ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "🛡 Guard: ${alert.message}", - color = guardColor, - fontSize = 13.sp, - modifier = Modifier.weight(1f), - ) - TextButton(onClick = { guardMsg = null }) { - Text("✕", color = textSecondary, fontSize = 12.sp) - } + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + Text("🛡 Guard: ${alert.message}", color = guardColor, fontSize = 13.sp, modifier = Modifier.weight(1f)) + TextButton(onClick = { guardMsg = null }) { Text("✕", color = textSecondary, fontSize = 12.sp) } } } } - // ── Chat-Nachrichten ───────────────────────────────────────────────── + // Chat Box(modifier = Modifier.weight(1f)) { - if (messages.isEmpty() && currentOutput.isEmpty()) { + if (messages.isEmpty() && currentOutput.isEmpty() && !isThinking) { Column( - modifier = Modifier - .fillMaxSize() - .padding(32.dp), + modifier = Modifier.fillMaxSize().padding(32.dp), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, ) { - Text(text = "🛡", fontSize = 40.sp, textAlign = TextAlign.Center) - Spacer(modifier = Modifier.height(16.dp)) + Text("🛡", fontSize = 40.sp, textAlign = TextAlign.Center) + Spacer(Modifier.height(16.dp)) + Text("ODY ist bereit.", color = textPrimary, fontSize = 18.sp, fontWeight = FontWeight.Medium, textAlign = TextAlign.Center) + Spacer(Modifier.height(8.dp)) Text( - text = "ODY ist bereit.", - color = textPrimary, - fontSize = 18.sp, - fontWeight = FontWeight.Medium, - textAlign = TextAlign.Center, - ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = if (engine.isSimulation) - "Lade ein GGUF-Modell, um echte Antworten zu erhalten." - else - "Stell eine Frage.", - color = textSecondary, - fontSize = 14.sp, - textAlign = TextAlign.Center, - lineHeight = 20.sp, + text = if (engine.isSimulation) "Lade ein Modell unter 🧠 oben." else "Stell eine Frage.", + color = textSecondary, fontSize = 14.sp, textAlign = TextAlign.Center, lineHeight = 20.sp, ) } } LazyColumn( state = listState, - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 12.dp), + modifier = Modifier.fillMaxSize().padding(horizontal = 12.dp), verticalArrangement = Arrangement.spacedBy(10.dp), contentPadding = PaddingValues(vertical = 12.dp), ) { items(messages) { msg -> val isUser = msg.role == "user" - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start, - ) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start) { Box( modifier = Modifier .fillMaxWidth(0.82f) @@ -415,10 +627,9 @@ fun ODYChatScreen() { .background( if (isUser) userBubbleColor else odyBubbleColor, RoundedCornerShape( - topStart = if (isUser) 16.dp else 4.dp, - topEnd = if (isUser) 4.dp else 16.dp, - bottomStart = 16.dp, - bottomEnd = 16.dp, + topStart = if (isUser) 16.dp else 4.dp, + topEnd = if (isUser) 4.dp else 16.dp, + bottomStart = 16.dp, bottomEnd = 16.dp, ) ) .padding(horizontal = 14.dp, vertical = 12.dp) @@ -428,59 +639,50 @@ fun ODYChatScreen() { Text( text = if (engine.isSimulation) "ODY [Simulation]" else "ODY", color = if (engine.isSimulation) warningColor else accentColor, - fontSize = 11.sp, - fontWeight = FontWeight.SemiBold, + fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(bottom = 6.dp), ) } when (msg.state) { - MessageState.ERROR -> Text( - text = msg.content, - color = errorColor, - fontSize = 15.sp, - lineHeight = 24.sp, - ) - else -> MarkdownText( - text = msg.content, - color = textPrimary, - ) + MessageState.ERROR -> Text(msg.content, color = errorColor, fontSize = 15.sp, lineHeight = 24.sp) + else -> MarkdownText(text = msg.content, color = textPrimary) } } } } } - // Streaming-Bubble: Plaintext + Cursor während Generierung - if (currentOutput.isNotEmpty()) { + // Thinking-Indikator: GENERATING aber noch kein sichtbarer Text + if (isThinking && currentOutput.isBlank()) { item { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Start, - ) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) { Box( modifier = Modifier - .fillMaxWidth(0.82f) - .background( - odyBubbleColor, - RoundedCornerShape(4.dp, 16.dp, 16.dp, 16.dp), - ) + .background(odyBubbleColor, RoundedCornerShape(4.dp, 16.dp, 16.dp, 16.dp)) .padding(horizontal = 14.dp, vertical = 12.dp) ) { Column { - Text( - text = if (engine.isSimulation) "ODY [Simulation]" else "ODY", - color = if (engine.isSimulation) warningColor else accentColor, - fontSize = 11.sp, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.padding(bottom = 6.dp), - ) - // Plaintext während Streaming — kein Markdown-Parser pro Token - Text( - text = currentOutput + "▌", - color = textPrimary, - fontSize = 15.sp, - lineHeight = 24.sp, - ) + Text("ODY", color = accentColor, fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(bottom = 6.dp)) + ThinkingIndicator(color = textSecondary) + } + } + } + } + } + + // Streaming-Blase: Plaintext + Cursor + if (currentOutput.isNotEmpty()) { + item { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) { + Box( + modifier = Modifier + .fillMaxWidth(0.82f) + .background(odyBubbleColor, RoundedCornerShape(4.dp, 16.dp, 16.dp, 16.dp)) + .padding(horizontal = 14.dp, vertical = 12.dp) + ) { + Column { + Text("ODY", color = accentColor, fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(bottom = 6.dp)) + Text(text = currentOutput + "▌", color = textPrimary, fontSize = 15.sp, lineHeight = 24.sp) } } } @@ -489,7 +691,7 @@ fun ODYChatScreen() { } } - // ── Eingabefeld ────────────────────────────────────────────────────── + // Eingabefeld Row( modifier = Modifier .fillMaxWidth() @@ -501,44 +703,36 @@ fun ODYChatScreen() { value = textInput, onValueChange = { textInput = it }, modifier = Modifier.weight(1f), - placeholder = { - Text(text = "Frag ODY...", color = textSecondary, fontSize = 15.sp) - }, + placeholder = { Text("Frag ODY...", color = textSecondary, fontSize = 15.sp) }, colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = accentColor, - unfocusedBorderColor = Color(0xFF3A3A4A), - focusedTextColor = textPrimary, - unfocusedTextColor = textPrimary, - cursorColor = accentColor, - focusedContainerColor = Color(0xFF1E1E24), - unfocusedContainerColor = Color(0xFF1A1A20), + focusedBorderColor = accentColor, unfocusedBorderColor = Color(0xFF3A3A4A), + focusedTextColor = textPrimary, unfocusedTextColor = textPrimary, + cursorColor = accentColor, + focusedContainerColor = Color(0xFF1E1E24), unfocusedContainerColor = Color(0xFF1A1A20), ), shape = RoundedCornerShape(24.dp), - singleLine = false, - maxLines = 4, + singleLine = false, maxLines = 4, ) - - Spacer(modifier = Modifier.width(10.dp)) - + Spacer(Modifier.width(10.dp)) val isGenerating = runtimeState == RuntimeState.GENERATING - IconButton( onClick = { if (isGenerating) { engine.cancel() + isThinking = false } else { val userText = textInput.trim() if (userText.isBlank()) return@IconButton - textInput = "" messages = messages + ChatMessage(role = "user", content = userText) currentOutput = "" - - scope.launch(kotlinx.coroutines.Dispatchers.Main) { + isThinking = true + scope.launch(Dispatchers.Main) { val guardResult = withContext(Dispatchers.Default) { de.ody.guards.GuardTest.checkAll(userText) } if (guardResult != null) { + isThinking = false guardMsg = guardResult messages = messages + ChatMessage( role = "assistant", @@ -547,110 +741,54 @@ fun ODYChatScreen() { ) return@launch } - - // systemPrompt = null → LlamaCppRuntime verwendet ODY Constitution v1.1 val request = GenerationRequest( - userMessage = userText, + userMessage = userText, systemPrompt = null, - history = messages.dropLast(1) + history = messages.dropLast(1) .filter { it.state == MessageState.COMPLETE } .map { it.role to it.content } .takeLast(10), ) - - val genRunId = System.currentTimeMillis() - android.util.Log.i("ODY_STREAM", "ui generate start runId=$genRunId") - engine.generate(request) { token -> - android.util.Log.i("ODY_STREAM", "ui onToken len=${token.length} runId=$genRunId") + if (token.isNotBlank()) isThinking = false currentOutput += token } - - android.util.Log.i("ODY_STREAM", "ui generate done runId=$genRunId output_len=${currentOutput.length}") - - // Leere Antworten verhindern val finalText = currentOutput.trim() - val finalMsg = if (finalText.isBlank()) { - ChatMessage( - role = "assistant", - content = "Es wurde keine sichtbare Antwort erzeugt.", - state = MessageState.ERROR, - ) + isThinking = false + messages = messages + if (finalText.isBlank()) { + ChatMessage(role = "assistant", content = "Es wurde keine sichtbare Antwort erzeugt.", state = MessageState.ERROR) } else { - ChatMessage( - role = "assistant", - content = finalText, - state = MessageState.COMPLETE, - ) + ChatMessage(role = "assistant", content = finalText, state = MessageState.COMPLETE) } - messages = messages + finalMsg currentOutput = "" } } }, - modifier = Modifier - .size(46.dp) - .background( - if (isGenerating) guardColor else accentColor, - RoundedCornerShape(50), - ), + modifier = Modifier.size(46.dp).background( + if (isGenerating) guardColor else accentColor, RoundedCornerShape(50) + ), ) { Icon( imageVector = if (isGenerating) Icons.Default.Stop else Icons.Default.Send, contentDescription = if (isGenerating) "Stop" else "Senden", - tint = Color.White, - modifier = Modifier.size(20.dp), + tint = Color.White, modifier = Modifier.size(20.dp), ) } } - - // ── Debug-Footer (nur in Debug-Builds) ────────────────────────────── - if (BuildConfig.IS_DEBUG_BUILD) { - Row( - modifier = Modifier - .fillMaxWidth() - .background(Color(0xFF0A0A0C)) - .padding(horizontal = 16.dp, vertical = 6.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "DEBUG · mmap20 · $runtimeState", - color = textSecondary, - fontSize = 10.sp, - ) - TextButton( - onClick = { - guardMsg = GuardAlert( - type = GuardType.REPETITION, - message = "Guard-Test ausgelöst", - severity = GuardSeverity.LOW, - ) - }, - modifier = Modifier.height(24.dp), - ) { - Text("🛡 Guard-Test", color = textSecondary, fontSize = 10.sp) - } - } - } } - // Auto-Scroll: nur nach Abschluss einer Antwort, nur wenn Nutzer unten war + // Auto-Scroll nach Abschluss val messageCount = messages.size LaunchedEffect(messageCount) { - // Nur scrollen wenn Nutzer am Ende war (kein manuelles Hochscrollen) - val wasNearBottom = !listState.canScrollForward - if (wasNearBottom && messageCount > 0) { - listState.scrollToItem(index = messageCount - 1) + if (!listState.canScrollForward && messageCount > 0) { + listState.scrollToItem(messageCount - 1) } } - - // Während Streaming: ans Ende scrollen wenn neue Streaming-Blase erscheint - val isStreaming = currentOutput.isNotEmpty() + val isStreaming = currentOutput.isNotEmpty() || isThinking LaunchedEffect(isStreaming) { if (isStreaming) { val totalItems = messages.size + 1 - listState.scrollToItem(index = (totalItems - 1).coerceAtLeast(0)) + listState.scrollToItem((totalItems - 1).coerceAtLeast(0)) } } } diff --git a/app/src/main/java/de/ody/model/LocalModel.kt b/app/src/main/java/de/ody/model/LocalModel.kt new file mode 100644 index 0000000..e031341 --- /dev/null +++ b/app/src/main/java/de/ody/model/LocalModel.kt @@ -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, + ) + } + } +} diff --git a/app/src/main/java/de/ody/model/ModelManager.kt b/app/src/main/java/de/ody/model/ModelManager.kt new file mode 100644 index 0000000..008c155 --- /dev/null +++ b/app/src/main/java/de/ody/model/ModelManager.kt @@ -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 { + 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/.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 +}