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 name: String,
val description: String?,
val avatar: String? = null,
@SerializedName("workflow_config") val workflowConfig: Map<String, Any?>?,
@SerializedName("budget_config") val budgetConfig: Map<String, Any?>?,
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(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -52,7 +52,7 @@ class SettingsViewModel @Inject constructor(
.stateIn(viewModelScope, SharingStarted.Eagerly, false)
val ttsVoice: StateFlow<String> = tokenDataStore.ttsVoice
.stateIn(viewModelScope, SharingStarted.Eagerly, "alloy")
.stateIn(viewModelScope, SharingStarted.Eagerly, "xiaoxiao")
val themeMode: StateFlow<String> = 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 音色") },

View File

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

View File

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