feat(chat): add per-message read-aloud TTS playback (#1305)

This commit is contained in:
m57 authored and GitHub committed 2026-09-27 19:43:50 +03:00
1 parent 2e0fd46a4c
commit 3c56026a1c
14 files changed
+2370

No files matched your search

@@ -0,0 +1,26 @@
package com.m57.hermescontrol.data.model
import kotlinx.serialization.Serializable
/**
* Request body for `POST /api/audio/speak` — the dashboard's server-side
* text-to-speech relay.
*/
@Serializable
data class TtsSpeakRequest(
val text: String,
)
/**
* Response of `POST /api/audio/speak`.
*
* Returns base64 encoded [data_url] with [mime_type] and the [provider] used,
* or [ok] = false on failure.
*/
@Serializable
data class TtsSpeakResponse(
val ok: Boolean = false,
val data_url: String? = null,
val mime_type: String? = null,
val provider: String? = null,
)
@@ -134,6 +134,8 @@ import com.m57.hermescontrol.data.model.ToolsetPostSetupResponse
import com.m57.hermescontrol.data.model.ToolsetProviderSelectRequest
import com.m57.hermescontrol.data.model.ToolsetProviderSelectResponse
import com.m57.hermescontrol.data.model.ToolsetToggleRequest
import com.m57.hermescontrol.data.model.TtsSpeakRequest
import com.m57.hermescontrol.data.model.TtsSpeakResponse
import com.m57.hermescontrol.data.model.UpdateCheckResponse
import com.m57.hermescontrol.data.model.UpdateCronJobRequest
import com.m57.hermescontrol.data.model.UpdateProfileDescriptionRequest
@@ -1043,4 +1045,10 @@ interface HermesApiService : KanbanApiService {
suspend fun transcribeAudio(
@Body body: AudioTranscriptionRequest,
): Response<AudioTranscriptionResponse>
@POST("api/audio/speak")
suspend fun speakText(
@Body request: TtsSpeakRequest,
@Query("profile") profile: String? = null,
): Response<TtsSpeakResponse>
}
@@ -84,10 +84,12 @@ import com.m57.hermescontrol.HistoryScreen
import com.m57.hermescontrol.LogsScreen
import com.m57.hermescontrol.NavigationController
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.model.Attachment
import com.m57.hermescontrol.data.model.AttachmentSource
import com.m57.hermescontrol.data.model.BusySendMode
import com.m57.hermescontrol.data.model.reasoningSupport
import com.m57.hermescontrol.data.session.ActiveSessionHolder
import com.m57.hermescontrol.data.ws.ConnectionStatus
import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.notification.NotificationHelper
@@ -111,10 +113,13 @@ import com.m57.hermescontrol.ui.chat.components.ReplyErrorCard
import com.m57.hermescontrol.ui.chat.components.SearchBarRow
import com.m57.hermescontrol.ui.chat.components.SessionIntegrationsSheet
import com.m57.hermescontrol.ui.chat.components.SideQuestionSheet
import com.m57.hermescontrol.ui.chat.components.SpeechRequest
import com.m57.hermescontrol.ui.chat.components.SpeechText
import com.m57.hermescontrol.ui.chat.components.SubagentInspectionSheet
import com.m57.hermescontrol.ui.chat.components.TaskProgressChip
import com.m57.hermescontrol.ui.chat.components.rememberChatMediaLaunchers
import com.m57.hermescontrol.ui.chat.components.rememberChatScrollController
import com.m57.hermescontrol.ui.chat.components.rememberChatSpeech
import com.m57.hermescontrol.ui.chat.components.shouldShowProgressChip
import com.m57.hermescontrol.ui.chat.components.tailContentKey
import com.m57.hermescontrol.ui.chat.fullbleed.FullBleedChatList
@@ -249,6 +254,10 @@ fun ChatScreen(
}
val browserEvent = connectorsState.browserLaunchEvent
val speechController = rememberChatSpeech()
val speakingMessageId by speechController.speakingId.collectAsStateWithLifecycle()
val dataScope by AuthManager.dataScopeFlow.collectAsStateWithLifecycle()
val activeSessionId by ActiveSessionHolder.activeSessionId.collectAsStateWithLifecycle()
val listState = rememberLazyListState(prefetchStrategy = ChatTimelineNoPrefetchStrategy)
val scrollScope = rememberCoroutineScope()
val scrollController = rememberChatScrollController(listState, scrollScope)
@@ -799,6 +808,17 @@ fun ChatScreen(
savingAttachmentPath = pendingSavePath ?: state.savingAttachmentPath,
openingAttachmentPath = state.openingAttachmentPath,
onImageClick = { viewingImage = it },
speakingMessageId = speakingMessageId,
onToggleSpeak = { message ->
val scopeKey = "${dataScope?.inMemoryKey(localKey = activeSessionId ?: "none")}"
speechController.toggle(
SpeechRequest(
scopeKey = scopeKey,
messageId = message.id,
text = SpeechText.stripMarkdownForSpeech(message.content),
),
)
},
replyErrorContent =
state.replyFailure?.takeUnless { timelineState.isHistorical }?.let { failure ->
{
@@ -0,0 +1,288 @@
package com.m57.hermescontrol.ui.chat.components
import android.content.Context
import android.net.Uri
import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer
import com.m57.hermescontrol.data.model.TtsSpeakRequest
import com.m57.hermescontrol.data.remote.HermesApiService
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.io.InputStream
import java.util.Base64
import java.util.UUID
private const val TTS_CACHE_DIR_NAME = "speech_tts"
private const val TTS_FILE_PREFIX = "tts_"
private const val TTS_STALE_CACHE_MAX_AGE_MS = 24 * 60 * 60 * 1000L // 24 hours
class FileSpeechAudio(
val file: File,
) : SpeechAudio {
override fun dispose() {
runCatching { file.delete() }
}
}
/**
* Android implementation of [SpeechSynthesizer] backed by Hermes TTS REST API.
*
* Audio is decoded from base64 data URLs directly to temporary files in application cache.
* Stale orphan cache files (created in previous sessions or left behind by unexpected crashes)
* older than [TTS_STALE_CACHE_MAX_AGE_MS] are opportunistically pruned during synthesis on the
* IO dispatcher before creating new files.
*
* Limitation note: Single-active controller usage is expected. Active audio files in current use
* by the active session are protected from cleanup by the age threshold, but concurrent multi-process
* or out-of-band manipulation of cache files is not synchronized.
*/
class AndroidSpeechSynthesizer(
context: Context,
private val api: HermesApiService,
private val profile: String?,
) : SpeechSynthesizer {
private val appContext = context.applicationContext
override suspend fun synthesize(request: SpeechRequest): SpeechAudio {
val response =
api.speakText(
request = TtsSpeakRequest(request.text),
profile = profile,
)
if (!response.isSuccessful) {
throw IOException("TTS request failed with code ${response.code()}")
}
val body = response.body() ?: throw IOException("Empty TTS response body")
if (!body.ok) {
throw IOException("TTS synthesis reported failure")
}
val dataUrl = body.data_url
if (dataUrl.isNullOrBlank()) {
throw IOException("Missing data URL in TTS response")
}
var ownedFile: File? = null
try {
val audioFile =
withContext(Dispatchers.IO) {
val comma = dataUrl.indexOf(',')
if (comma < 0) {
throw IOException("Malformed data URL in TTS response")
}
val meta = dataUrl.substring(0, comma)
if (!meta.startsWith("data:audio/", ignoreCase = true)) {
throw IOException("Expected audio data URL")
}
if (!meta.endsWith(";base64", ignoreCase = true)) {
throw IOException("Expected base64-encoded audio data URL")
}
val ext =
when {
meta.contains(
"audio/mpeg",
ignoreCase = true,
) || meta.contains("audio/mp3", ignoreCase = true) -> "mp3"
meta.contains("audio/wav", ignoreCase = true) -> "wav"
meta.contains("audio/ogg", ignoreCase = true) -> "ogg"
meta.contains("audio/aac", ignoreCase = true) -> "aac"
meta.contains("audio/opus", ignoreCase = true) -> "opus"
meta.contains("audio/flac", ignoreCase = true) -> "flac"
else -> "audio"
}
val cacheDir = File(appContext.cacheDir, TTS_CACHE_DIR_NAME).apply { mkdirs() }
cleanStaleCacheFiles(cacheDir)
val file = File(cacheDir, "${TTS_FILE_PREFIX}${UUID.randomUUID()}.$ext")
ownedFile = file
val rawStream = CharSequenceInputStream(dataUrl, comma + 1)
val decodingStream = Base64.getDecoder().wrap(rawStream)
var totalBytesWritten = 0L
decodingStream.use { input ->
FileOutputStream(file).use { output ->
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
var bytesRead: Int
while (input.read(buffer).also { bytesRead = it } != -1) {
currentCoroutineContext().ensureActive()
output.write(buffer, 0, bytesRead)
totalBytesWritten += bytesRead
}
output.flush()
}
}
// Java's Base64.getDecoder().wrap() stops decoding upon encountering padding ('=').
// Verify that the underlying stream contains no unparsed trailing non-whitespace characters.
if (rawStream.read() != -1) {
throw IOException("Trailing garbage characters found after base64 data")
}
if (totalBytesWritten == 0L) {
throw IOException("Decoded audio file is empty")
}
file
}
ownedFile = null
return FileSpeechAudio(audioFile)
} catch (e: CancellationException) {
ownedFile?.let { runCatching { it.delete() } }
throw e
} catch (t: Throwable) {
ownedFile?.let { runCatching { it.delete() } }
throw t
}
}
private fun cleanStaleCacheFiles(cacheDir: File) {
val now = System.currentTimeMillis()
val files =
cacheDir.listFiles { _, name ->
name.startsWith(TTS_FILE_PREFIX)
} ?: return
for (file in files) {
if (now - file.lastModified() > TTS_STALE_CACHE_MAX_AGE_MS) {
runCatching { file.delete() }
}
}
}
private class CharSequenceInputStream(
private val value: CharSequence,
startIndex: Int,
) : InputStream() {
private var index = startIndex
override fun read(): Int {
while (index < value.length) {
val c = value[index++]
if (!c.isWhitespace()) {
if (c.code > 127) {
throw IOException("Non-ASCII character in base64 data")
}
return c.code
}
}
return -1
}
override fun read(
b: ByteArray,
off: Int,
len: Int,
): Int {
if (len == 0) return 0
var bytesRead = 0
while (bytesRead < len) {
val c = read()
if (c == -1) {
return if (bytesRead == 0) -1 else bytesRead
}
b[off + bytesRead] = c.toByte()
bytesRead++
}
return bytesRead
}
}
}
class AndroidSpeechPlayer(
context: Context,
) : SpeechPlayer {
private val appContext = context.applicationContext
private var exoPlayer: ExoPlayer? = null
private var activeListener: Player.Listener? = null
override fun play(
audio: SpeechAudio,
onEnded: () -> Unit,
onError: () -> Unit,
) {
if (audio !is FileSpeechAudio) {
onError()
return
}
val file = audio.file
if (!file.exists() || file.length() == 0L) {
onError()
return
}
val player =
exoPlayer ?: ExoPlayer
.Builder(appContext)
.setAudioAttributes(
AudioAttributes
.Builder()
.setContentType(C.AUDIO_CONTENT_TYPE_SPEECH)
.setUsage(C.USAGE_MEDIA)
.build(),
true,
).setHandleAudioBecomingNoisy(true)
.build()
.also { exoPlayer = it }
activeListener?.let { player.removeListener(it) }
val listener =
object : Player.Listener {
override fun onPlaybackStateChanged(playbackState: Int) {
if (playbackState == Player.STATE_ENDED) {
player.removeListener(this)
activeListener = null
onEnded()
}
}
override fun onPlayerError(error: PlaybackException) {
player.removeListener(this)
activeListener = null
onError()
}
}
activeListener = listener
player.addListener(listener)
val mediaItem = MediaItem.fromUri(Uri.fromFile(file))
player.setMediaItem(mediaItem)
player.prepare()
player.playWhenReady = true
}
override fun stop() {
val player = exoPlayer ?: return
activeListener?.let { player.removeListener(it) }
activeListener = null
player.stop()
player.clearMediaItems()
player.release()
exoPlayer = null
}
}
@@ -0,0 +1,99 @@
package com.m57.hermescontrol.ui.chat.components
import android.widget.Toast
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.session.ActiveSessionHolder
/**
* Creates and remembers a screen/list-level [SpeechController] scoped to the active
* chat session, server endpoint, profile, and auth state.
*
* Handles lifecycle events:
* - Resets (stops playback and cleans cache) on [Lifecycle.Event.ON_STOP]
* - Resets on composition disposal or when the server endpoint, profile, auth state, or session changes
*/
@Composable
fun rememberChatSpeech(
synthesizer: SpeechSynthesizer? = null,
player: SpeechPlayer? = null,
onError: (() -> Unit)? = null,
): SpeechController {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val lifecycleOwner = LocalLifecycleOwner.current
val errorSynthesisMessage = stringResource(R.string.tts_error_synthesis)
val dataScope by AuthManager.dataScopeFlow.collectAsStateWithLifecycle()
val activeSessionId by ActiveSessionHolder.activeSessionId.collectAsStateWithLifecycle()
val profile = dataScope?.activeProfileId
val scopeKey = dataScope?.inMemoryKey(localKey = activeSessionId ?: "none").orEmpty()
val resolvedSynthesizer =
remember(synthesizer, dataScope) {
synthesizer ?: AndroidSpeechSynthesizer(
context = context,
api = ApiClient.hermesApi,
profile = profile,
)
}
val resolvedPlayer =
remember(player, context) {
player ?: AndroidSpeechPlayer(context)
}
val controller =
remember(scope, resolvedSynthesizer, resolvedPlayer, errorSynthesisMessage) {
SpeechController(
scope = scope,
synthesizer = resolvedSynthesizer,
player = resolvedPlayer,
onError = {
onError?.invoke() ?: Toast
.makeText(
context,
errorSynthesisMessage,
Toast.LENGTH_SHORT,
).show()
},
)
}
// Reset when session scope changes (session/profile/server/auth switch)
DisposableEffect(controller, scopeKey) {
onDispose {
controller.reset()
}
}
// Reset on ON_STOP lifecycle event
DisposableEffect(lifecycleOwner, controller) {
val observer =
LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_STOP) {
controller.reset()
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
controller.reset()
}
}
return controller
}
@@ -0,0 +1,178 @@
package com.m57.hermescontrol.ui.chat.components
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
data class SpeechRequest(
val scopeKey: String,
val messageId: String,
val text: String,
)
interface SpeechAudio {
fun dispose()
}
interface SpeechSynthesizer {
suspend fun synthesize(request: SpeechRequest): SpeechAudio
}
interface SpeechPlayer {
fun play(
audio: SpeechAudio,
onEnded: () -> Unit,
onError: () -> Unit,
)
fun stop()
}
class SpeechController(
private val scope: CoroutineScope,
private val synthesizer: SpeechSynthesizer,
private val player: SpeechPlayer,
private val onError: () -> Unit,
) {
private val _speakingId = MutableStateFlow<String?>(null)
val speakingId: StateFlow<String?> = _speakingId.asStateFlow()
private var activeRequest: SpeechRequest? = null
private var activeJob: Job? = null
private var generation: Long = 0L
private var cachedRequest: SpeechRequest? = null
private var cachedAudio: SpeechAudio? = null
fun toggle(request: SpeechRequest) {
if (request.text.isBlank()) {
return
}
if (activeRequest == request) {
stop()
return
}
start(request)
}
fun stop() {
generation++
activeJob?.cancel()
activeJob = null
activeRequest = null
player.stop()
_speakingId.value = null
}
fun reset() {
stop()
disposeCache()
}
private fun start(request: SpeechRequest) {
generation++
val currentGen = generation
activeJob?.cancel()
player.stop()
if (cachedRequest != null && cachedRequest != request) {
disposeCache()
}
activeRequest = request
_speakingId.value = request.messageId
val cached = cachedAudio
if (cached != null && cachedRequest == request) {
playAudio(cached, currentGen)
return
}
activeJob =
scope.launch {
var synthesizedAudio: SpeechAudio? = null
try {
val audio = synthesizer.synthesize(request)
synthesizedAudio = audio
if (generation != currentGen) {
audio.dispose()
return@launch
}
cachedAudio?.dispose()
cachedRequest = request
cachedAudio = audio
synthesizedAudio = null
playAudio(audio, currentGen)
} catch (e: CancellationException) {
synthesizedAudio?.dispose()
throw e
} catch (e: Exception) {
synthesizedAudio?.dispose()
if (generation == currentGen) {
handlePlaybackFailure()
}
}
}
}
private fun playAudio(
audio: SpeechAudio,
gen: Long,
) {
try {
var completed = false
player.play(
audio = audio,
onEnded = {
if (generation == gen && !completed) {
completed = true
handlePlaybackFinished()
}
},
onError = {
if (generation == gen && !completed) {
completed = true
handlePlaybackFailure()
}
},
)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
if (generation == gen) {
handlePlaybackFailure()
}
}
}
private fun handlePlaybackFinished() {
activeJob = null
activeRequest = null
player.stop()
_speakingId.value = null
}
private fun handlePlaybackFailure() {
activeJob = null
activeRequest = null
player.stop()
_speakingId.value = null
onError()
}
private fun disposeCache() {
cachedAudio?.dispose()
cachedAudio = null
cachedRequest = null
}
}
@@ -0,0 +1,118 @@
package com.m57.hermescontrol.ui.chat.components
/**
* Plain-text preparation for per-message read-aloud (TTS speak button).
*
* Pure Kotlin — no Android imports — so it stays unit-testable on the JVM.
* Strips Markdown formatting while preserving readable prose, inline code,
* fenced code contents, link titles/alt texts, and Unicode/emojis.
*/
object SpeechText {
// Fenced code blocks: ``` or ~~~ with optional info string, spanning to closing fence or EOF.
private val FENCED_CODE_BLOCK_REGEX =
Regex("(?m)^[ \\t]*(`{3,}|~{3,})[^\\r\\n`~]*\\r?\\n([\\s\\S]*?)(?:^[ \\t]*\\1[ \\t]*(?:\\r?\\n|\\Z)|\\Z)")
// Placeholders with null byte to guarantee safety from standard markdown text
private const val CODE_SPAN_PREFIX = "\u0000HERMESCODE"
private const val CODE_SPAN_SUFFIX = "\u0000"
// Inline code: `code` or multiple backticks ``code``
private val INLINE_CODE_REGEX = Regex("(`+)(.+?)\\1")
// Markdown structures
private val IMAGE_LINK_REGEX = Regex("!\\[([^\\]]*)\\]\\([^)]*\\)")
private val LINK_REGEX = Regex("\\[([^\\]]*)\\]\\([^)]*\\)")
private val URL_REGEX = Regex("https?://\\S+")
private val HTML_TAG_REGEX = Regex("<[^>]+>")
// Block elements (multiline)
private val HEADING_REGEX = Regex("(?m)^\\s{0,3}#{1,6}\\s+")
private val BLOCKQUOTE_REGEX = Regex("(?m)^\\s*>\\s?")
private val LIST_MARKER_REGEX = Regex("(?m)^\\s*(?:[-*+]|\\d+[.)])\\s+")
private val HORIZONTAL_RULE_REGEX = Regex("(?m)^\\s*([-_*])(?:\\s*\\1){2,}\\s*$")
// Emphasis / strikethrough markers
private val BOLD_REGEX = Regex("(\\*\\*|__)(.*?)\\1")
private val ITALIC_REGEX = Regex("(\\*|_)(.*?)\\1")
private val STRIKETHROUGH_REGEX = Regex("~~(.*?)~~")
// Punctuation set including Arabic question mark (\u061F) and semicolon (\u061B), and standard ones
// Punctuations that can end a sentence or pause: . , ! ? ; : ؟ ؛
private const val PUNCTUATION_CLASS = "[.,!?;:\\u061F\\u061B]"
private val PUNCT_BEFORE_NEWLINE_REGEX = Regex("($PUNCTUATION_CLASS)[^\\S\\r\\n]*(?:\\r?\\n[^\\S\\r\\n]*)+")
private val PLAIN_NEWLINE_REGEX = Regex("[^\\S\\r\\n]*(?:\\r?\\n[^\\S\\r\\n]*)+")
private val WHITESPACE_REGEX = Regex("\\s+")
private val SPACE_BEFORE_PUNCT_REGEX = Regex("\\s+($PUNCTUATION_CLASS)")
fun stripMarkdownForSpeech(text: String): String {
if (text.isBlank()) return ""
val codeSpans = mutableListOf<String>()
// 1. Protect fenced code blocks before prose processing.
// Delimiters and info string are stripped; code content whitespace is normalized for speech.
var out =
FENCED_CODE_BLOCK_REGEX.replace(text) { matchResult ->
val rawContent = matchResult.groupValues[2]
val normalizedContent =
rawContent
.trim()
.split(
WHITESPACE_REGEX,
).filter { it.isNotEmpty() }
.joinToString(" ")
val index = codeSpans.size
codeSpans.add(normalizedContent)
"\n$CODE_SPAN_PREFIX$index$CODE_SPAN_SUFFIX\n"
}
// 2. Protect inline code spans before prose processing.
out =
INLINE_CODE_REGEX.replace(out) { matchResult ->
val content = matchResult.groupValues[2].trim()
val index = codeSpans.size
codeSpans.add(content)
"$CODE_SPAN_PREFIX$index$CODE_SPAN_SUFFIX"
}
// 3. Process prose markdown elements
// Images: keep alt text, drop the URL.
out = out.replace(IMAGE_LINK_REGEX, "$1")
// Links: keep visible text, drop the target.
out = out.replace(LINK_REGEX, "$1")
// Autolinks / bare URLs: drop the URL, keep surrounding prose.
out = out.replace(URL_REGEX, " ")
// HTML tags.
out = out.replace(HTML_TAG_REGEX, " ")
// Headings, blockquotes, list markers, horizontal rules.
out = out.replace(HEADING_REGEX, "")
out = out.replace(BLOCKQUOTE_REGEX, "")
out = out.replace(LIST_MARKER_REGEX, "")
out = out.replace(HORIZONTAL_RULE_REGEX, " ")
// Table pipes
out = out.replace("|", " ")
// Emphasis / strikethrough markers
out = out.replace(BOLD_REGEX, "$2")
out = out.replace(ITALIC_REGEX, "$2")
out = out.replace(STRIKETHROUGH_REGEX, "$1")
// 4. Normalise newlines into sensible pauses without accidental global punctuation mutation.
// If line ends with punctuation, keep it with a trailing space.
// Otherwise, replace newline with ". " to create a pause.
out = out.replace(PUNCT_BEFORE_NEWLINE_REGEX, "$1 ")
out = out.replace(PLAIN_NEWLINE_REGEX, ". ")
// 5. Restore protected code spans (both fenced and inline)
for ((index, code) in codeSpans.withIndex()) {
out = out.replace("$CODE_SPAN_PREFIX$index$CODE_SPAN_SUFFIX", code)
}
// 6. Collapse multiple whitespaces and trim
out = out.replace(WHITESPACE_REGEX, " ").trim()
// 7. Tidy spaces before punctuation left behind by marker removal.
out = out.replace(SPACE_BEFORE_PUNCT_REGEX, "$1")
return out
}
}
@@ -13,8 +13,10 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.VolumeUp
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -65,6 +67,8 @@ internal fun FullBleedAgentMessage(
openingAttachmentPath: String? = null,
canSaveAttachment: Boolean = true,
onImageClick: (ImageViewerModel) -> Unit = {},
isSpeaking: Boolean = false,
onToggleSpeak: (() -> Unit)? = null,
messageStatsEnabled: Boolean = false,
showAssistantMessageTokens: Boolean = true,
showTokensPerSecond: Boolean = true,
@@ -162,6 +166,31 @@ internal fun FullBleedAgentMessage(
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (onToggleSpeak != null) {
IconButton(
onClick = onToggleSpeak,
modifier = Modifier.size(28.dp).testTag("fullbleed_speak"),
) {
Icon(
imageVector =
if (isSpeaking) {
Icons.Filled.Stop
} else {
Icons.AutoMirrored.Filled.VolumeUp
},
contentDescription =
stringResource(
if (isSpeaking) {
R.string.content_desc_stop_speaking
} else {
R.string.content_desc_speak
},
),
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
if (showTokenStat) {
AssistantStatItem(
value =
@@ -105,6 +105,8 @@ fun FullBleedChatList(
pagingSessionId: String? = null,
onLoadOlder: () -> Unit = viewModel::loadOlderMessages,
replyErrorContent: (@Composable () -> Unit)? = null,
speakingMessageId: String? = null,
onToggleSpeak: ((ChatMessage) -> Unit)? = null,
) {
if (messages.isEmpty() && !isLoading && !isAgentTyping && replyErrorContent == null) {
Box(
@@ -363,6 +365,15 @@ fun FullBleedChatList(
showReasoning = !hoistedReasoning,
)
} else {
val isSpeakingThis =
speakingMessageId != null &&
speakingMessageId == proseMessage.id
val toggleSpeakAction =
if (onToggleSpeak != null) {
{ onToggleSpeak(proseMessage) }
} else {
null
}
FullBleedAgentMessage(
message = proseMessage,
// Highlight only bubbles that actually contain a match —
@@ -385,6 +396,8 @@ fun FullBleedChatList(
openingAttachmentPath = openingAttachmentPath,
canSaveAttachment = savingAttachmentPath == null,
onImageClick = onImageClick,
isSpeaking = isSpeakingThis,
onToggleSpeak = toggleSpeakAction,
messageStatsEnabled = messageStatsEnabled,
showAssistantMessageTokens = showAssistantMessageTokens,
showTokensPerSecond = showTokensPerSecond,