mmap20: Markdown, Auto-Scroll, Antwortzustände, Typografie, versionCode=4
This commit is contained in:
parent
83b7a33122
commit
c4610a3796
2 changed files with 196 additions and 98 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue