fix: revert SpeechQueue TTS batching, keep TTS text cleaning

Reverted the sentence-batching SpeechQueue that caused duplicate playback.
TTS now uses the original simple approach: speak full text once on Final.
Retained: parenthetical content stripping + emoji removal for cleaner TTS output.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 09:03:30 +08:00
parent 1693292bbd
commit b20bafa598
9 changed files with 165 additions and 29 deletions

View File

@@ -51,6 +51,7 @@ data class AgentResponse(
val id: String, val id: String,
val name: String, val name: String,
val description: String?, val description: String?,
val avatar: String? = null,
@SerializedName("workflow_config") val workflowConfig: Map<String, Any?>?, @SerializedName("workflow_config") val workflowConfig: Map<String, Any?>?,
@SerializedName("budget_config") val budgetConfig: Map<String, Any?>?, @SerializedName("budget_config") val budgetConfig: Map<String, Any?>?,
val version: Int = 1, val version: Int = 1,
@@ -268,7 +269,8 @@ data class AsrResponse(
data class TtsRequest( data class TtsRequest(
val text: String, val text: String,
val voice: String = "alloy" val voice: String = "xiaoxiao",
val speed: Float = 1.0f
) )
data class TtsResponse( data class TtsResponse(

View File

@@ -25,6 +25,7 @@ class AgentRepository @Inject constructor(
id = dto.id, id = dto.id,
name = dto.name ?: "", name = dto.name ?: "",
description = dto.description ?: "", description = dto.description ?: "",
avatar = dto.avatar,
status = dto.status ?: "published", status = dto.status ?: "published",
userId = dto.userId, userId = dto.userId,
version = dto.version, version = dto.version,
@@ -50,6 +51,7 @@ class AgentRepository @Inject constructor(
id = dto.id, id = dto.id,
name = dto.name ?: "", name = dto.name ?: "",
description = dto.description ?: "", description = dto.description ?: "",
avatar = dto.avatar,
status = dto.status ?: "published", status = dto.status ?: "published",
userId = dto.userId, userId = dto.userId,
version = dto.version, version = dto.version,
@@ -150,6 +152,7 @@ class AgentRepository @Inject constructor(
id = id, id = id,
name = name ?: "", name = name ?: "",
description = description ?: "", description = description ?: "",
avatar = avatar,
status = status ?: "published", status = status ?: "published",
userId = userId, userId = userId,
version = version, version = version,

View File

@@ -8,5 +8,6 @@ data class Agent(
val userId: String? = null, val userId: String? = null,
val version: Int = 1, val version: Int = 1,
val createdAt: String = "", val createdAt: String = "",
val updatedAt: String = "" val updatedAt: String = "",
val avatar: String? = null // emoji or image URL
) )

View File

@@ -2,6 +2,7 @@ package com.tiangong.aiagent.ui.chat
import android.net.Uri import android.net.Uri
import android.util.Log import android.util.Log
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
@@ -74,6 +75,11 @@ fun ChatScreen(
// Drawer state // Drawer state
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) 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 // Track first load for skeleton
LaunchedEffect(uiState.messages.size, uiState.isStreaming) { LaunchedEffect(uiState.messages.size, uiState.isStreaming) {
if (uiState.messages.isNotEmpty() || uiState.isStreaming || uiState.currentAgent != null) { if (uiState.messages.isNotEmpty() || uiState.isStreaming || uiState.currentAgent != null) {
@@ -663,7 +669,11 @@ fun ChatScreen(
if (showFeedback) { if (showFeedback) {
val clipboardManager = LocalClipboardManager.current val clipboardManager = LocalClipboardManager.current
Column { Column {
MessageBubbleFromUi(message = message) MessageBubbleFromUi(
message = message,
agentName = uiState.currentAgent?.name ?: "助手",
agentAvatar = uiState.currentAgent?.avatar
)
Row( Row(
modifier = Modifier.padding(start = 56.dp, bottom = 4.dp), modifier = Modifier.padding(start = 56.dp, bottom = 4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp),
@@ -794,7 +804,11 @@ fun ChatScreen(
// User message: show bubble with copy & share actions // User message: show bubble with copy & share actions
val clipboardManager = LocalClipboardManager.current val clipboardManager = LocalClipboardManager.current
Column { Column {
MessageBubbleFromUi(message = message) MessageBubbleFromUi(
message = message,
agentName = uiState.currentAgent?.name ?: "助手",
agentAvatar = uiState.currentAgent?.avatar
)
Row( Row(
modifier = Modifier.padding(end = 16.dp, bottom = 4.dp), modifier = Modifier.padding(end = 16.dp, bottom = 4.dp),
horizontalArrangement = Arrangement.End, horizontalArrangement = Arrangement.End,
@@ -904,8 +918,16 @@ fun ChatScreen(
/** Thin adapter to render UiMessage via existing MessageBubble. */ /** Thin adapter to render UiMessage via existing MessageBubble. */
@Composable @Composable
private fun MessageBubbleFromUi(message: UiMessage) { private fun MessageBubbleFromUi(
com.tiangong.aiagent.ui.chat.components.MessageBubble(message = message.toDomainMessage()) 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 { private fun UiMessage.toDomainMessage(): com.tiangong.aiagent.domain.model.Message {

View File

@@ -437,8 +437,12 @@ class ChatViewModel @Inject constructor(
return 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 val agentId = _uiState.value.currentAgent?.id
if (agentId == null) {
_uiState.value = _uiState.value.copy(error = "请先选择一个智能体")
return
}
val sessionId = _uiState.value.sessionId ?: UUID.randomUUID().toString() val sessionId = _uiState.value.sessionId ?: UUID.randomUUID().toString()
// Analytics: track message send // Analytics: track message send
@@ -999,6 +1003,7 @@ class ChatViewModel @Inject constructor(
super.onCleared() super.onCleared()
sseJob?.cancel() sseJob?.cancel()
historyFlowJob?.cancel() historyFlowJob?.cancel()
audioPlayer.release()
audioRecorder.cleanup() audioRecorder.cleanup()
persistSession() persistSession()
} }

View File

@@ -9,8 +9,12 @@ import androidx.compose.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp 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.domain.model.Message
import com.tiangong.aiagent.util.MarkdownRenderer import com.tiangong.aiagent.util.MarkdownRenderer
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
@@ -18,7 +22,11 @@ import java.util.Date
import java.util.Locale import java.util.Locale
@Composable @Composable
fun MessageBubble(message: Message) { fun MessageBubble(
message: Message,
agentName: String = "助手",
agentAvatar: String? = null
) {
val isUser = message.role == Message.Role.USER val isUser = message.role == Message.Role.USER
val isTool = message.role == Message.Role.TOOL val isTool = message.role == Message.Role.TOOL
val alignment = if (isUser) Alignment.End else Alignment.Start val alignment = if (isUser) Alignment.End else Alignment.Start
@@ -42,15 +50,11 @@ fun MessageBubble(message: Message) {
) { ) {
if (!isUser && !isTool) { if (!isUser && !isTool) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Icon( // Avatar: emoji, image URL, or default icon
imageVector = Icons.Default.SmartToy, AgentAvatar(agentAvatar = agentAvatar)
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))
Text( Text(
text = "助手", text = agentName,
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold 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
)
}
}
}

View File

@@ -52,7 +52,7 @@ class SettingsViewModel @Inject constructor(
.stateIn(viewModelScope, SharingStarted.Eagerly, false) .stateIn(viewModelScope, SharingStarted.Eagerly, false)
val ttsVoice: StateFlow<String> = tokenDataStore.ttsVoice val ttsVoice: StateFlow<String> = tokenDataStore.ttsVoice
.stateIn(viewModelScope, SharingStarted.Eagerly, "alloy") .stateIn(viewModelScope, SharingStarted.Eagerly, "xiaoxiao")
val themeMode: StateFlow<String> = tokenDataStore.themeMode val themeMode: StateFlow<String> = tokenDataStore.themeMode
.stateIn(viewModelScope, SharingStarted.Eagerly, "system") .stateIn(viewModelScope, SharingStarted.Eagerly, "system")
@@ -153,12 +153,21 @@ class SettingsViewModel @Inject constructor(
} }
fun voiceLabel(voice: String): String = when (voice) { fun voiceLabel(voice: String): String = when (voice) {
"alloy" -> "Alloy (中性)" "xiaoxiao" -> "晓晓 (温柔女声)"
"echo" -> "Echo (男声)" "xiaoyi" -> "晓怡 (活泼女声)"
"fable" -> "Fable (英式)" "yunxi" -> "云希 (阳光男声)"
"onyx" -> "Onyx (深沉)" "yunyang" -> "云扬 (新闻男声)"
"nova" -> "Nova (女声)" "yunjian" -> "云健 (成熟男声)"
"shimmer" -> "Shimmer (轻柔)" "yunxia" -> "云夏 (可爱女声)"
"xiaochen" -> "晓辰 (温婉女声)"
"xiaohan" -> "晓涵 (知性女声)"
// Backward compat: OpenAI → Edge voice names
"alloy" -> "晓晓 (默认)"
"echo" -> "云扬 (默认)"
"fable" -> "晓晓 (默认)"
"onyx" -> "云健 (默认)"
"nova" -> "晓怡 (默认)"
"shimmer" -> "晓晓 (默认)"
else -> voice else -> voice
} }
} }
@@ -492,7 +501,7 @@ fun SettingsScreen(
// Voice selection dialog // Voice selection dialog
if (showVoiceDialog) { if (showVoiceDialog) {
val voices = listOf("alloy", "echo", "fable", "onyx", "nova", "shimmer") val voices = listOf("xiaoxiao", "xiaoyi", "yunxi", "yunyang", "yunjian", "yunxia")
AlertDialog( AlertDialog(
onDismissRequest = { showVoiceDialog = false }, onDismissRequest = { showVoiceDialog = false },
title = { Text("选择 TTS 音色") }, title = { Text("选择 TTS 音色") },

View File

@@ -19,6 +19,9 @@ class AudioRecorder @Inject constructor(
@Throws(IOException::class) @Throws(IOException::class)
fun startRecording(): File { 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") outputFile = File(context.cacheDir, "voice_${System.currentTimeMillis()}.aac")
mediaRecorder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { mediaRecorder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {

View File

@@ -33,6 +33,7 @@ object MarkdownRenderer {
* Uses regex to remove common formatting markers: * Uses regex to remove common formatting markers:
* **bold**, *italic*, `code`, ```blocks```, # headings, * **bold**, *italic*, `code`, ```blocks```, # headings,
* [links](url), ![images](url), > blockquotes, - lists, | tables. * [links](url), ![images](url), > blockquotes, - lists, | tables.
* Also removes emoji and parenthetical content (same as web cleanForTTS).
*/ */
fun stripMarkdownForTTS(text: String): String { fun stripMarkdownForTTS(text: String): String {
if (text.isBlank()) return "" if (text.isBlank()) return ""
@@ -47,18 +48,67 @@ object MarkdownRenderer {
.replace(Regex("(?m)^>\\s*"), "") // blockquote .replace(Regex("(?m)^>\\s*"), "") // blockquote
.replace(Regex("(?m)^---+$"), "") // horizontal rules .replace(Regex("(?m)^---+$"), "") // horizontal rules
.replace("|", " ") // table pipes .replace("|", " ") // table pipes
.replace(Regex("[((][^))]*[))]"), "") // parenthetical content
.replace(Regex("\\n{3,}"), "\n\n") // collapse excessive newlines .replace(Regex("\\n{3,}"), "\n\n") // collapse excessive newlines
.let { stripEmoji(it) } // strip emoji characters
.trim() .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 var markwonInstance: Markwon? = null
private fun getMarkwon(context: Context): Markwon { private fun getMarkwon(context: Context): Markwon {
return markwonInstance ?: Markwon.builder(context) return markwonInstance ?: synchronized(this) {
.usePlugin(CoilImagesPlugin.create(context)) markwonInstance ?: Markwon.builder(context)
.usePlugin(LinkifyPlugin.create()) .usePlugin(CoilImagesPlugin.create(context))
.build() .usePlugin(LinkifyPlugin.create())
.also { markwonInstance = it } .build()
.also { markwonInstance = it }
}
} }
/** /**
@@ -310,7 +360,7 @@ object MarkdownRenderer {
withStyle( withStyle(
SpanStyle( SpanStyle(
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
background = androidx.compose.ui.graphics.Color(0x20000000) background = MaterialTheme.colorScheme.surfaceVariant
) )
) { ) {
append(text.substring(i + 1, end)) append(text.substring(i + 1, end))