fix: Android null-safety Gson crash + AgentChat full UX overhaul (Round 1-4)
Android: - Add NullSafeStringAdapter to GsonBuilder globally to prevent null String injection into non-nullable Kotlin fields - Add defensive null-safe mappings in AgentRepository and all UI Text() calls Frontend AgentChat (4 rounds): - Round 1: Stop generation button, server-side session history sync, smart scroll - Round 2: Full markdown rendering (marked), file/image upload with drag-drop, message edit/delete - Round 3: TTS speech (browser SpeechSynthesis), voice input (SpeechRecognition), empty state guidance, model/temperature selector - Round 4: Extract shared chat core — useMarkdown, useFileUpload, ChatMessageBubble, ChatInputArea modules — merge AgentChat.vue and AgentChatPreview.vue divergent code Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -23,9 +23,9 @@ class AgentRepository @Inject constructor(
|
||||
val agents = response.body()?.map { dto ->
|
||||
Agent(
|
||||
id = dto.id,
|
||||
name = dto.name,
|
||||
name = dto.name ?: "",
|
||||
description = dto.description ?: "",
|
||||
status = dto.status,
|
||||
status = dto.status ?: "published",
|
||||
userId = dto.userId,
|
||||
version = dto.version,
|
||||
createdAt = dto.createdAt,
|
||||
@@ -48,9 +48,9 @@ class AgentRepository @Inject constructor(
|
||||
Result.success(
|
||||
Agent(
|
||||
id = dto.id,
|
||||
name = dto.name,
|
||||
name = dto.name ?: "",
|
||||
description = dto.description ?: "",
|
||||
status = dto.status,
|
||||
status = dto.status ?: "published",
|
||||
userId = dto.userId,
|
||||
version = dto.version,
|
||||
createdAt = dto.createdAt,
|
||||
@@ -148,9 +148,9 @@ class AgentRepository @Inject constructor(
|
||||
|
||||
private fun com.tiangong.aiagent.data.remote.dto.AgentResponse.toDomain() = Agent(
|
||||
id = id,
|
||||
name = name,
|
||||
name = name ?: "",
|
||||
description = description ?: "",
|
||||
status = status,
|
||||
status = status ?: "published",
|
||||
userId = userId,
|
||||
version = version,
|
||||
createdAt = createdAt,
|
||||
|
||||
@@ -6,6 +6,10 @@ import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.GsonBuilder
|
||||
import com.google.gson.TypeAdapter
|
||||
import com.google.gson.stream.JsonReader
|
||||
import com.google.gson.stream.JsonToken
|
||||
import com.google.gson.stream.JsonWriter
|
||||
import com.tiangong.aiagent.BuildConfig
|
||||
import com.tiangong.aiagent.data.local.AppDatabase
|
||||
import com.tiangong.aiagent.data.local.CredentialStore
|
||||
@@ -33,9 +37,29 @@ object AppModule {
|
||||
@Singleton
|
||||
fun provideGson(): Gson {
|
||||
return GsonBuilder()
|
||||
.registerTypeAdapter(String::class.java, NullSafeStringAdapter())
|
||||
.create()
|
||||
}
|
||||
|
||||
/**
|
||||
* Prevents Gson from injecting null into Kotlin non-nullable String fields.
|
||||
* Gson's UnsafeAllocator bypasses Kotlin constructors, so null strings from
|
||||
* JSON can crash Compose Text() with NullPointerException in SpannableString.
|
||||
*/
|
||||
private class NullSafeStringAdapter : TypeAdapter<String>() {
|
||||
override fun write(out: JsonWriter, value: String?) {
|
||||
out.value(value ?: "")
|
||||
}
|
||||
|
||||
override fun read(reader: JsonReader): String {
|
||||
if (reader.peek() == JsonToken.NULL) {
|
||||
reader.nextNull()
|
||||
return ""
|
||||
}
|
||||
return reader.nextString()
|
||||
}
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideLoggingInterceptor(): HttpLoggingInterceptor {
|
||||
|
||||
@@ -262,7 +262,7 @@ fun AgentListItem(
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
text = agent.name.take(1),
|
||||
text = (agent.name ?: "AI").take(1),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
@@ -275,7 +275,7 @@ fun AgentListItem(
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = agent.name,
|
||||
text = agent.name ?: "未知",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
@@ -293,6 +293,7 @@ fun AgentListItem(
|
||||
"running" -> "运行中"
|
||||
"draft" -> "草稿"
|
||||
"stopped" -> "已停止"
|
||||
null -> "未知"
|
||||
else -> agent.status
|
||||
},
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||
@@ -300,7 +301,7 @@ fun AgentListItem(
|
||||
)
|
||||
}
|
||||
}
|
||||
if (agent.description.isNotEmpty()) {
|
||||
if (!agent.description.isNullOrEmpty()) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = agent.description,
|
||||
|
||||
@@ -5,6 +5,8 @@ import android.util.Log
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.PickVisualMediaRequest
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
@@ -19,7 +21,9 @@ import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
@@ -110,6 +114,10 @@ fun ChatScreen(
|
||||
|
||||
var lastLoadMoreTime by remember { mutableStateOf(0L) }
|
||||
|
||||
// Detect if user has scrolled away from the bottom (for "scroll to bottom" FAB)
|
||||
val canScrollForward by remember { derivedStateOf { listState.canScrollForward } }
|
||||
val showScrollToBottom = canScrollForward && uiState.messages.isNotEmpty()
|
||||
|
||||
LaunchedEffect(isAtTop) {
|
||||
val now = System.currentTimeMillis()
|
||||
if (isAtTop && uiState.hasMoreMessages && !uiState.isLoadingMore && initialScrollDone
|
||||
@@ -126,6 +134,7 @@ fun ChatScreen(
|
||||
}
|
||||
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
val haptic = LocalHapticFeedback.current
|
||||
|
||||
// Image picker (multi-select, max 9)
|
||||
val imagePickerLauncher = rememberLauncherForActivityResult(
|
||||
@@ -197,6 +206,26 @@ fun ChatScreen(
|
||||
}
|
||||
) {
|
||||
Scaffold(
|
||||
floatingActionButton = {
|
||||
if (showScrollToBottom) {
|
||||
FloatingActionButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
listState.animateScrollToItem(uiState.messages.size - 1)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.size(40.dp),
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
contentColor = MaterialTheme.colorScheme.primary
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.KeyboardArrowDown,
|
||||
contentDescription = "回到底部",
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
navigationIcon = {
|
||||
@@ -247,7 +276,10 @@ fun ChatScreen(
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
FilledTonalButton(
|
||||
onClick = { viewModel.stopGeneration() },
|
||||
onClick = {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
viewModel.stopGeneration()
|
||||
},
|
||||
colors = ButtonDefaults.filledTonalButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer
|
||||
)
|
||||
@@ -327,9 +359,19 @@ fun ChatScreen(
|
||||
}
|
||||
}
|
||||
|
||||
val sendEnabled = inputText.isNotBlank() && !uiState.isLoading && !uiState.isStreaming && !uiState.isOffline
|
||||
val sendColor by animateColorAsState(
|
||||
targetValue = if (sendEnabled)
|
||||
MaterialTheme.colorScheme.primary
|
||||
else
|
||||
MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
animationSpec = tween(200),
|
||||
label = "sendColor"
|
||||
)
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (inputText.isNotBlank()) {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||
val text = inputText
|
||||
inputText = ""
|
||||
if (uiState.editingMessageId != null) {
|
||||
@@ -339,14 +381,12 @@ fun ChatScreen(
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = inputText.isNotBlank() && !uiState.isLoading && !uiState.isStreaming && !uiState.isOffline
|
||||
enabled = sendEnabled
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.Send,
|
||||
contentDescription = "发送",
|
||||
tint = if (inputText.isNotBlank() && !uiState.isLoading && !uiState.isStreaming && !uiState.isOffline)
|
||||
MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
tint = sendColor
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -472,7 +512,10 @@ fun ChatScreen(
|
||||
)
|
||||
}
|
||||
FilledTonalButton(
|
||||
onClick = { viewModel.approveToolCall() },
|
||||
onClick = {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
viewModel.approveToolCall()
|
||||
},
|
||||
modifier = Modifier.padding(end = 8.dp)
|
||||
) {
|
||||
Icon(Icons.Default.Check, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||
@@ -480,7 +523,10 @@ fun ChatScreen(
|
||||
Text("允许")
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = { viewModel.rejectToolCall() }
|
||||
onClick = {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
viewModel.rejectToolCall()
|
||||
}
|
||||
) {
|
||||
Icon(Icons.Default.Close, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.tiangong.aiagent.ui.chat.components
|
||||
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
@@ -35,7 +36,8 @@ fun MessageBubble(message: Message) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp, horizontal = 12.dp),
|
||||
.padding(vertical = 4.dp, horizontal = 12.dp)
|
||||
.animateContentSize(),
|
||||
horizontalAlignment = alignment
|
||||
) {
|
||||
if (!isUser && !isTool) {
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
package com.tiangong.aiagent.ui.chat.components
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.tiangong.aiagent.util.MarkdownRenderer
|
||||
|
||||
@Composable
|
||||
@@ -18,7 +21,7 @@ fun StreamingText(
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
if (content.isEmpty()) {
|
||||
// Show thinking indicator
|
||||
// Animated thinking indicator with pulsing dots
|
||||
Row(
|
||||
modifier = modifier.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
@@ -28,13 +31,19 @@ fun StreamingText(
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
// Simple dot animation
|
||||
Text(
|
||||
text = "▌",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
repeat(3) { index ->
|
||||
val alpha by animateDotAlpha(index)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 1.5.dp)
|
||||
.size(5.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
MaterialTheme.colorScheme.primary.copy(alpha = alpha)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Surface(
|
||||
@@ -51,3 +60,17 @@ fun StreamingText(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun animateDotAlpha(index: Int): State<Float> {
|
||||
val transition = rememberInfiniteTransition(label = "dot_$index")
|
||||
return transition.animateFloat(
|
||||
initialValue = 0.2f,
|
||||
targetValue = 1.0f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(400, easing = LinearEasing, delayMillis = index * 200),
|
||||
repeatMode = RepeatMode.Reverse
|
||||
),
|
||||
label = "dot_alpha_$index"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.tiangong.aiagent.ui.chat.components
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
@@ -73,8 +74,8 @@ fun ThinkTracePanel(
|
||||
// Expanded content
|
||||
AnimatedVisibility(
|
||||
visible = expanded,
|
||||
enter = expandVertically(),
|
||||
exit = shrinkVertically()
|
||||
enter = expandVertically(spring(dampingRatio = 0.65f, stiffness = 300f)),
|
||||
exit = shrinkVertically(spring(dampingRatio = 0.8f, stiffness = 300f))
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(start = 12.dp, end = 12.dp, bottom = 10.dp)
|
||||
|
||||
@@ -270,7 +270,7 @@ private fun AgentCard(
|
||||
) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
Text(
|
||||
agent.name,
|
||||
agent.name ?: "",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
@@ -351,7 +351,7 @@ private fun AgentListItem(
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
agent.name,
|
||||
agent.name ?: "",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
@@ -422,7 +422,7 @@ private fun TemplateCard(
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
template.name,
|
||||
template.name ?: "",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
@@ -482,7 +482,7 @@ private fun SceneTemplateCard(
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
template.name,
|
||||
template.name ?: "",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
@@ -486,7 +486,7 @@ private fun KnowledgeEntitiesTab(
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = item.name,
|
||||
text = item.name ?: "",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user