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:
2026-07-04 14:45:55 +08:00
parent 876789fac1
commit fbf8dbbe86
15 changed files with 1280 additions and 994 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"
)
}

View File

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

View File

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

View File

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