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"
|
applicationId = "de.ody"
|
||||||
minSdk = 28
|
minSdk = 28
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 3
|
versionCode = 4
|
||||||
versionName = "0.3.0"
|
versionName = "0.4.0"
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,11 @@ import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalContext
|
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.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.withStyle
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.core.view.WindowCompat
|
import androidx.core.view.WindowCompat
|
||||||
|
|
@ -38,17 +41,105 @@ import de.ody.runtime.GenerationRequest
|
||||||
import de.ody.runtime.RuntimeState
|
import de.ody.runtime.RuntimeState
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import androidx.lifecycle.lifecycleScope
|
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.launch
|
||||||
import kotlinx.coroutines.withContext
|
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() {
|
class MainActivity : ComponentActivity() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
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()
|
enableEdgeToEdge()
|
||||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||||
setContent {
|
setContent {
|
||||||
|
|
@ -80,7 +171,7 @@ class MainActivity : ComponentActivity() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
// ODY Chat Screen — kennt ausschließlich AssistantEngine und RuntimeState
|
// ODY Chat Screen
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -89,24 +180,19 @@ fun ODYChatScreen() {
|
||||||
val app = context.applicationContext as ODYApplication
|
val app = context.applicationContext as ODYApplication
|
||||||
|
|
||||||
val engine: AssistantEngine = remember { app.engine }
|
val engine: AssistantEngine = remember { app.engine }
|
||||||
// FIX 1: engine.state → engine.runtimeState
|
|
||||||
val runtimeState by engine.runtimeState.collectAsState()
|
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
|
val scope = (LocalContext.current as? androidx.activity.ComponentActivity)?.lifecycleScope
|
||||||
?: rememberCoroutineScope()
|
?: rememberCoroutineScope()
|
||||||
|
|
||||||
var textInput by remember { mutableStateOf("") }
|
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 currentOutput by remember { mutableStateOf("") }
|
||||||
var batteryLevel by remember { mutableStateOf(0) }
|
var batteryLevel by remember { mutableStateOf(0) }
|
||||||
var guardMsg by remember { mutableStateOf<GuardAlert?>(null) }
|
var guardMsg by remember { mutableStateOf<GuardAlert?>(null) }
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
|
|
||||||
// ODY-Farbpalette
|
// Farben
|
||||||
val bgColor = Color(0xFF0D0D0F)
|
val bgColor = Color(0xFF0D0D0F)
|
||||||
val surfaceColor = Color(0xFF1A1A1E)
|
val surfaceColor = Color(0xFF1A1A1E)
|
||||||
val userBubbleColor = Color(0xFF1E3A5F)
|
val userBubbleColor = Color(0xFF1E3A5F)
|
||||||
|
|
@ -117,9 +203,8 @@ fun ODYChatScreen() {
|
||||||
val textSecondary = Color(0xFF8A8A9A)
|
val textSecondary = Color(0xFF8A8A9A)
|
||||||
val guardColor = Color(0xFFCC3333)
|
val guardColor = Color(0xFFCC3333)
|
||||||
val successColor = Color(0xFF2ECC71)
|
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) {
|
LaunchedEffect(Unit) {
|
||||||
batteryLevel = MainActivity.getBatteryLevel(context)
|
batteryLevel = MainActivity.getBatteryLevel(context)
|
||||||
}
|
}
|
||||||
|
|
@ -137,7 +222,6 @@ fun ODYChatScreen() {
|
||||||
else -> "Bereit"
|
else -> "Bereit"
|
||||||
}
|
}
|
||||||
|
|
||||||
// FIX 3: else-Branch hinzugefügt — when-Ausdruck muss exhaustive sein
|
|
||||||
val statusColor = when (runtimeState) {
|
val statusColor = when (runtimeState) {
|
||||||
RuntimeState.READY, RuntimeState.GENERATING -> successColor
|
RuntimeState.READY, RuntimeState.GENERATING -> successColor
|
||||||
RuntimeState.LOADING -> warningColor
|
RuntimeState.LOADING -> warningColor
|
||||||
|
|
@ -154,16 +238,12 @@ fun ODYChatScreen() {
|
||||||
else -> "🧠 Lokal"
|
else -> "🧠 Lokal"
|
||||||
}
|
}
|
||||||
|
|
||||||
val showSimBanner = engine.isSimulation
|
|
||||||
|
|
||||||
// P1+P2: systemBarsPadding() für Status-/Navigationsleiste,
|
|
||||||
// imePadding() für Tastatur — verhindert Layout-Shift und Überlagerung
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(bgColor)
|
.background(bgColor)
|
||||||
.systemBarsPadding()
|
.systemBarsPadding()
|
||||||
.imePadding() // P2: Eingabefeld bleibt über Tastatur
|
.imePadding()
|
||||||
) {
|
) {
|
||||||
// ── Header ──────────────────────────────────────────────────────────
|
// ── Header ──────────────────────────────────────────────────────────
|
||||||
Column(
|
Column(
|
||||||
|
|
@ -209,7 +289,6 @@ fun ODYChatScreen() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// P7: HorizontalDivider statt deprecated Divider
|
|
||||||
HorizontalDivider(
|
HorizontalDivider(
|
||||||
color = Color(0xFF2A2A2E),
|
color = Color(0xFF2A2A2E),
|
||||||
thickness = 0.5.dp,
|
thickness = 0.5.dp,
|
||||||
|
|
@ -217,19 +296,19 @@ fun ODYChatScreen() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Simulation-Banner (P4: kompakter) ───────────────────────────────
|
// ── Simulation-Banner ────────────────────────────────────────────────
|
||||||
if (showSimBanner) {
|
if (engine.isSimulation) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 12.dp, vertical = 3.dp)
|
.padding(horizontal = 12.dp, vertical = 3.dp)
|
||||||
.background(Color(0xFF2A1F00), RoundedCornerShape(8.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(
|
||||||
text = "⚠ [Simulation] · Kein Modell geladen",
|
text = "⚠ [Simulation] · Kein Modell geladen",
|
||||||
color = warningColor,
|
color = warningColor,
|
||||||
fontSize = 12.sp, // P4: 13→12sp
|
fontSize = 12.sp,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -250,11 +329,7 @@ fun ODYChatScreen() {
|
||||||
strokeWidth = 2.dp,
|
strokeWidth = 2.dp,
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
Text(
|
Text(text = "Modell wird geladen...", color = accentColor, fontSize = 13.sp)
|
||||||
text = "Modell wird geladen...",
|
|
||||||
color = accentColor,
|
|
||||||
fontSize = 13.sp,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -288,7 +363,6 @@ fun ODYChatScreen() {
|
||||||
|
|
||||||
// ── Chat-Nachrichten ─────────────────────────────────────────────────
|
// ── Chat-Nachrichten ─────────────────────────────────────────────────
|
||||||
Box(modifier = Modifier.weight(1f)) {
|
Box(modifier = Modifier.weight(1f)) {
|
||||||
// P3: Willkommens-Zustand wenn keine Nachrichten
|
|
||||||
if (messages.isEmpty() && currentOutput.isEmpty()) {
|
if (messages.isEmpty() && currentOutput.isEmpty()) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|
@ -297,11 +371,7 @@ fun ODYChatScreen() {
|
||||||
verticalArrangement = Arrangement.Center,
|
verticalArrangement = Arrangement.Center,
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(text = "🛡", fontSize = 40.sp, textAlign = TextAlign.Center)
|
||||||
text = "🛡",
|
|
||||||
fontSize = 40.sp,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
Text(
|
Text(
|
||||||
text = "ODY ist bereit.",
|
text = "ODY ist bereit.",
|
||||||
|
|
@ -329,21 +399,19 @@ fun ODYChatScreen() {
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(horizontal = 12.dp),
|
.padding(horizontal = 12.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
contentPadding = PaddingValues(vertical = 12.dp),
|
contentPadding = PaddingValues(vertical = 12.dp),
|
||||||
) {
|
) {
|
||||||
items(messages) { (role, content) ->
|
items(messages) { msg ->
|
||||||
val isUser = role == "user"
|
val isUser = msg.role == "user"
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start,
|
horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start,
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth(0.82f) // P5: 280dp fix → 82% Breite
|
.fillMaxWidth(0.82f)
|
||||||
.wrapContentWidth(
|
.wrapContentWidth(if (isUser) Alignment.End else Alignment.Start)
|
||||||
if (isUser) Alignment.End else Alignment.Start
|
|
||||||
)
|
|
||||||
.background(
|
.background(
|
||||||
if (isUser) userBubbleColor else odyBubbleColor,
|
if (isUser) userBubbleColor else odyBubbleColor,
|
||||||
RoundedCornerShape(
|
RoundedCornerShape(
|
||||||
|
|
@ -353,7 +421,7 @@ fun ODYChatScreen() {
|
||||||
bottomEnd = 16.dp,
|
bottomEnd = 16.dp,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.padding(horizontal = 14.dp, vertical = 10.dp)
|
.padding(horizontal = 14.dp, vertical = 12.dp)
|
||||||
) {
|
) {
|
||||||
Column {
|
Column {
|
||||||
if (!isUser) {
|
if (!isUser) {
|
||||||
|
|
@ -362,21 +430,27 @@ fun ODYChatScreen() {
|
||||||
color = if (engine.isSimulation) warningColor else accentColor,
|
color = if (engine.isSimulation) warningColor else accentColor,
|
||||||
fontSize = 11.sp,
|
fontSize = 11.sp,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
modifier = Modifier.padding(bottom = 4.dp),
|
modifier = Modifier.padding(bottom = 6.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Text(
|
when (msg.state) {
|
||||||
text = content,
|
MessageState.ERROR -> Text(
|
||||||
color = textPrimary,
|
text = msg.content,
|
||||||
|
color = errorColor,
|
||||||
fontSize = 15.sp,
|
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()) {
|
if (currentOutput.isNotEmpty()) {
|
||||||
item {
|
item {
|
||||||
Row(
|
Row(
|
||||||
|
|
@ -385,12 +459,12 @@ fun ODYChatScreen() {
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth(0.82f) // P5: konsistent
|
.fillMaxWidth(0.82f)
|
||||||
.background(
|
.background(
|
||||||
odyBubbleColor,
|
odyBubbleColor,
|
||||||
RoundedCornerShape(4.dp, 16.dp, 16.dp, 16.dp),
|
RoundedCornerShape(4.dp, 16.dp, 16.dp, 16.dp),
|
||||||
)
|
)
|
||||||
.padding(horizontal = 14.dp, vertical = 10.dp)
|
.padding(horizontal = 14.dp, vertical = 12.dp)
|
||||||
) {
|
) {
|
||||||
Column {
|
Column {
|
||||||
Text(
|
Text(
|
||||||
|
|
@ -398,13 +472,14 @@ fun ODYChatScreen() {
|
||||||
color = if (engine.isSimulation) warningColor else accentColor,
|
color = if (engine.isSimulation) warningColor else accentColor,
|
||||||
fontSize = 11.sp,
|
fontSize = 11.sp,
|
||||||
fontWeight = FontWeight.SemiBold,
|
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(
|
||||||
text = currentOutput + "▌",
|
text = currentOutput + "▌",
|
||||||
color = textPrimary,
|
color = textPrimary,
|
||||||
fontSize = 15.sp,
|
fontSize = 15.sp,
|
||||||
lineHeight = 22.sp,
|
lineHeight = 24.sp,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -419,7 +494,7 @@ fun ODYChatScreen() {
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.background(surfaceColor)
|
.background(surfaceColor)
|
||||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
.padding(horizontal = 12.dp, vertical = 12.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
|
|
@ -427,11 +502,7 @@ fun ODYChatScreen() {
|
||||||
onValueChange = { textInput = it },
|
onValueChange = { textInput = it },
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
placeholder = {
|
placeholder = {
|
||||||
Text(
|
Text(text = "Frag ODY...", color = textSecondary, fontSize = 15.sp)
|
||||||
text = "Frag ODY...",
|
|
||||||
color = textSecondary,
|
|
||||||
fontSize = 15.sp,
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
colors = OutlinedTextFieldDefaults.colors(
|
colors = OutlinedTextFieldDefaults.colors(
|
||||||
focusedBorderColor = accentColor,
|
focusedBorderColor = accentColor,
|
||||||
|
|
@ -440,14 +511,14 @@ fun ODYChatScreen() {
|
||||||
unfocusedTextColor = textPrimary,
|
unfocusedTextColor = textPrimary,
|
||||||
cursorColor = accentColor,
|
cursorColor = accentColor,
|
||||||
focusedContainerColor = Color(0xFF1E1E24),
|
focusedContainerColor = Color(0xFF1E1E24),
|
||||||
unfocusedContainerColor= Color(0xFF1A1A20),
|
unfocusedContainerColor = Color(0xFF1A1A20),
|
||||||
),
|
),
|
||||||
shape = RoundedCornerShape(24.dp),
|
shape = RoundedCornerShape(24.dp),
|
||||||
singleLine = false,
|
singleLine = false,
|
||||||
maxLines = 4,
|
maxLines = 4,
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(10.dp))
|
||||||
|
|
||||||
val isGenerating = runtimeState == RuntimeState.GENERATING
|
val isGenerating = runtimeState == RuntimeState.GENERATING
|
||||||
|
|
||||||
|
|
@ -460,7 +531,7 @@ fun ODYChatScreen() {
|
||||||
if (userText.isBlank()) return@IconButton
|
if (userText.isBlank()) return@IconButton
|
||||||
|
|
||||||
textInput = ""
|
textInput = ""
|
||||||
messages = messages + ("user" to userText)
|
messages = messages + ChatMessage(role = "user", content = userText)
|
||||||
currentOutput = ""
|
currentOutput = ""
|
||||||
|
|
||||||
scope.launch(kotlinx.coroutines.Dispatchers.Main) {
|
scope.launch(kotlinx.coroutines.Dispatchers.Main) {
|
||||||
|
|
@ -469,36 +540,56 @@ fun ODYChatScreen() {
|
||||||
}
|
}
|
||||||
if (guardResult != null) {
|
if (guardResult != null) {
|
||||||
guardMsg = guardResult
|
guardMsg = guardResult
|
||||||
messages = messages + ("assistant" to "[Guard] ${guardResult.message}")
|
messages = messages + ChatMessage(
|
||||||
|
role = "assistant",
|
||||||
|
content = "[Guard] ${guardResult.message}",
|
||||||
|
state = MessageState.ERROR,
|
||||||
|
)
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// systemPrompt = null → LlamaCppRuntime verwendet ODY Constitution v1.1
|
||||||
val request = GenerationRequest(
|
val request = GenerationRequest(
|
||||||
userMessage = userText,
|
userMessage = userText,
|
||||||
systemPrompt = "Du bist ODY, ein lokales Werkzeug. Antworte präzise und direkt auf Deutsch. Keine Smalltalk-Floskeln.",
|
systemPrompt = null,
|
||||||
history = messages.dropLast(1).takeLast(10),
|
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()
|
val genRunId = System.currentTimeMillis()
|
||||||
android.util.Log.i("ODY_STREAM", "ui generate start runId=$genRunId")
|
android.util.Log.i("ODY_STREAM", "ui generate start runId=$genRunId")
|
||||||
|
|
||||||
engine.generate(request) { token ->
|
engine.generate(request) { token ->
|
||||||
android.util.Log.i("ODY_STREAM", "ui onToken len=${token.length} runId=$genRunId")
|
android.util.Log.i("ODY_STREAM", "ui onToken len=${token.length} runId=$genRunId")
|
||||||
currentOutput += token
|
currentOutput += token
|
||||||
}
|
}
|
||||||
|
|
||||||
android.util.Log.i("ODY_STREAM", "ui generate done runId=$genRunId output_len=${currentOutput.length}")
|
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)
|
// Leere Antworten verhindern
|
||||||
// -> State-Mutation hier direkt erlaubt.
|
val finalText = currentOutput.trim()
|
||||||
messages = messages + ("assistant" to currentOutput)
|
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 = ""
|
currentOutput = ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(48.dp)
|
.size(46.dp)
|
||||||
.background(
|
.background(
|
||||||
if (isGenerating) guardColor else accentColor,
|
if (isGenerating) guardColor else accentColor,
|
||||||
RoundedCornerShape(50),
|
RoundedCornerShape(50),
|
||||||
|
|
@ -524,7 +615,7 @@ fun ODYChatScreen() {
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "DEBUG · mmap16 · 1784582716 · $runtimeState",
|
text = "DEBUG · mmap20 · $runtimeState",
|
||||||
color = textSecondary,
|
color = textSecondary,
|
||||||
fontSize = 10.sp,
|
fontSize = 10.sp,
|
||||||
)
|
)
|
||||||
|
|
@ -544,14 +635,21 @@ fun ODYChatScreen() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// P6: Auto-Scroll-Guard — kein IndexOutOfBoundsException
|
// Auto-Scroll: nur nach Abschluss einer Antwort, nur wenn Nutzer unten war
|
||||||
// P8: Scroll nur bei neuer Nachricht, nicht bei jedem Token
|
val messageCount = messages.size
|
||||||
// animateScrollToItem während Streaming erzeugt einen Scroll-Loop
|
LaunchedEffect(messageCount) {
|
||||||
// FIX mmap14: Scroll auch wenn Streaming-Blase erscheint (currentOutput wird nicht-leer)
|
// Nur scrollen wenn Nutzer am Ende war (kein manuelles Hochscrollen)
|
||||||
val scrollTrigger = messages.size + if (currentOutput.isNotEmpty()) 1 else 0
|
val wasNearBottom = !listState.canScrollForward
|
||||||
LaunchedEffect(scrollTrigger) {
|
if (wasNearBottom && messageCount > 0) {
|
||||||
val totalItems = messages.size + if (currentOutput.isNotEmpty()) 1 else 0
|
listState.scrollToItem(index = messageCount - 1)
|
||||||
if (totalItems > 0) {
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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))
|
listState.scrollToItem(index = (totalItems - 1).coerceAtLeast(0))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue