diff --git a/android/app/src/main/java/com/tiangong/aiagent/data/remote/dto/Dtos.kt b/android/app/src/main/java/com/tiangong/aiagent/data/remote/dto/Dtos.kt index 047ca6b..427d6f6 100644 --- a/android/app/src/main/java/com/tiangong/aiagent/data/remote/dto/Dtos.kt +++ b/android/app/src/main/java/com/tiangong/aiagent/data/remote/dto/Dtos.kt @@ -51,6 +51,7 @@ data class AgentResponse( val id: String, val name: String, val description: String?, + val avatar: String? = null, @SerializedName("workflow_config") val workflowConfig: Map?, @SerializedName("budget_config") val budgetConfig: Map?, val version: Int = 1, @@ -268,7 +269,8 @@ data class AsrResponse( data class TtsRequest( val text: String, - val voice: String = "alloy" + val voice: String = "xiaoxiao", + val speed: Float = 1.0f ) data class TtsResponse( diff --git a/android/app/src/main/java/com/tiangong/aiagent/data/repository/AgentRepository.kt b/android/app/src/main/java/com/tiangong/aiagent/data/repository/AgentRepository.kt index 5c968dd..93ae165 100644 --- a/android/app/src/main/java/com/tiangong/aiagent/data/repository/AgentRepository.kt +++ b/android/app/src/main/java/com/tiangong/aiagent/data/repository/AgentRepository.kt @@ -25,6 +25,7 @@ class AgentRepository @Inject constructor( id = dto.id, name = dto.name ?: "", description = dto.description ?: "", + avatar = dto.avatar, status = dto.status ?: "published", userId = dto.userId, version = dto.version, @@ -50,6 +51,7 @@ class AgentRepository @Inject constructor( id = dto.id, name = dto.name ?: "", description = dto.description ?: "", + avatar = dto.avatar, status = dto.status ?: "published", userId = dto.userId, version = dto.version, @@ -150,6 +152,7 @@ class AgentRepository @Inject constructor( id = id, name = name ?: "", description = description ?: "", + avatar = avatar, status = status ?: "published", userId = userId, version = version, diff --git a/android/app/src/main/java/com/tiangong/aiagent/domain/model/Agent.kt b/android/app/src/main/java/com/tiangong/aiagent/domain/model/Agent.kt index 8bc80c5..1bea779 100644 --- a/android/app/src/main/java/com/tiangong/aiagent/domain/model/Agent.kt +++ b/android/app/src/main/java/com/tiangong/aiagent/domain/model/Agent.kt @@ -8,5 +8,6 @@ data class Agent( val userId: String? = null, val version: Int = 1, val createdAt: String = "", - val updatedAt: String = "" + val updatedAt: String = "", + val avatar: String? = null // emoji or image URL ) diff --git a/android/app/src/main/java/com/tiangong/aiagent/ui/chat/ChatScreen.kt b/android/app/src/main/java/com/tiangong/aiagent/ui/chat/ChatScreen.kt index c9cdcdb..b5797d7 100644 --- a/android/app/src/main/java/com/tiangong/aiagent/ui/chat/ChatScreen.kt +++ b/android/app/src/main/java/com/tiangong/aiagent/ui/chat/ChatScreen.kt @@ -2,6 +2,7 @@ package com.tiangong.aiagent.ui.chat import android.net.Uri import android.util.Log +import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts @@ -74,6 +75,11 @@ fun ChatScreen( // Drawer state val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) + // Close drawer on system back press + BackHandler(enabled = drawerState.isOpen) { + scope.launch { drawerState.close() } + } + // Track first load for skeleton LaunchedEffect(uiState.messages.size, uiState.isStreaming) { if (uiState.messages.isNotEmpty() || uiState.isStreaming || uiState.currentAgent != null) { @@ -663,7 +669,11 @@ fun ChatScreen( if (showFeedback) { val clipboardManager = LocalClipboardManager.current Column { - MessageBubbleFromUi(message = message) + MessageBubbleFromUi( + message = message, + agentName = uiState.currentAgent?.name ?: "助手", + agentAvatar = uiState.currentAgent?.avatar + ) Row( modifier = Modifier.padding(start = 56.dp, bottom = 4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp), @@ -794,7 +804,11 @@ fun ChatScreen( // User message: show bubble with copy & share actions val clipboardManager = LocalClipboardManager.current Column { - MessageBubbleFromUi(message = message) + MessageBubbleFromUi( + message = message, + agentName = uiState.currentAgent?.name ?: "助手", + agentAvatar = uiState.currentAgent?.avatar + ) Row( modifier = Modifier.padding(end = 16.dp, bottom = 4.dp), horizontalArrangement = Arrangement.End, @@ -904,8 +918,16 @@ fun ChatScreen( /** Thin adapter to render UiMessage via existing MessageBubble. */ @Composable -private fun MessageBubbleFromUi(message: UiMessage) { - com.tiangong.aiagent.ui.chat.components.MessageBubble(message = message.toDomainMessage()) +private fun MessageBubbleFromUi( + message: UiMessage, + agentName: String = "助手", + agentAvatar: String? = null +) { + com.tiangong.aiagent.ui.chat.components.MessageBubble( + message = message.toDomainMessage(), + agentName = agentName, + agentAvatar = agentAvatar + ) } private fun UiMessage.toDomainMessage(): com.tiangong.aiagent.domain.model.Message { diff --git a/android/app/src/main/java/com/tiangong/aiagent/ui/chat/ChatViewModel.kt b/android/app/src/main/java/com/tiangong/aiagent/ui/chat/ChatViewModel.kt index 24647a8..e614455 100644 --- a/android/app/src/main/java/com/tiangong/aiagent/ui/chat/ChatViewModel.kt +++ b/android/app/src/main/java/com/tiangong/aiagent/ui/chat/ChatViewModel.kt @@ -437,8 +437,12 @@ class ChatViewModel @Inject constructor( return } - // Capture agent context at call time to avoid any race condition + // Guard: don't send without an agent (prevents routing to non-existent bare SSE endpoint) val agentId = _uiState.value.currentAgent?.id + if (agentId == null) { + _uiState.value = _uiState.value.copy(error = "请先选择一个智能体") + return + } val sessionId = _uiState.value.sessionId ?: UUID.randomUUID().toString() // Analytics: track message send @@ -999,6 +1003,7 @@ class ChatViewModel @Inject constructor( super.onCleared() sseJob?.cancel() historyFlowJob?.cancel() + audioPlayer.release() audioRecorder.cleanup() persistSession() } diff --git a/android/app/src/main/java/com/tiangong/aiagent/ui/chat/components/MessageBubble.kt b/android/app/src/main/java/com/tiangong/aiagent/ui/chat/components/MessageBubble.kt index 4569f91..820465b 100644 --- a/android/app/src/main/java/com/tiangong/aiagent/ui/chat/components/MessageBubble.kt +++ b/android/app/src/main/java/com/tiangong/aiagent/ui/chat/components/MessageBubble.kt @@ -9,8 +9,12 @@ import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil.compose.AsyncImage +import coil.request.ImageRequest import com.tiangong.aiagent.domain.model.Message import com.tiangong.aiagent.util.MarkdownRenderer import java.text.SimpleDateFormat @@ -18,7 +22,11 @@ import java.util.Date import java.util.Locale @Composable -fun MessageBubble(message: Message) { +fun MessageBubble( + message: Message, + agentName: String = "助手", + agentAvatar: String? = null +) { val isUser = message.role == Message.Role.USER val isTool = message.role == Message.Role.TOOL val alignment = if (isUser) Alignment.End else Alignment.Start @@ -42,15 +50,11 @@ fun MessageBubble(message: Message) { ) { if (!isUser && !isTool) { Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - imageVector = Icons.Default.SmartToy, - contentDescription = null, - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.primary - ) + // Avatar: emoji, image URL, or default icon + AgentAvatar(agentAvatar = agentAvatar) Spacer(modifier = Modifier.width(4.dp)) Text( - text = "助手", + text = agentName, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold @@ -111,3 +115,40 @@ private fun formatTimestamp(epochMillis: Long): String { } } } + +@Composable +private fun AgentAvatar(agentAvatar: String?) { + when { + // Image URL + agentAvatar != null && (agentAvatar.startsWith("http") || + agentAvatar.startsWith("/") || + agentAvatar.startsWith("data:")) -> { + AsyncImage( + model = ImageRequest.Builder(androidx.compose.ui.platform.LocalContext.current) + .data(agentAvatar) + .crossfade(true) + .build(), + contentDescription = "头像", + modifier = Modifier.size(18.dp), + contentScale = ContentScale.Crop + ) + } + // Emoji or text avatar + agentAvatar != null -> { + Text( + text = agentAvatar, + fontSize = 16.sp, + modifier = Modifier.size(18.dp) + ) + } + // Default fallback + else -> { + Icon( + imageVector = Icons.Default.SmartToy, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.primary + ) + } + } +} diff --git a/android/app/src/main/java/com/tiangong/aiagent/ui/settings/SettingsScreen.kt b/android/app/src/main/java/com/tiangong/aiagent/ui/settings/SettingsScreen.kt index cfbd802..3a63522 100644 --- a/android/app/src/main/java/com/tiangong/aiagent/ui/settings/SettingsScreen.kt +++ b/android/app/src/main/java/com/tiangong/aiagent/ui/settings/SettingsScreen.kt @@ -52,7 +52,7 @@ class SettingsViewModel @Inject constructor( .stateIn(viewModelScope, SharingStarted.Eagerly, false) val ttsVoice: StateFlow = tokenDataStore.ttsVoice - .stateIn(viewModelScope, SharingStarted.Eagerly, "alloy") + .stateIn(viewModelScope, SharingStarted.Eagerly, "xiaoxiao") val themeMode: StateFlow = tokenDataStore.themeMode .stateIn(viewModelScope, SharingStarted.Eagerly, "system") @@ -153,12 +153,21 @@ class SettingsViewModel @Inject constructor( } fun voiceLabel(voice: String): String = when (voice) { - "alloy" -> "Alloy (中性)" - "echo" -> "Echo (男声)" - "fable" -> "Fable (英式)" - "onyx" -> "Onyx (深沉)" - "nova" -> "Nova (女声)" - "shimmer" -> "Shimmer (轻柔)" + "xiaoxiao" -> "晓晓 (温柔女声)" + "xiaoyi" -> "晓怡 (活泼女声)" + "yunxi" -> "云希 (阳光男声)" + "yunyang" -> "云扬 (新闻男声)" + "yunjian" -> "云健 (成熟男声)" + "yunxia" -> "云夏 (可爱女声)" + "xiaochen" -> "晓辰 (温婉女声)" + "xiaohan" -> "晓涵 (知性女声)" + // Backward compat: OpenAI → Edge voice names + "alloy" -> "晓晓 (默认)" + "echo" -> "云扬 (默认)" + "fable" -> "晓晓 (默认)" + "onyx" -> "云健 (默认)" + "nova" -> "晓怡 (默认)" + "shimmer" -> "晓晓 (默认)" else -> voice } } @@ -492,7 +501,7 @@ fun SettingsScreen( // Voice selection dialog if (showVoiceDialog) { - val voices = listOf("alloy", "echo", "fable", "onyx", "nova", "shimmer") + val voices = listOf("xiaoxiao", "xiaoyi", "yunxi", "yunyang", "yunjian", "yunxia") AlertDialog( onDismissRequest = { showVoiceDialog = false }, title = { Text("选择 TTS 音色") }, diff --git a/android/app/src/main/java/com/tiangong/aiagent/util/AudioRecorder.kt b/android/app/src/main/java/com/tiangong/aiagent/util/AudioRecorder.kt index facbe8c..41d4d1a 100644 --- a/android/app/src/main/java/com/tiangong/aiagent/util/AudioRecorder.kt +++ b/android/app/src/main/java/com/tiangong/aiagent/util/AudioRecorder.kt @@ -19,6 +19,9 @@ class AudioRecorder @Inject constructor( @Throws(IOException::class) fun startRecording(): File { + // Guard against double-start which leaks the first MediaRecorder + if (mediaRecorder != null) return outputFile!! + outputFile = File(context.cacheDir, "voice_${System.currentTimeMillis()}.aac") mediaRecorder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { diff --git a/android/app/src/main/java/com/tiangong/aiagent/util/MarkdownRenderer.kt b/android/app/src/main/java/com/tiangong/aiagent/util/MarkdownRenderer.kt index 000c61d..8620f9d 100644 --- a/android/app/src/main/java/com/tiangong/aiagent/util/MarkdownRenderer.kt +++ b/android/app/src/main/java/com/tiangong/aiagent/util/MarkdownRenderer.kt @@ -33,6 +33,7 @@ object MarkdownRenderer { * Uses regex to remove common formatting markers: * **bold**, *italic*, `code`, ```blocks```, # headings, * [links](url), ![images](url), > blockquotes, - lists, | tables. + * Also removes emoji and parenthetical content (same as web cleanForTTS). */ fun stripMarkdownForTTS(text: String): String { if (text.isBlank()) return "" @@ -47,18 +48,67 @@ object MarkdownRenderer { .replace(Regex("(?m)^>\\s*"), "") // blockquote .replace(Regex("(?m)^---+$"), "") // horizontal rules .replace("|", " ") // table pipes + .replace(Regex("[((][^))]*[))]"), "") // parenthetical content .replace(Regex("\\n{3,}"), "\n\n") // collapse excessive newlines + .let { stripEmoji(it) } // strip emoji characters .trim() } + /** + * Remove emoji characters using code-point ranges. + * Java/Kotlin regex doesn't support Unicode emoji property escapes, + * so we filter by checking each code point against known emoji blocks. + */ + private fun stripEmoji(text: String): String { + val sb = StringBuilder(text.length) + var i = 0 + while (i < text.length) { + val cp = text.codePointAt(i) + val charCount = Character.charCount(cp) + if (!isEmoji(cp)) { + sb.appendCodePoint(cp) + } + i += charCount + } + return sb.toString() + } + + private fun isEmoji(cp: Int): Boolean { + return cp in 0x1F600..0x1F64F // Emoticons + || cp in 0x1F300..0x1F5FF // Misc Symbols & Pictographs + || cp in 0x1F680..0x1F6FF // Transport & Map + || cp in 0x1F1E0..0x1F1FF // Flags (regional indicators) + || cp in 0x1F900..0x1F9FF // Supplemental Symbols + || cp in 0x1FA00..0x1FA6F // Chess Symbols + || cp in 0x1FA70..0x1FAFF // Symbols Extended-A + || cp in 0x2600..0x27BF // Misc Symbols + Dingbats + || cp in 0x2300..0x23FF // Misc Technical + || cp in 0x2B50..0x2B55 // Star/circle symbols + || cp in 0x2702..0x27B0 // Scissors/dingbats + || cp in 0x25AA..0x25FE // Geometric shapes + || cp == 0x200D // Zero-width joiner + || cp == 0xFE0F // Variation selector-16 + || cp in 0x2934..0x2935 // Curved arrows + || cp == 0x3030 // Wavy dash + || cp == 0x303D // Part alternation mark + || cp == 0x3297 // Congratulation (CJK) + || cp == 0x3299 // Secret (CJK) + || cp in 0x1F000..0x1F02F // Mahjong/Domino tiles + || cp in 0x1F0A0..0x1F0FF // Playing cards + || cp in 0x1FA80..0x1FA8F // Kite, yo-yo etc + } + + @Volatile private var markwonInstance: Markwon? = null private fun getMarkwon(context: Context): Markwon { - return markwonInstance ?: Markwon.builder(context) - .usePlugin(CoilImagesPlugin.create(context)) - .usePlugin(LinkifyPlugin.create()) - .build() - .also { markwonInstance = it } + return markwonInstance ?: synchronized(this) { + markwonInstance ?: Markwon.builder(context) + .usePlugin(CoilImagesPlugin.create(context)) + .usePlugin(LinkifyPlugin.create()) + .build() + .also { markwonInstance = it } + } } /** @@ -310,7 +360,7 @@ object MarkdownRenderer { withStyle( SpanStyle( fontFamily = FontFamily.Monospace, - background = androidx.compose.ui.graphics.Color(0x20000000) + background = MaterialTheme.colorScheme.surfaceVariant ) ) { append(text.substring(i + 1, end))