mmap20: Markdown, Auto-Scroll, Antwortzustände, Typografie, versionCode=4

This commit is contained in:
ODY Build 2026-07-21 08:43:00 +00:00
parent 83b7a33122
commit c4610a3796
2 changed files with 196 additions and 98 deletions

View file

@ -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 {

View file

@ -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<List<Pair<String, String>>>(emptyList()) }
var messages by remember { mutableStateOf<List<ChatMessage>>(emptyList()) }
var currentOutput by remember { mutableStateOf("") }
var batteryLevel by remember { mutableStateOf(0) }
var guardMsg by remember { mutableStateOf<GuardAlert?>(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)
}
@ -137,7 +222,6 @@ fun ODYChatScreen() {
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),
)
}
Text(
text = content,
color = textPrimary,
when (msg.state) {
MessageState.ERROR -> Text(
text = msg.content,
color = errorColor,
fontSize = 15.sp,
lineHeight = 22.sp,
lineHeight = 24.sp,
)
else -> MarkdownText(
text = msg.content,
color = textPrimary,
)
}
}
}
}
}
// 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,11 +502,7 @@ 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,
@ -447,7 +518,7 @@ fun ODYChatScreen() {
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))
}
}