From c4610a3796afdb24c9f189b7121d417262c3c75e Mon Sep 17 00:00:00 2001 From: ODY Build Date: Tue, 21 Jul 2026 08:43:00 +0000 Subject: [PATCH] =?UTF-8?q?mmap20:=20Markdown,=20Auto-Scroll,=20Antwortzus?= =?UTF-8?q?t=C3=A4nde,=20Typografie,=20versionCode=3D4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 4 +- app/src/main/java/de/ody/app/MainActivity.kt | 290 +++++++++++++------ 2 files changed, 196 insertions(+), 98 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1cd4cd2..9d5cb51 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -10,8 +10,8 @@ android { applicationId = "de.ody" minSdk = 28 targetSdk = 36 - versionCode = 3 - versionName = "0.3.0" + versionCode = 4 + versionName = "0.4.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 727cd1e..b90a8e0 100644 --- a/app/src/main/java/de/ody/app/MainActivity.kt +++ b/app/src/main/java/de/ody/app/MainActivity.kt @@ -23,8 +23,11 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.view.WindowCompat @@ -38,17 +41,105 @@ import de.ody.runtime.GenerationRequest import de.ody.runtime.RuntimeState import kotlinx.coroutines.Dispatchers import androidx.lifecycle.lifecycleScope -import android.os.Handler -import android.os.Looper -import androidx.compose.runtime.snapshots.Snapshot import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +// ───────────────────────────────────────────────────────────────────────────── +// Antwortzustände +// ───────────────────────────────────────────────────────────────────────────── +enum class MessageState { GENERATING, COMPLETE, ERROR } + +data class ChatMessage( + val role: String, // "user" | "assistant" + val content: String, + val state: MessageState = MessageState.COMPLETE, +) + +// ───────────────────────────────────────────────────────────────────────────── +// Markdown-Parser — robuster Subset: **fett**, # ## ###, 1. Listen, - Listen +// Kein Crash bei fehlerhafter Eingabe. +// ───────────────────────────────────────────────────────────────────────────── +@Composable +fun MarkdownText( + text: String, + color: Color, + fontSize: androidx.compose.ui.unit.TextUnit = 15.sp, + lineHeight: androidx.compose.ui.unit.TextUnit = 24.sp, +) { + val annotated = buildAnnotatedString { + 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, + )) { + 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]) + } + 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("• ") + appendInlineBold(bulletMatch.groupValues[1]) + 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, + ) +} + +// 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) + } + } +} + class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - // P1+P2: Edge-to-Edge aktivieren — Layout verwaltet eigene Insets - // P8: Edge-to-Edge-Test: Safe Insets werden via systemBarsPadding() + imePadding() gesetzt enableEdgeToEdge() WindowCompat.setDecorFitsSystemWindows(window, false) setContent { @@ -80,7 +171,7 @@ class MainActivity : ComponentActivity() { } // ───────────────────────────────────────────────────────────────────────────── -// ODY Chat Screen — kennt ausschließlich AssistantEngine und RuntimeState +// ODY Chat Screen // ───────────────────────────────────────────────────────────────────────────── @Composable @@ -89,24 +180,19 @@ fun ODYChatScreen() { val app = context.applicationContext as ODYApplication val engine: AssistantEngine = remember { app.engine } - // FIX 1: engine.state → engine.runtimeState val runtimeState by engine.runtimeState.collectAsState() - // FIX mmap14: rememberCoroutineScope() wird bei Recompose gecancelt - // → generate() wird abgebrochen → currentOutput bleibt leer - // lifecycleScope der Activity überlebt Recomposes - val activity = LocalContext.current as? androidx.activity.ComponentActivity - // FIX mmap17: stabiler lifecycleScope, kein remember() Fallback val scope = (LocalContext.current as? androidx.activity.ComponentActivity)?.lifecycleScope ?: rememberCoroutineScope() + var textInput by remember { mutableStateOf("") } - var messages by remember { mutableStateOf>>(emptyList()) } + var messages by remember { mutableStateOf>(emptyList()) } var currentOutput by remember { mutableStateOf("") } var batteryLevel by remember { mutableStateOf(0) } var guardMsg by remember { mutableStateOf(null) } val listState = rememberLazyListState() - // ODY-Farbpalette + // Farben val bgColor = Color(0xFF0D0D0F) val surfaceColor = Color(0xFF1A1A1E) val userBubbleColor = Color(0xFF1E3A5F) @@ -117,9 +203,8 @@ fun ODYChatScreen() { val textSecondary = Color(0xFF8A8A9A) val guardColor = Color(0xFFCC3333) val successColor = Color(0xFF2ECC71) + val errorColor = Color(0xFFFF6B6B) - // FIX 2: loadModel()-Aufruf entfernt — Modell-Laden passiert in ODYApplication.onCreate() - // via engine.start(modelFile). Nur batteryLevel-Initialisierung bleibt. LaunchedEffect(Unit) { batteryLevel = MainActivity.getBatteryLevel(context) } @@ -131,13 +216,12 @@ fun ODYChatScreen() { runtimeState == RuntimeState.VALIDATING -> "Prüfe Modell..." runtimeState == RuntimeState.LOADING -> "Lädt Modell..." runtimeState == RuntimeState.MODEL_LOAD_FAILED -> "Ladefehler — Modell ungültig" - runtimeState == RuntimeState.READY -> "Autark · Lokal aktiv" - runtimeState == RuntimeState.GENERATING -> "Autark · Generiert" - runtimeState == RuntimeState.ERROR -> "Fehler" - else -> "Bereit" + runtimeState == RuntimeState.READY -> "Autark · Lokal aktiv" + runtimeState == RuntimeState.GENERATING -> "Autark · Generiert" + runtimeState == RuntimeState.ERROR -> "Fehler" + else -> "Bereit" } - // FIX 3: else-Branch hinzugefügt — when-Ausdruck muss exhaustive sein val statusColor = when (runtimeState) { RuntimeState.READY, RuntimeState.GENERATING -> successColor RuntimeState.LOADING -> warningColor @@ -154,16 +238,12 @@ fun ODYChatScreen() { else -> "🧠 Lokal" } - val showSimBanner = engine.isSimulation - - // P1+P2: systemBarsPadding() für Status-/Navigationsleiste, - // imePadding() für Tastatur — verhindert Layout-Shift und Überlagerung Column( modifier = Modifier .fillMaxSize() .background(bgColor) .systemBarsPadding() - .imePadding() // P2: Eingabefeld bleibt über Tastatur + .imePadding() ) { // ── Header ────────────────────────────────────────────────────────── Column( @@ -209,7 +289,6 @@ fun ODYChatScreen() { ) } } - // P7: HorizontalDivider statt deprecated Divider HorizontalDivider( color = Color(0xFF2A2A2E), thickness = 0.5.dp, @@ -217,19 +296,19 @@ fun ODYChatScreen() { ) } - // ── Simulation-Banner (P4: kompakter) ─────────────────────────────── - if (showSimBanner) { + // ── 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) // P4: 8→6dp + .padding(horizontal = 14.dp, vertical = 6.dp) ) { Text( text = "⚠ [Simulation] · Kein Modell geladen", color = warningColor, - fontSize = 12.sp, // P4: 13→12sp + fontSize = 12.sp, ) } } @@ -250,11 +329,7 @@ fun ODYChatScreen() { strokeWidth = 2.dp, ) Spacer(modifier = Modifier.width(8.dp)) - Text( - text = "Modell wird geladen...", - color = accentColor, - fontSize = 13.sp, - ) + Text(text = "Modell wird geladen...", color = accentColor, fontSize = 13.sp) } } } @@ -288,7 +363,6 @@ fun ODYChatScreen() { // ── Chat-Nachrichten ───────────────────────────────────────────────── Box(modifier = Modifier.weight(1f)) { - // P3: Willkommens-Zustand wenn keine Nachrichten if (messages.isEmpty() && currentOutput.isEmpty()) { Column( modifier = Modifier @@ -297,11 +371,7 @@ fun ODYChatScreen() { verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, ) { - Text( - text = "🛡", - fontSize = 40.sp, - textAlign = TextAlign.Center, - ) + Text(text = "🛡", fontSize = 40.sp, textAlign = TextAlign.Center) Spacer(modifier = Modifier.height(16.dp)) Text( text = "ODY ist bereit.", @@ -329,21 +399,19 @@ fun ODYChatScreen() { modifier = Modifier .fillMaxSize() .padding(horizontal = 12.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), contentPadding = PaddingValues(vertical = 12.dp), ) { - items(messages) { (role, content) -> - val isUser = role == "user" + items(messages) { msg -> + val isUser = msg.role == "user" Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start, ) { Box( modifier = Modifier - .fillMaxWidth(0.82f) // P5: 280dp fix → 82% Breite - .wrapContentWidth( - if (isUser) Alignment.End else Alignment.Start - ) + .fillMaxWidth(0.82f) + .wrapContentWidth(if (isUser) Alignment.End else Alignment.Start) .background( if (isUser) userBubbleColor else odyBubbleColor, RoundedCornerShape( @@ -353,7 +421,7 @@ fun ODYChatScreen() { bottomEnd = 16.dp, ) ) - .padding(horizontal = 14.dp, vertical = 10.dp) + .padding(horizontal = 14.dp, vertical = 12.dp) ) { Column { if (!isUser) { @@ -362,21 +430,27 @@ fun ODYChatScreen() { color = if (engine.isSimulation) warningColor else accentColor, fontSize = 11.sp, fontWeight = FontWeight.SemiBold, - modifier = Modifier.padding(bottom = 4.dp), + 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, ) } - Text( - text = content, - color = textPrimary, - fontSize = 15.sp, - lineHeight = 22.sp, - ) } } } } - // Streaming-Bubble + // Streaming-Bubble: Plaintext + Cursor während Generierung if (currentOutput.isNotEmpty()) { item { Row( @@ -385,12 +459,12 @@ fun ODYChatScreen() { ) { Box( modifier = Modifier - .fillMaxWidth(0.82f) // P5: konsistent + .fillMaxWidth(0.82f) .background( odyBubbleColor, RoundedCornerShape(4.dp, 16.dp, 16.dp, 16.dp), ) - .padding(horizontal = 14.dp, vertical = 10.dp) + .padding(horizontal = 14.dp, vertical = 12.dp) ) { Column { Text( @@ -398,13 +472,14 @@ fun ODYChatScreen() { color = if (engine.isSimulation) warningColor else accentColor, fontSize = 11.sp, fontWeight = FontWeight.SemiBold, - modifier = Modifier.padding(bottom = 4.dp), + modifier = Modifier.padding(bottom = 6.dp), ) + // Plaintext während Streaming — kein Markdown-Parser pro Token Text( text = currentOutput + "▌", color = textPrimary, fontSize = 15.sp, - lineHeight = 22.sp, + lineHeight = 24.sp, ) } } @@ -419,7 +494,7 @@ fun ODYChatScreen() { modifier = Modifier .fillMaxWidth() .background(surfaceColor) - .padding(horizontal = 12.dp, vertical = 10.dp), + .padding(horizontal = 12.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, ) { OutlinedTextField( @@ -427,27 +502,23 @@ fun ODYChatScreen() { onValueChange = { textInput = it }, modifier = Modifier.weight(1f), placeholder = { - Text( - text = "Frag ODY...", - color = textSecondary, - fontSize = 15.sp, - ) + Text(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, ) - Spacer(modifier = Modifier.width(8.dp)) + Spacer(modifier = Modifier.width(10.dp)) val isGenerating = runtimeState == RuntimeState.GENERATING @@ -460,7 +531,7 @@ fun ODYChatScreen() { if (userText.isBlank()) return@IconButton textInput = "" - messages = messages + ("user" to userText) + messages = messages + ChatMessage(role = "user", content = userText) currentOutput = "" scope.launch(kotlinx.coroutines.Dispatchers.Main) { @@ -469,36 +540,56 @@ fun ODYChatScreen() { } if (guardResult != null) { guardMsg = guardResult - messages = messages + ("assistant" to "[Guard] ${guardResult.message}") + messages = messages + ChatMessage( + role = "assistant", + content = "[Guard] ${guardResult.message}", + state = MessageState.ERROR, + ) return@launch } + // systemPrompt = null → LlamaCppRuntime verwendet ODY Constitution v1.1 val request = GenerationRequest( userMessage = userText, - systemPrompt = "Du bist ODY, ein lokales Werkzeug. Antworte präzise und direkt auf Deutsch. Keine Smalltalk-Floskeln.", - history = messages.dropLast(1).takeLast(10), + systemPrompt = null, + history = messages.dropLast(1) + .filter { it.state == MessageState.COMPLETE } + .map { it.role to it.content } + .takeLast(10), ) - // onToken wird von LlamaCppRuntime bereits auf Main-Thread geliefert - // (via singleton mainHandler in LlamaCppRuntime). - // Hier kein Handler mehr noetig. 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") currentOutput += token } + android.util.Log.i("ODY_STREAM", "ui generate done runId=$genRunId output_len=${currentOutput.length}") - // generate() ist suspend und laeuft in scope.launch (Dispatchers.Main) - // -> State-Mutation hier direkt erlaubt. - messages = messages + ("assistant" to currentOutput) + // 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, + ) + } else { + ChatMessage( + role = "assistant", + content = finalText, + state = MessageState.COMPLETE, + ) + } + messages = messages + finalMsg currentOutput = "" } } }, modifier = Modifier - .size(48.dp) + .size(46.dp) .background( if (isGenerating) guardColor else accentColor, RoundedCornerShape(50), @@ -524,7 +615,7 @@ fun ODYChatScreen() { verticalAlignment = Alignment.CenterVertically, ) { Text( - text = "DEBUG · mmap16 · 1784582716 · $runtimeState", + text = "DEBUG · mmap20 · $runtimeState", color = textSecondary, fontSize = 10.sp, ) @@ -544,14 +635,21 @@ fun ODYChatScreen() { } } - // P6: Auto-Scroll-Guard — kein IndexOutOfBoundsException - // P8: Scroll nur bei neuer Nachricht, nicht bei jedem Token - // animateScrollToItem während Streaming erzeugt einen Scroll-Loop - // FIX mmap14: Scroll auch wenn Streaming-Blase erscheint (currentOutput wird nicht-leer) - val scrollTrigger = messages.size + if (currentOutput.isNotEmpty()) 1 else 0 - LaunchedEffect(scrollTrigger) { - val totalItems = messages.size + if (currentOutput.isNotEmpty()) 1 else 0 - if (totalItems > 0) { + // Auto-Scroll: nur nach Abschluss einer Antwort, nur wenn Nutzer unten war + 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) + } + } + + // Während Streaming: ans Ende scrollen wenn neue Streaming-Blase erscheint + val isStreaming = currentOutput.isNotEmpty() + LaunchedEffect(isStreaming) { + if (isStreaming) { + val totalItems = messages.size + 1 listState.scrollToItem(index = (totalItems - 1).coerceAtLeast(0)) } }