Co-authored-by: M57 <41645758+Hy4ri@users.noreply.github.com>
This commit is contained in:
1 parent
0e228dec18
commit
ae9003decd
19 files changed
+1619
-89
No files matched your search
+8
@@ -34,6 +34,7 @@ class ComposerReadinessTest {
|
||||
onMicTap = {},
|
||||
isListening = false,
|
||||
isAgentTyping = true,
|
||||
canInterrupt = ready.value,
|
||||
isConnected = true,
|
||||
commandCatalog = CommandCatalog(),
|
||||
isSessionReady = ready.value,
|
||||
@@ -42,13 +43,20 @@ class ComposerReadinessTest {
|
||||
compose.onNodeWithTag("chat_input").performTextInput("held draft")
|
||||
compose.onNodeWithTag("chat_input").assertTextEquals("held draft")
|
||||
compose.onNodeWithTag("chat_session_preparing").assertExists()
|
||||
// Nothing to interrupt while the session prepares: the action button
|
||||
// must not offer Stop (review, PR #1250).
|
||||
compose.onNodeWithTag("stop_button").assertDoesNotExist()
|
||||
compose.onNodeWithTag("send_button").assertIsNotEnabled().performClick()
|
||||
compose.runOnIdle { assertEquals(0, sends) }
|
||||
compose.runOnIdle { ready.value = true }
|
||||
compose.onNodeWithTag("chat_session_preparing").assertDoesNotExist()
|
||||
// Ready + typing = an interruptible generation: Stop replaces the
|
||||
// action glyph while queue-send stays available in the flat slot.
|
||||
compose.onNodeWithTag("stop_button").assertExists()
|
||||
compose.onNodeWithTag("send_button").assertIsEnabled().performClick()
|
||||
compose.runOnIdle { assertEquals(1, sends) }
|
||||
compose.runOnIdle { ready.value = false }
|
||||
compose.onNodeWithTag("stop_button").assertDoesNotExist()
|
||||
compose.onNodeWithTag("send_button").assertIsNotEnabled()
|
||||
compose.onNodeWithTag("chat_input").assertTextEquals("held draft")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.m57.hermescontrol.data.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Request body for `POST /api/audio/transcribe` — the dashboard's server-side
|
||||
* transcription relay, the same endpoint the desktop client's voice path uses.
|
||||
*
|
||||
* The recording rides inline as a base64 `data:` URL (the desktop wire shape).
|
||||
* [OkHttpProvider.json] carries [kotlinx.serialization.json.JsonNamingStrategy.SnakeCase],
|
||||
* so [dataUrl]/[mimeType] serialize as `data_url`/`mime_type`.
|
||||
*/
|
||||
@Serializable
|
||||
data class AudioTranscriptionRequest(
|
||||
val dataUrl: String,
|
||||
val mimeType: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* Response of `POST /api/audio/transcribe`.
|
||||
*
|
||||
* [transcript] is empty (not an error) when the server detects no speech in
|
||||
* the recording.
|
||||
*/
|
||||
@Serializable
|
||||
data class AudioTranscriptionResponse(
|
||||
val ok: Boolean = false,
|
||||
val transcript: String? = null,
|
||||
val provider: String? = null,
|
||||
)
|
||||
@@ -4,9 +4,11 @@ import com.m57.hermescontrol.BuildConfig
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.kotlinx.serialization.asConverterFactory
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Provides a Retrofit-backed [HermesApiService].
|
||||
@@ -22,6 +24,9 @@ object ApiClient {
|
||||
@Volatile
|
||||
private var service: HermesApiService? = null
|
||||
|
||||
@Volatile
|
||||
private var okHttpClient: OkHttpClient? = null
|
||||
|
||||
/** The current [HermesApiService] instance. Lazily created on first access. */
|
||||
val hermesApi: HermesApiService
|
||||
get() {
|
||||
@@ -39,6 +44,7 @@ object ApiClient {
|
||||
synchronized(this) {
|
||||
retrofit = null
|
||||
service = null
|
||||
okHttpClient = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,6 +87,64 @@ object ApiClient {
|
||||
return tempRetrofit.create(HermesApiService::class.java)
|
||||
}
|
||||
|
||||
/**
|
||||
* Dedicated [HermesApiService] for the blocking voice-note transcription
|
||||
* POST (`/api/audio/transcribe`).
|
||||
*
|
||||
* STT blocks until the profile's provider finishes, so it cannot run on
|
||||
* the shared 30s client, and a re-submitted POST would repeat expensive
|
||||
* provider work after the request may already have reached the server
|
||||
* (the repository also passes `retries = 0`). The returned service reuses
|
||||
* the shared connection pool and the auth/profile interceptor stack, and
|
||||
* applies [readTimeoutMs] to reads and writes with transport retries
|
||||
* disabled (review, PR #1250).
|
||||
*/
|
||||
fun transcriptionService(readTimeoutMs: Long): HermesApiService {
|
||||
val client =
|
||||
OkHttpProvider
|
||||
.base
|
||||
.newBuilder()
|
||||
.addInterceptor(restAuthInterceptor())
|
||||
.addInterceptor(ProfileScopeInterceptor)
|
||||
.authenticator(TokenRefreshAuthenticator)
|
||||
.readTimeout(readTimeoutMs, TimeUnit.MILLISECONDS)
|
||||
.writeTimeout(readTimeoutMs, TimeUnit.MILLISECONDS)
|
||||
.retryOnConnectionFailure(false)
|
||||
.build()
|
||||
return Retrofit
|
||||
.Builder()
|
||||
.baseUrl(AuthManager.endpointForBuild().baseUrl)
|
||||
.client(client)
|
||||
.addConverterFactory(OkHttpProvider.json.asConverterFactory("application/json".toMediaType()))
|
||||
.build()
|
||||
.create(HermesApiService::class.java)
|
||||
}
|
||||
|
||||
/**
|
||||
* REST auth stamping shared by every client: token mode sends the bearer
|
||||
* header; gated (basic-auth cookie) mode leaves the request alone because
|
||||
* the shared CookieManager jar carries the session cookie and a stray
|
||||
* Authorization header would 401 (issue #470).
|
||||
*/
|
||||
private fun restAuthInterceptor(): Interceptor =
|
||||
Interceptor { chain ->
|
||||
val request = chain.request()
|
||||
if (AuthManager.isGatedMode()) {
|
||||
return@Interceptor chain.proceed(request)
|
||||
}
|
||||
val token = AuthManager.getToken()
|
||||
if (!token.isNullOrBlank()) {
|
||||
chain.proceed(
|
||||
request
|
||||
.newBuilder()
|
||||
.addHeader("Authorization", "Bearer $token")
|
||||
.build(),
|
||||
)
|
||||
} else {
|
||||
chain.proceed(request)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Internal ─────────────────────────────────────────────────────────
|
||||
|
||||
private fun buildService(): HermesApiService =
|
||||
@@ -111,25 +175,7 @@ object ApiClient {
|
||||
// must NOT stamp one here — otherwise every REST tab (skills, cron,
|
||||
// config, ...) fails with "token expired" while the WS chat, which
|
||||
// authenticates via ?ticket=, keeps working.
|
||||
val authInterceptor =
|
||||
Interceptor { chain ->
|
||||
val request = chain.request()
|
||||
if (AuthManager.isGatedMode()) {
|
||||
// Cookie in the shared jar is the only valid REST credential.
|
||||
return@Interceptor chain.proceed(request)
|
||||
}
|
||||
val token = AuthManager.getToken()
|
||||
if (!token.isNullOrBlank()) {
|
||||
chain.proceed(
|
||||
request
|
||||
.newBuilder()
|
||||
.addHeader("Authorization", "Bearer $token")
|
||||
.build(),
|
||||
)
|
||||
} else {
|
||||
chain.proceed(request)
|
||||
}
|
||||
}
|
||||
val authInterceptor = restAuthInterceptor()
|
||||
|
||||
val okHttp =
|
||||
OkHttpProvider
|
||||
|
||||
@@ -7,6 +7,8 @@ import com.m57.hermescontrol.data.model.ActiveProfileResponse
|
||||
import com.m57.hermescontrol.data.model.AddMcpServerRequest
|
||||
import com.m57.hermescontrol.data.model.AgentPluginInstallBody
|
||||
import com.m57.hermescontrol.data.model.AnalyticsResponse
|
||||
import com.m57.hermescontrol.data.model.AudioTranscriptionRequest
|
||||
import com.m57.hermescontrol.data.model.AudioTranscriptionResponse
|
||||
import com.m57.hermescontrol.data.model.AuxiliaryModelsResponse
|
||||
import com.m57.hermescontrol.data.model.BackupTriggerRequest
|
||||
import com.m57.hermescontrol.data.model.BulkDeleteRequest
|
||||
@@ -1030,4 +1032,14 @@ interface HermesApiService : KanbanApiService {
|
||||
suspend fun deleteManagedFile(
|
||||
@Body body: ManagedFileDelete,
|
||||
): Response<ManagedFileActionResponse>
|
||||
|
||||
// ── Audio: server-side transcription ────────────────────────────────
|
||||
// The dashboard's voice relay (hermes_cli/web_routers/audio.py) — the
|
||||
// same endpoint the desktop client's voice notes use. The clip rides
|
||||
// inline as a base64 data URL; the server resolves STT through the
|
||||
// active profile's configured provider.
|
||||
@POST("api/audio/transcribe")
|
||||
suspend fun transcribeAudio(
|
||||
@Body body: AudioTranscriptionRequest,
|
||||
): Response<AudioTranscriptionResponse>
|
||||
}
|
||||
@@ -26,6 +26,13 @@ object ProfileScopeInterceptor : Interceptor {
|
||||
private val PROFILE_SCOPED_PREFIXES =
|
||||
listOf(
|
||||
"/api/analytics",
|
||||
// Voice-note transcription resolves STT through the profile's
|
||||
// configured provider, so the request must carry the active
|
||||
// profile (the desktop sends this route `...profileScoped()`;
|
||||
// hermes_cli/web_routers/audio.py is config-scoped). Unscoped, a
|
||||
// multi-profile host transcribes under the launch profile
|
||||
// (review, PR #1250).
|
||||
"/api/audio",
|
||||
"/api/config",
|
||||
"/api/credentials/pool",
|
||||
"/api/cron",
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package com.m57.hermescontrol.data.repository
|
||||
|
||||
import com.m57.hermescontrol.data.model.AudioTranscriptionRequest
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
import com.m57.hermescontrol.data.remote.HermesApiService
|
||||
import com.m57.hermescontrol.data.remote.NetworkError
|
||||
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||
import com.m57.hermescontrol.data.remote.safeApiCall
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.util.Base64
|
||||
|
||||
/**
|
||||
* MIME type of the recorder's output: MPEG-4 container with AAC audio — see
|
||||
* `VoiceNoteRecorder` in the chat UI layer.
|
||||
*/
|
||||
const val VOICE_NOTE_MIME_TYPE = "audio/mp4"
|
||||
|
||||
/**
|
||||
* Transcribes a recorded voice note through the dashboard's server-side
|
||||
* transcription endpoint (`POST /api/audio/transcribe`).
|
||||
*
|
||||
* The wire shape is the desktop client's: base64 `data:` URL plus MIME type.
|
||||
* The server resolves STT through the active profile's configured provider
|
||||
* (the request is profile-scoped by ProfileScopeInterceptor, matching the
|
||||
* desktop's `...profileScoped()` on this route), so the transcript matches
|
||||
* the rest of Hermes instead of the phone's on-device recognizer.
|
||||
*
|
||||
* Two contracts differ from a plain REST call (review, PR #1250):
|
||||
* - [serviceProvider] resolves the Retrofit service at CALL time, so a
|
||||
* connection/profile switch (ApiClient.rebuild) applies to the next
|
||||
* transcription instead of pinning the construction-time service.
|
||||
* - The STT POST runs on a dedicated long-timeout client with `retries = 0`:
|
||||
* re-submitting after a timeout or 5xx would repeat expensive provider
|
||||
* work although the request may already have reached the server.
|
||||
*
|
||||
* Open for test substitution (mirrors [ChatPersistenceRepository]'s pattern).
|
||||
*/
|
||||
open class VoiceNoteRepository(
|
||||
private val serviceProvider: (timeoutMs: Long) -> HermesApiService = { timeoutMs ->
|
||||
ApiClient.transcriptionService(timeoutMs)
|
||||
},
|
||||
) {
|
||||
open suspend fun transcribe(file: File): NetworkResult<String> {
|
||||
val encoded =
|
||||
try {
|
||||
file.readBytes()
|
||||
} catch (e: IOException) {
|
||||
// A local read failure must return a Failure instead of
|
||||
// escaping as an exception — the caller resets its
|
||||
// transcription state from this result (review, PR #1250).
|
||||
return NetworkResult.Failure(
|
||||
NetworkError.Unknown("Failed to read the voice note: ${e.message}", e),
|
||||
)
|
||||
}
|
||||
val dataUrl = "data:$VOICE_NOTE_MIME_TYPE;base64," + Base64.getEncoder().encodeToString(encoded)
|
||||
val result =
|
||||
safeApiCall(retries = 0) {
|
||||
serviceProvider(transcriptionTimeoutMs(dataUrl)).transcribeAudio(
|
||||
AudioTranscriptionRequest(dataUrl = dataUrl, mimeType = VOICE_NOTE_MIME_TYPE),
|
||||
)
|
||||
}
|
||||
return when (result) {
|
||||
is NetworkResult.Success -> {
|
||||
NetworkResult.Success(
|
||||
result.data
|
||||
?.transcript
|
||||
.orEmpty()
|
||||
.trim(),
|
||||
)
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Floor for STT requests — desktop parity (`AUDIO_TRANSCRIBE_MIN_REQUEST_TIMEOUT_MS`). */
|
||||
const val MIN_REQUEST_TIMEOUT_MS = 180_000L
|
||||
|
||||
/** Cap so a hung transcription cannot pin the call forever — desktop parity. */
|
||||
const val MAX_REQUEST_TIMEOUT_MS = 600_000L
|
||||
|
||||
private const val BASE64_CHARS_PER_TIMEOUT_MS = 10L
|
||||
|
||||
/**
|
||||
* Timeout for the blocking STT endpoint, mirroring the desktop's
|
||||
* `audioTranscribeRequestTimeoutMs`: ~0.1ms of budget per base64 char
|
||||
* keeps short clips at the floor while multi-minute recordings scale
|
||||
* toward the cap. Integer ceil() keeps the arithmetic exact.
|
||||
*/
|
||||
fun transcriptionTimeoutMs(dataUrl: String): Long =
|
||||
((dataUrl.length + BASE64_CHARS_PER_TIMEOUT_MS - 1) / BASE64_CHARS_PER_TIMEOUT_MS)
|
||||
.coerceIn(MIN_REQUEST_TIMEOUT_MS, MAX_REQUEST_TIMEOUT_MS)
|
||||
}
|
||||
}
|
||||
@@ -440,12 +440,16 @@ fun ChatScreen(
|
||||
onAddAttachments = { attachments ->
|
||||
viewModel.addAttachments(attachments)
|
||||
},
|
||||
onVoiceNoteRecorded = { file ->
|
||||
viewModel.sendVoiceNote(file)
|
||||
},
|
||||
onShowMessage = { msg ->
|
||||
scrollScope.launch {
|
||||
snackbarHostState.showSnackbar(msg)
|
||||
}
|
||||
},
|
||||
launchExternalActivity = launchExternalActivity,
|
||||
isTranscribingVoiceNote = state.isTranscribingVoiceNote,
|
||||
context = context,
|
||||
)
|
||||
|
||||
@@ -901,9 +905,16 @@ fun ChatScreen(
|
||||
}
|
||||
},
|
||||
onMicTap = mediaLaunchers.onMicTap,
|
||||
isListening = mediaLaunchers.isListening,
|
||||
onMicHoldStart = mediaLaunchers.onMicHoldStart,
|
||||
onMicHoldEnd = mediaLaunchers.onMicHoldEnd,
|
||||
onMicHoldCancel = mediaLaunchers.onMicHoldCancel,
|
||||
isListening = mediaLaunchers.isListening || state.isTranscribingVoiceNote,
|
||||
isRecordingVoice = mediaLaunchers.isRecordingVoice,
|
||||
voiceNoteAmplitude = mediaLaunchers.voiceNoteAmplitude,
|
||||
onStopGeneration = { viewModel.interruptSession() },
|
||||
isAgentTyping = state.isAgentTyping,
|
||||
isMainTurnBusy = state.isMainTurnBusy,
|
||||
canInterrupt = state.canInterrupt,
|
||||
isConnected = state.isConnected,
|
||||
isSessionReady = state.isSessionReady && !timelineState.isHistorical,
|
||||
sessionPreparationFailed = state.resumeError != null,
|
||||
|
||||
@@ -6,6 +6,7 @@ import android.util.Log
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.local.DataScope
|
||||
import com.m57.hermescontrol.data.local.HermesDatabase
|
||||
import com.m57.hermescontrol.data.local.SlashUsageStore
|
||||
import com.m57.hermescontrol.data.model.Attachment
|
||||
@@ -22,6 +23,7 @@ import com.m57.hermescontrol.data.remote.ApiClient
|
||||
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||
import com.m57.hermescontrol.data.remote.safeApiCall
|
||||
import com.m57.hermescontrol.data.repository.VoiceNoteRepository
|
||||
import com.m57.hermescontrol.data.session.ActiveSessionHolder
|
||||
import com.m57.hermescontrol.data.session.ProfileSwitchCoordinator
|
||||
import com.m57.hermescontrol.data.ws.CommandBlocklist
|
||||
@@ -66,6 +68,11 @@ private const val TAG = "ChatViewModel"
|
||||
private const val MESSAGE_PAGE_SIZE = 150
|
||||
private const val TIMELINE_PAGE_SIZE = 500
|
||||
private const val HISTORY_WINDOW_SIZE = 120
|
||||
private const val VOICE_NOTE_OFFLINE_MESSAGE = "Voice note not sent — not connected"
|
||||
private const val VOICE_NOTE_EMPTY_MESSAGE = "No speech detected in the voice note"
|
||||
private const val VOICE_NOTE_FAILED_MESSAGE = "Voice note transcription failed"
|
||||
private const val VOICE_NOTE_UNSENT_MESSAGE =
|
||||
"Voice note not sent — transcript kept in the input field"
|
||||
|
||||
private val REASONING_EFFORT_LEVELS =
|
||||
setOf("none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra")
|
||||
@@ -135,6 +142,8 @@ data class ChatUiState(
|
||||
val pendingSends: List<PendingSend> = emptyList(),
|
||||
val isThinking: Boolean = false,
|
||||
val thinkingText: String = "",
|
||||
/** True while a recorded voice note uploads for server-side transcription. */
|
||||
val isTranscribingVoiceNote: Boolean = false,
|
||||
val isLoading: Boolean = false,
|
||||
val isLoadingOlder: Boolean = false,
|
||||
val hasOlderMessages: Boolean = false,
|
||||
@@ -255,6 +264,15 @@ data class ChatUiState(
|
||||
) {
|
||||
/** Convenience — derived from [connectionStatus]. */
|
||||
val isConnected: Boolean get() = connectionStatus == ConnectionStatus.CONNECTED
|
||||
|
||||
/**
|
||||
* True only while a generation can actually be interrupted: typing can
|
||||
* start during session preparation, before any runtime session exists,
|
||||
* and `session.interrupt` has nothing to stop then. The composer derives
|
||||
* its Stop affordance from this instead of [isAgentTyping] alone
|
||||
* (review, PR #1250).
|
||||
*/
|
||||
val canInterrupt: Boolean get() = isAgentTyping && isSessionReady
|
||||
}
|
||||
|
||||
data class ChatTimelineState(
|
||||
@@ -414,6 +432,7 @@ class ChatViewModel(
|
||||
private val ioDispatcher: kotlinx.coroutines.CoroutineDispatcher = kotlinx.coroutines.Dispatchers.IO,
|
||||
private val historyDispatcher: kotlinx.coroutines.CoroutineDispatcher = searchDispatcher,
|
||||
private val sendStore: ChatSendStore = ChatSendStore(application),
|
||||
private val voiceNoteRepository: VoiceNoteRepository = VoiceNoteRepository(),
|
||||
) : AndroidViewModel(application) {
|
||||
constructor(application: Application) : this(application, startCleanup = true)
|
||||
|
||||
@@ -2874,6 +2893,113 @@ class ChatViewModel(
|
||||
return true
|
||||
}
|
||||
|
||||
/** True while a voice-note transcription owns the mic-to-send pipeline. */
|
||||
private var voiceNoteTranscriptionInFlight = false
|
||||
|
||||
/**
|
||||
* Transcribe a recorded voice note through the dashboard's server-side
|
||||
* STT relay (`POST /api/audio/transcribe` — the desktop client's voice
|
||||
* path) and submit the transcript as the next message. The profile's
|
||||
* configured STT provider answers, not the phone's on-device recognizer.
|
||||
*
|
||||
* The recording belongs to the session that was active when it was taken:
|
||||
* transcription takes long enough for the socket, the profile, or the user
|
||||
* to move on, so a transcript that can no longer land in that session or
|
||||
* connection/profile scope is preserved in the composer instead of being
|
||||
* dropped. Only one
|
||||
* transcription runs at a time — [voiceNoteTranscriptionInFlight] keeps
|
||||
* every branch below single-flight (review, PR #1250).
|
||||
*/
|
||||
fun sendVoiceNote(file: File) {
|
||||
if (voiceNoteTranscriptionInFlight) {
|
||||
file.delete()
|
||||
return
|
||||
}
|
||||
if (!canSubmitMessage()) {
|
||||
file.delete()
|
||||
_uiState.update { it.copy(errorMessage = VOICE_NOTE_OFFLINE_MESSAGE) }
|
||||
return
|
||||
}
|
||||
val recordedSessionId = _uiState.value.currentSessionId
|
||||
val recordedRuntimeSessionId = runtimeSessionId
|
||||
// The recording also belongs to the connection/profile scope it was
|
||||
// taken on: both session IDs can still be null/null across a scope
|
||||
// switch while a replacement session create is pending, so the data
|
||||
// scope is the deciding identity for that window (review, PR #1250).
|
||||
val recordedDataScope = AuthManager.currentDataScope()
|
||||
voiceNoteTranscriptionInFlight = true
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isTranscribingVoiceNote = true) }
|
||||
try {
|
||||
val result =
|
||||
withContext(ioDispatcher) {
|
||||
try {
|
||||
voiceNoteRepository.transcribe(file)
|
||||
} finally {
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
when (result) {
|
||||
is NetworkResult.Success -> {
|
||||
val transcript = result.data.trim()
|
||||
if (transcript.isEmpty()) {
|
||||
_uiState.update { it.copy(errorMessage = VOICE_NOTE_EMPTY_MESSAGE) }
|
||||
} else {
|
||||
deliverVoiceTranscript(
|
||||
transcript = transcript,
|
||||
recordedSessionId = recordedSessionId,
|
||||
recordedRuntimeSessionId = recordedRuntimeSessionId,
|
||||
recordedDataScope = recordedDataScope,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
Log.w(TAG, "Voice note transcription failed: ${result.error.message}")
|
||||
_uiState.update {
|
||||
it.copy(errorMessage = "$VOICE_NOTE_FAILED_MESSAGE: ${result.error.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
// Always release the single-flight flag and the composer's
|
||||
// transcription state, even if a branch above throws.
|
||||
voiceNoteTranscriptionInFlight = false
|
||||
_uiState.update { it.copy(isTranscribingVoiceNote = false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Submits a transcribed voice note, or keeps the transcript in the input
|
||||
* field when it can no longer land where it was recorded. The recording
|
||||
* file is already deleted by now, so a rejected send restores the text
|
||||
* instead of losing it.
|
||||
*/
|
||||
private fun deliverVoiceTranscript(
|
||||
transcript: String,
|
||||
recordedSessionId: String?,
|
||||
recordedRuntimeSessionId: String?,
|
||||
recordedDataScope: DataScope?,
|
||||
) {
|
||||
val sessionChanged =
|
||||
_uiState.value.currentSessionId != recordedSessionId ||
|
||||
runtimeSessionId != recordedRuntimeSessionId
|
||||
// A connection/profile switch can leave the session IDs null on both
|
||||
// sides of the recording (a replacement session create may still be
|
||||
// pending), so the data scope is the ownership check that catches
|
||||
// that move (review, PR #1250).
|
||||
val scopeChanged = AuthManager.currentDataScope() != recordedDataScope
|
||||
if (sessionChanged || scopeChanged || !sendMessage(transcript)) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
composerTextToRestore = transcript,
|
||||
errorMessage = VOICE_NOTE_UNSENT_MESSAGE,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun canSubmitMessage(): Boolean =
|
||||
_uiState.value.pendingReasoningLevel == null &&
|
||||
wsClient.connectionStatus.value == ConnectionStatus.CONNECTED &&
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import android.content.Context
|
||||
import android.media.MediaRecorder
|
||||
import android.os.Build
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Records a hold-to-talk voice note as an MPEG-4/AAC file in the app cache.
|
||||
*
|
||||
* One instance drives one in-flight recording; the call site owns UI state and
|
||||
* decides what to do with the clip. 16 kHz mono AAC keeps the payload small
|
||||
* (roughly 0.5 MB at [MAX_DURATION_MS]) so it uploads quickly to the
|
||||
* dashboard's transcription endpoint.
|
||||
*/
|
||||
class VoiceNoteRecorder(
|
||||
private val context: Context,
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "VoiceNoteRecorder"
|
||||
|
||||
/** Clips shorter than this are treated as accidental and discarded. */
|
||||
const val MIN_DURATION_MS = 500L
|
||||
|
||||
/**
|
||||
* Hard cap. The call site (ChatMediaLaunchers) owns the timer and
|
||||
* drives the single stop path — the recorder itself no longer stops
|
||||
* recording, so MediaRecorder's auto-stop cannot race the
|
||||
* hold-release stop (review, PR #1250).
|
||||
*/
|
||||
const val MAX_DURATION_MS = 120_000
|
||||
}
|
||||
|
||||
private var recorder: MediaRecorder? = null
|
||||
private var outputFile: File? = null
|
||||
private var startedAtMs = 0L
|
||||
|
||||
val isActive: Boolean
|
||||
get() = recorder != null
|
||||
|
||||
/** Starts recording. Returns false when the microphone is unavailable. */
|
||||
fun start(): Boolean {
|
||||
if (recorder != null) return false
|
||||
val file =
|
||||
runCatching { File.createTempFile("voice_", ".m4a", context.cacheDir) }
|
||||
.getOrNull()
|
||||
?: return false
|
||||
return try {
|
||||
val mediaRecorder =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
MediaRecorder(context)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
MediaRecorder()
|
||||
}
|
||||
mediaRecorder.apply {
|
||||
setAudioSource(MediaRecorder.AudioSource.MIC)
|
||||
setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
|
||||
setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
|
||||
setAudioSamplingRate(16_000)
|
||||
setAudioEncodingBitRate(32_000)
|
||||
setAudioChannels(1)
|
||||
setOutputFile(file.absolutePath)
|
||||
prepare()
|
||||
start()
|
||||
}
|
||||
recorder = mediaRecorder
|
||||
outputFile = file
|
||||
startedAtMs = SystemClock.elapsedRealtime()
|
||||
Log.i(TAG, "Voice note recording started: ${file.name}")
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to start voice note recording", e)
|
||||
releaseQuietly()
|
||||
file.delete()
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops recording and returns the clip when it is long enough to send.
|
||||
* Returns null for too-short or failed recordings — the temp file is
|
||||
* removed in both cases.
|
||||
*/
|
||||
fun stop(): File? {
|
||||
val active = recorder ?: return null
|
||||
val file = outputFile
|
||||
val durationMs = SystemClock.elapsedRealtime() - startedAtMs
|
||||
val stopped = runCatching { active.stop() }.isSuccess
|
||||
releaseQuietly()
|
||||
val byteCount = file?.length() ?: -1L
|
||||
Log.i(TAG, "Voice note stop: durationMs=$durationMs stopped=$stopped bytes=$byteCount")
|
||||
if (!stopped || file == null || durationMs < MIN_DURATION_MS) {
|
||||
file?.delete()
|
||||
return null
|
||||
}
|
||||
return file
|
||||
}
|
||||
|
||||
/** Aborts the recording and deletes the clip. */
|
||||
fun cancel() {
|
||||
if (recorder != null) {
|
||||
runCatching { recorder?.stop() }
|
||||
}
|
||||
releaseQuietly()
|
||||
outputFile?.delete()
|
||||
}
|
||||
|
||||
/** Peak amplitude of the current recording window — 0 when idle. */
|
||||
fun currentAmplitude(): Int = runCatching { recorder?.maxAmplitude ?: 0 }.getOrDefault(0)
|
||||
|
||||
private fun releaseQuietly() {
|
||||
runCatching { recorder?.release() }
|
||||
recorder = null
|
||||
outputFile = null
|
||||
startedAtMs = 0L
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,7 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -83,6 +84,7 @@ fun ChatInputBar(
|
||||
isListening: Boolean,
|
||||
isAgentTyping: Boolean,
|
||||
isMainTurnBusy: Boolean = false,
|
||||
canInterrupt: Boolean = false,
|
||||
isConnected: Boolean,
|
||||
commandCatalog: CommandCatalog,
|
||||
isSessionReady: Boolean,
|
||||
@@ -108,6 +110,12 @@ fun ChatInputBar(
|
||||
showModelProvider: Boolean = false,
|
||||
reasoningWireLevel: String? = null,
|
||||
pendingReasoningLevel: String? = null,
|
||||
onMicHoldStart: () -> Unit = {},
|
||||
onMicHoldEnd: () -> Unit = {},
|
||||
onMicHoldCancel: () -> Unit = {},
|
||||
isRecordingVoice: Boolean = false,
|
||||
voiceNoteAmplitude: State<Float> = remember { mutableStateOf(0f) },
|
||||
onStopGeneration: () -> Unit = {},
|
||||
) {
|
||||
// Allow sending while the agent is mid-turn or awaiting approval: the
|
||||
// gateway's prompt.submit busy-input policy queues it as the next turn
|
||||
@@ -269,55 +277,64 @@ fun ChatInputBar(
|
||||
}
|
||||
val isInputRtl = inputLayoutDirection == LayoutDirection.Rtl
|
||||
|
||||
CompositionLocalProvider(LocalLayoutDirection provides inputLayoutDirection) {
|
||||
BasicTextField(
|
||||
value = inputFieldValue,
|
||||
onValueChange = onInputChange,
|
||||
modifier =
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.heightIn(min = 42.dp, max = 200.dp)
|
||||
.padding(vertical = 4.dp)
|
||||
.testTag("chat_input"),
|
||||
enabled = isConnected,
|
||||
textStyle =
|
||||
MaterialTheme.typography.bodyLarge.copy(
|
||||
color = palette.text,
|
||||
textAlign = if (isInputRtl) TextAlign.Right else TextAlign.Left,
|
||||
textDirection = if (isInputRtl) TextDirection.Rtl else TextDirection.Ltr,
|
||||
),
|
||||
singleLine = false,
|
||||
maxLines = 8,
|
||||
cursorBrush = SolidColor(palette.text),
|
||||
decorationBox = { innerTextField ->
|
||||
CompositionLocalProvider(LocalLayoutDirection provides ambientLayoutDirection) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentAlignment =
|
||||
if (isInputRtl) {
|
||||
Alignment.CenterEnd
|
||||
} else {
|
||||
Alignment.CenterStart
|
||||
},
|
||||
) {
|
||||
CompositionLocalProvider(LocalLayoutDirection provides inputLayoutDirection) {
|
||||
if (inputFieldValue.text.isEmpty()) {
|
||||
Text(
|
||||
text = placeholderText,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = if (isInputRtl) TextAlign.Right else TextAlign.Left,
|
||||
color = palette.placeholder,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
if (isRecordingVoice) {
|
||||
VoiceNoteRecordingPanel(
|
||||
amplitude = voiceNoteAmplitude,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
} else {
|
||||
CompositionLocalProvider(LocalLayoutDirection provides inputLayoutDirection) {
|
||||
BasicTextField(
|
||||
value = inputFieldValue,
|
||||
onValueChange = onInputChange,
|
||||
modifier =
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.heightIn(min = 42.dp, max = 200.dp)
|
||||
.padding(vertical = 4.dp)
|
||||
.testTag("chat_input"),
|
||||
enabled = isConnected,
|
||||
textStyle =
|
||||
MaterialTheme.typography.bodyLarge.copy(
|
||||
color = palette.text,
|
||||
textAlign = if (isInputRtl) TextAlign.Right else TextAlign.Left,
|
||||
textDirection = if (isInputRtl) TextDirection.Rtl else TextDirection.Ltr,
|
||||
),
|
||||
singleLine = false,
|
||||
maxLines = 8,
|
||||
cursorBrush = SolidColor(palette.text),
|
||||
decorationBox = { innerTextField ->
|
||||
CompositionLocalProvider(LocalLayoutDirection provides ambientLayoutDirection) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentAlignment =
|
||||
if (isInputRtl) {
|
||||
Alignment.CenterEnd
|
||||
} else {
|
||||
Alignment.CenterStart
|
||||
},
|
||||
) {
|
||||
CompositionLocalProvider(
|
||||
LocalLayoutDirection provides inputLayoutDirection,
|
||||
) {
|
||||
if (inputFieldValue.text.isEmpty()) {
|
||||
Text(
|
||||
text = placeholderText,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = if (isInputRtl) TextAlign.Right else TextAlign.Left,
|
||||
color = palette.placeholder,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
innerTextField()
|
||||
}
|
||||
innerTextField()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -333,10 +350,15 @@ fun ChatInputBar(
|
||||
onSend = onSend,
|
||||
onQueue = { onBusySend(BusySendMode.QUEUE) },
|
||||
onStopAndSend = { onBusySend(BusySendMode.INTERRUPT) },
|
||||
canInterrupt = canInterrupt,
|
||||
onStopGeneration = onStopGeneration,
|
||||
onAttachTap = { showAttachmentTray = !showAttachmentTray },
|
||||
onModelTap = onModelTap,
|
||||
onReasoningSelected = onReasoningTap,
|
||||
onMicTap = onMicTap,
|
||||
onMicHoldStart = onMicHoldStart,
|
||||
onMicHoldEnd = onMicHoldEnd,
|
||||
onMicHoldCancel = onMicHoldCancel,
|
||||
modifier = Modifier.testTag("chat_composer_toolbar"),
|
||||
canDisableReasoning = canDisableReasoning,
|
||||
supportsReasoning = supportsReasoning,
|
||||
|
||||
@@ -12,6 +12,9 @@ import android.util.Log
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -27,6 +30,8 @@ import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.model.Attachment
|
||||
import com.m57.hermescontrol.ui.chat.ChatInputPolicy
|
||||
import com.m57.hermescontrol.ui.chat.SpeechInputHelper
|
||||
import com.m57.hermescontrol.ui.chat.VoiceNoteRecorder
|
||||
import kotlinx.coroutines.delay
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
@@ -34,10 +39,15 @@ import java.util.Locale
|
||||
|
||||
class ChatMediaLaunchers(
|
||||
val isListening: Boolean,
|
||||
val isRecordingVoice: Boolean,
|
||||
val voiceNoteAmplitude: State<Float>,
|
||||
val onMicTap: () -> Unit,
|
||||
val onCameraTap: () -> Unit,
|
||||
val onImageTap: () -> Unit,
|
||||
val onFileTap: () -> Unit,
|
||||
val onMicHoldStart: () -> Unit = {},
|
||||
val onMicHoldEnd: () -> Unit = {},
|
||||
val onMicHoldCancel: () -> Unit = {},
|
||||
)
|
||||
|
||||
@Composable
|
||||
@@ -46,8 +56,10 @@ fun rememberChatMediaLaunchers(
|
||||
onInputFieldValueChange: (TextFieldValue) -> Unit,
|
||||
onAddAttachment: (uri: String, name: String, mimeType: String, size: Long) -> Unit,
|
||||
onAddAttachments: (List<Attachment>) -> Unit,
|
||||
onVoiceNoteRecorded: (file: File) -> Unit,
|
||||
onShowMessage: (String) -> Unit,
|
||||
launchExternalActivity: (() -> Unit) -> Unit,
|
||||
isTranscribingVoiceNote: Boolean,
|
||||
context: Context = LocalContext.current,
|
||||
): ChatMediaLaunchers {
|
||||
var isListening by remember { mutableStateOf(false) }
|
||||
@@ -57,6 +69,7 @@ fun rememberChatMediaLaunchers(
|
||||
val currentOnInputFieldValueChange by rememberUpdatedState(onInputFieldValueChange)
|
||||
val currentOnAddAttachment by rememberUpdatedState(onAddAttachment)
|
||||
val currentOnAddAttachments by rememberUpdatedState(onAddAttachments)
|
||||
val currentOnVoiceNoteRecorded by rememberUpdatedState(onVoiceNoteRecorded)
|
||||
val currentOnShowMessage by rememberUpdatedState(onShowMessage)
|
||||
val currentLaunchExternalActivity by rememberUpdatedState(launchExternalActivity)
|
||||
|
||||
@@ -64,6 +77,59 @@ fun rememberChatMediaLaunchers(
|
||||
val sttNotAvailableMsg = stringResource(R.string.stt_not_available)
|
||||
val sttPermissionDeniedMsg = stringResource(R.string.stt_permission_denied)
|
||||
val cameraErrorMsg = stringResource(R.string.chat_camera_error)
|
||||
val voiceRecordFailedMsg = stringResource(R.string.chat_voice_record_failed)
|
||||
|
||||
// Hold-to-record voice note recorder: the clip uploads to the dashboard
|
||||
// for server-side transcription, unlike the flat tap path below, which
|
||||
// still drives the on-device recognizer.
|
||||
val voiceNoteRecorder = remember { VoiceNoteRecorder(context) }
|
||||
var isRecordingVoice by remember { mutableStateOf(false) }
|
||||
|
||||
// Live mic level for the recording panel — rises fast, decays slowly so
|
||||
// the meter reads as voice activity instead of flicker.
|
||||
val voiceNoteAmplitude = remember { mutableStateOf(0f) }
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
delay(90)
|
||||
val previous = voiceNoteAmplitude.value
|
||||
val next =
|
||||
if (voiceNoteRecorder.isActive) {
|
||||
maxOf(voiceNoteRecorder.currentAmplitude() / 32767f, previous * 0.8f)
|
||||
} else {
|
||||
0f
|
||||
}
|
||||
if (next != previous) {
|
||||
voiceNoteAmplitude.value = next
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun finishVoiceRecording() {
|
||||
val recordedFile = voiceNoteRecorder.stop()
|
||||
isRecordingVoice = false
|
||||
if (recordedFile != null) {
|
||||
currentOnVoiceNoteRecorded(recordedFile)
|
||||
}
|
||||
}
|
||||
|
||||
// App-owned max-duration cap: the timer drives the same
|
||||
// finishVoiceRecording() path as the hold release, so exactly one code
|
||||
// path ever calls stop() — MediaRecorder's own auto-stop used to race
|
||||
// the release and discard a valid max-length clip (review, PR #1250).
|
||||
LaunchedEffect(isRecordingVoice) {
|
||||
if (isRecordingVoice) {
|
||||
delay(VoiceNoteRecorder.MAX_DURATION_MS.toLong())
|
||||
if (voiceNoteRecorder.isActive) {
|
||||
finishVoiceRecording()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(voiceNoteRecorder) {
|
||||
onDispose {
|
||||
voiceNoteRecorder.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
// Speech-to-text recognition launcher (issue #194)
|
||||
val speechLauncher =
|
||||
@@ -117,6 +183,18 @@ fun rememberChatMediaLaunchers(
|
||||
}
|
||||
}
|
||||
|
||||
// Hold-to-record permission: unlike the launcher above, granting does NOT
|
||||
// auto-start dictation — the user records by holding the mic again.
|
||||
val voiceNotePermissionLauncher =
|
||||
rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission(),
|
||||
) { granted ->
|
||||
ExternalActivityLifecycleGuard.externalActivityReturned()
|
||||
if (!granted) {
|
||||
currentOnShowMessage(sttPermissionDeniedMsg)
|
||||
}
|
||||
}
|
||||
|
||||
// Multi-file picker for attachments (issue #195)
|
||||
val filePickerLauncher =
|
||||
rememberLauncherForActivityResult(
|
||||
@@ -183,7 +261,14 @@ fun rememberChatMediaLaunchers(
|
||||
}
|
||||
|
||||
val onMicTap: () -> Unit = {
|
||||
if (isListening) {
|
||||
if (isTranscribingVoiceNote) {
|
||||
// Single-flight: a server transcription owns the voice pipeline
|
||||
// until it lands (review, PR #1250).
|
||||
} else if (isRecordingVoice) {
|
||||
// A tap while recording discards the in-flight voice note.
|
||||
voiceNoteRecorder.cancel()
|
||||
isRecordingVoice = false
|
||||
} else if (isListening) {
|
||||
isListening = false
|
||||
} else if (
|
||||
ContextCompat.checkSelfPermission(
|
||||
@@ -212,6 +297,45 @@ fun rememberChatMediaLaunchers(
|
||||
}
|
||||
}
|
||||
|
||||
val onMicHoldStart: () -> Unit = {
|
||||
if (isTranscribingVoiceNote) {
|
||||
// Single-flight: no new recording while a transcription runs.
|
||||
} else if (voiceNoteRecorder.isActive || isListening) {
|
||||
// A voice note or a dictation session is already running.
|
||||
} else if (
|
||||
ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.RECORD_AUDIO,
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
if (voiceNoteRecorder.start()) {
|
||||
isRecordingVoice = true
|
||||
} else {
|
||||
currentOnShowMessage(voiceRecordFailedMsg)
|
||||
}
|
||||
} else {
|
||||
// The denial message comes from voiceNotePermissionLauncher's
|
||||
// result callback only — showing it here fired it before the
|
||||
// dialog even opened (review, PR #1250).
|
||||
currentLaunchExternalActivity {
|
||||
voiceNotePermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val onMicHoldEnd: () -> Unit = {
|
||||
if (voiceNoteRecorder.isActive) {
|
||||
finishVoiceRecording()
|
||||
}
|
||||
}
|
||||
|
||||
val onMicHoldCancel: () -> Unit = {
|
||||
if (voiceNoteRecorder.isActive) {
|
||||
voiceNoteRecorder.cancel()
|
||||
}
|
||||
isRecordingVoice = false
|
||||
}
|
||||
|
||||
val onCameraTap: () -> Unit = {
|
||||
try {
|
||||
val timeStamp =
|
||||
@@ -245,13 +369,18 @@ fun rememberChatMediaLaunchers(
|
||||
}
|
||||
}
|
||||
|
||||
return remember(isListening) {
|
||||
return remember(isListening, isRecordingVoice, isTranscribingVoiceNote) {
|
||||
ChatMediaLaunchers(
|
||||
isListening = isListening,
|
||||
isListening = isListening || isRecordingVoice,
|
||||
isRecordingVoice = isRecordingVoice,
|
||||
voiceNoteAmplitude = voiceNoteAmplitude,
|
||||
onMicTap = onMicTap,
|
||||
onCameraTap = onCameraTap,
|
||||
onImageTap = onImageTap,
|
||||
onFileTap = onFileTap,
|
||||
onMicHoldStart = onMicHoldStart,
|
||||
onMicHoldEnd = onMicHoldEnd,
|
||||
onMicHoldCancel = onMicHoldCancel,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,8 @@ import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -51,12 +53,16 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.input.pointer.PointerInputScope
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.CustomAccessibilityAction
|
||||
@@ -67,6 +73,7 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.m57.hermescontrol.R
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
|
||||
/**
|
||||
* Bottom controls row for the chat composer, rendered inside the composer card.
|
||||
@@ -116,12 +123,50 @@ fun ComposerToolbar(
|
||||
reasoningWireLevel: String? = null,
|
||||
pendingReasoningLevel: String? = null,
|
||||
isSessionReady: Boolean = true,
|
||||
canInterrupt: Boolean = false,
|
||||
onMicHoldStart: () -> Unit = {},
|
||||
onMicHoldEnd: () -> Unit = {},
|
||||
onMicHoldCancel: () -> Unit = {},
|
||||
onStopGeneration: () -> Unit = {},
|
||||
) {
|
||||
var showReasoningMenu by remember { mutableStateOf(false) }
|
||||
val palette = composerPalette()
|
||||
val reasoningDisabledForModel = supportsReasoning == false
|
||||
val canDisable = canDisableReasoning
|
||||
|
||||
// Telegram-style voice notes: hold to record, release to send, slide away
|
||||
// to cancel. Both mic controls run the same single pointer loop
|
||||
// (micHoldHandler), which owns the whole press until finger-up — so the
|
||||
// release that submits the note can never be lost between recompositions.
|
||||
val currentIsConnected = rememberUpdatedState(isConnected)
|
||||
val currentShowSend = rememberUpdatedState(showSend)
|
||||
val currentCanInterrupt = rememberUpdatedState(canInterrupt)
|
||||
val currentOnMicHoldStart = rememberUpdatedState(onMicHoldStart)
|
||||
val currentOnMicHoldEnd = rememberUpdatedState(onMicHoldEnd)
|
||||
val currentOnMicHoldCancel = rememberUpdatedState(onMicHoldCancel)
|
||||
val flatMicGesture =
|
||||
Modifier.pointerInput(Unit) {
|
||||
micHoldHandler(
|
||||
isEnabled = {
|
||||
currentIsConnected.value && currentShowSend.value && !currentCanInterrupt.value
|
||||
},
|
||||
onHoldStart = { currentOnMicHoldStart.value() },
|
||||
onHoldEnd = { currentOnMicHoldEnd.value() },
|
||||
onHoldCancel = { currentOnMicHoldCancel.value() },
|
||||
)
|
||||
}
|
||||
val actionMicGesture =
|
||||
Modifier.pointerInput(Unit) {
|
||||
micHoldHandler(
|
||||
isEnabled = {
|
||||
currentIsConnected.value && !currentShowSend.value && !currentCanInterrupt.value
|
||||
},
|
||||
onHoldStart = { currentOnMicHoldStart.value() },
|
||||
onHoldEnd = { currentOnMicHoldEnd.value() },
|
||||
onHoldCancel = { currentOnMicHoldCancel.value() },
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier =
|
||||
modifier
|
||||
@@ -417,25 +462,47 @@ fun ComposerToolbar(
|
||||
}
|
||||
}
|
||||
|
||||
// Flat mic / stop button — only while the action button is in send mode
|
||||
// Flat slot — mic while idle; while a generation can be interrupted it
|
||||
// keeps queue-send available (the action button carries Stop). During
|
||||
// session preparation the mic stays here and the action button is a
|
||||
// disabled send: Stop must not appear when there is nothing to
|
||||
// interrupt (review, PR #1250).
|
||||
AnimatedVisibility(
|
||||
visible = showSend,
|
||||
enter = fadeIn() + scaleIn(initialScale = 0.8f),
|
||||
exit = fadeOut() + scaleOut(targetScale = 0.8f),
|
||||
) {
|
||||
FilledIconButton(
|
||||
onClick = onMicTap,
|
||||
enabled = isConnected,
|
||||
colors = if (isListening) listeningIconButtonColors() else flatIconButtonColors(palette),
|
||||
modifier =
|
||||
Modifier
|
||||
.size(ControlSize)
|
||||
.testTag(if (isListening) "mic_stop_button" else "mic_button"),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (isListening) Icons.Default.Stop else Icons.Outlined.Mic,
|
||||
contentDescription = if (isListening) "Stop listening" else "Mic",
|
||||
)
|
||||
if (canInterrupt) {
|
||||
FilledIconButton(
|
||||
onClick = onSend,
|
||||
enabled = canSend,
|
||||
colors = flatIconButtonColors(palette),
|
||||
modifier =
|
||||
Modifier
|
||||
.size(ControlSize)
|
||||
.testTag("send_button"),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.Send,
|
||||
contentDescription = stringResource(R.string.chat_send_desc),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
FilledIconButton(
|
||||
onClick = onMicTap,
|
||||
enabled = isConnected,
|
||||
colors = if (isListening) listeningIconButtonColors() else flatIconButtonColors(palette),
|
||||
modifier =
|
||||
Modifier
|
||||
.size(ControlSize)
|
||||
.testTag(if (isListening) "mic_stop_button" else "mic_button")
|
||||
.then(flatMicGesture),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (isListening) Icons.Default.Stop else Icons.Outlined.Mic,
|
||||
contentDescription = if (isListening) "Stop listening" else "Mic",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -484,21 +551,29 @@ fun ComposerToolbar(
|
||||
}
|
||||
}.combinedClickable(
|
||||
enabled = actionEnabled,
|
||||
onClick = if (showSend) onSend else onMicTap,
|
||||
onClick = {
|
||||
when {
|
||||
canInterrupt -> onStopGeneration()
|
||||
showSend -> onSend()
|
||||
else -> onMicTap()
|
||||
}
|
||||
},
|
||||
onLongClick = if (showSend && showQueue) onStopAndSend else null,
|
||||
onLongClickLabel = if (showSend && showQueue) stopAndSendLabel else null,
|
||||
).testTag(
|
||||
when {
|
||||
canInterrupt -> "stop_button"
|
||||
showSend -> "send_button"
|
||||
isListening -> "mic_stop_button"
|
||||
else -> "mic_button"
|
||||
},
|
||||
),
|
||||
).then(if (showSend || canInterrupt) Modifier else actionMicGesture),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Crossfade(
|
||||
targetState =
|
||||
when {
|
||||
canInterrupt -> ActionGlyph.STOP
|
||||
showSend -> ActionGlyph.SEND
|
||||
isListening -> ActionGlyph.STOP
|
||||
else -> ActionGlyph.VOICE
|
||||
@@ -517,8 +592,18 @@ fun ComposerToolbar(
|
||||
ActionGlyph.STOP -> {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Stop,
|
||||
contentDescription = "Stop listening",
|
||||
tint = if (isListening) MaterialTheme.colorScheme.onErrorContainer else palette.onAction,
|
||||
contentDescription =
|
||||
if (canInterrupt) {
|
||||
stringResource(R.string.chat_voice_stop_generating)
|
||||
} else {
|
||||
"Stop listening"
|
||||
},
|
||||
tint =
|
||||
if (isListening && !canInterrupt) {
|
||||
MaterialTheme.colorScheme.onErrorContainer
|
||||
} else {
|
||||
palette.onAction
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -632,3 +717,91 @@ fun buildReasoningLabel(
|
||||
}
|
||||
return reqLabel
|
||||
}
|
||||
|
||||
/**
|
||||
* Telegram-style mic gesture: press and hold past [HOLD_TO_RECORD_THRESHOLD_MS]
|
||||
* records a voice note, release sends it, and sliding away (left or up) from
|
||||
* the button cancels instead of sending. Short taps use the control's normal
|
||||
* click handler for dictation, so touch and accessibility actions share one
|
||||
* callback path.
|
||||
*
|
||||
* The whole down-to-up sequence lives in this one pointer loop, so the release
|
||||
* that submits can never be lost to a recomposition between press and
|
||||
* finger-up — an earlier interaction-source based approach could lose it and
|
||||
* leave the recorder running until some later tap "sent" the stale clip.
|
||||
*/
|
||||
private suspend fun PointerInputScope.micHoldHandler(
|
||||
isEnabled: () -> Boolean,
|
||||
onHoldStart: () -> Unit,
|
||||
onHoldEnd: () -> Unit,
|
||||
onHoldCancel: () -> Unit,
|
||||
) {
|
||||
val cancelSlop = MIC_SLIDE_CANCEL_DP.dp.toPx()
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown(requireUnconsumed = false)
|
||||
if (!isEnabled()) {
|
||||
return@awaitEachGesture
|
||||
}
|
||||
|
||||
fun slidAway(position: Offset): Boolean {
|
||||
val dx = position.x - down.position.x
|
||||
val dy = position.y - down.position.y
|
||||
return dx < -cancelSlop || dy < -cancelSlop
|
||||
}
|
||||
|
||||
// Phase 1 — a finger-up before the threshold is a plain tap.
|
||||
var earlyUp = false
|
||||
var gestureAborted = false
|
||||
withTimeoutOrNull(HOLD_TO_RECORD_THRESHOLD_MS) {
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
val change =
|
||||
event.changes.firstOrNull { it.id == down.id }
|
||||
?: run {
|
||||
gestureAborted = true
|
||||
return@withTimeoutOrNull
|
||||
}
|
||||
if (!change.pressed) {
|
||||
earlyUp = true
|
||||
return@withTimeoutOrNull
|
||||
}
|
||||
if (change.isConsumed || slidAway(change.position)) {
|
||||
gestureAborted = true
|
||||
return@withTimeoutOrNull
|
||||
}
|
||||
}
|
||||
}
|
||||
if (gestureAborted) {
|
||||
return@awaitEachGesture
|
||||
}
|
||||
if (earlyUp) {
|
||||
return@awaitEachGesture
|
||||
}
|
||||
|
||||
// Phase 2 — the threshold passed with the finger down: record until
|
||||
// the finger lifts (send) or slides away (cancel). No timeout here.
|
||||
onHoldStart()
|
||||
var send = false
|
||||
try {
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
val change = event.changes.firstOrNull { it.id == down.id } ?: break
|
||||
if (!change.pressed) {
|
||||
send = true
|
||||
break
|
||||
}
|
||||
if (change.isConsumed || slidAway(change.position)) {
|
||||
break
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (send) onHoldEnd() else onHoldCancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A press shorter than this is a tap; longer arms voice-note recording. */
|
||||
private const val HOLD_TO_RECORD_THRESHOLD_MS = 400L
|
||||
|
||||
/** Sliding this far from the mic button (left or up) cancels the recording. */
|
||||
private const val MIC_SLIDE_CANCEL_DP = 64
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
package com.m57.hermescontrol.ui.chat.components
|
||||
|
||||
import android.os.SystemClock
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.m57.hermescontrol.R
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
/**
|
||||
* Telegram-style recording strip shown in place of the input field while a
|
||||
* voice note is being recorded: a slide-to-cancel hint, an elapsed timer, and
|
||||
* a live mic level meter so the press and the captured audio are both visible
|
||||
* even when the mic button itself is under the thumb.
|
||||
*/
|
||||
@Composable
|
||||
internal fun VoiceNoteRecordingPanel(
|
||||
amplitude: State<Float>,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val palette = composerPalette()
|
||||
var elapsedSeconds by remember { mutableStateOf(0L) }
|
||||
LaunchedEffect(Unit) {
|
||||
val startedAt = SystemClock.elapsedRealtime()
|
||||
while (true) {
|
||||
elapsedSeconds = (SystemClock.elapsedRealtime() - startedAt) / 1000
|
||||
delay(200)
|
||||
}
|
||||
}
|
||||
val levels = remember { mutableStateListOf<Float>() }
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { amplitude.value }.collect { level ->
|
||||
levels.add(level)
|
||||
while (levels.size > VOICE_NOTE_BARS) {
|
||||
levels.removeAt(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(
|
||||
modifier =
|
||||
modifier
|
||||
.heightIn(min = 42.dp)
|
||||
.testTag("voice_note_recording_panel"),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
// Decorative: the adjacent "Slide to cancel" text carries the
|
||||
// instruction, so the arrow must not repeat it (review, PR #1250).
|
||||
contentDescription = null,
|
||||
tint = palette.placeholder,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.chat_voice_slide_to_cancel),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = palette.placeholder,
|
||||
maxLines = 1,
|
||||
)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(8.dp)
|
||||
.background(MaterialTheme.colorScheme.error, CircleShape),
|
||||
)
|
||||
Text(
|
||||
text = formatElapsed(elapsedSeconds),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = palette.text,
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.height(24.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
val filler = VOICE_NOTE_BARS - levels.size
|
||||
repeat(VOICE_NOTE_BARS) { index ->
|
||||
val level = if (index < filler) 0f else levels[index - filler]
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.width(3.dp)
|
||||
.height((4f + 18f * level).dp)
|
||||
.background(palette.placeholder, RoundedCornerShape(2.dp)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Number of level-meter bars in the recording panel. */
|
||||
private const val VOICE_NOTE_BARS = 14
|
||||
|
||||
/** Formats elapsed recording time as m:ss. */
|
||||
private fun formatElapsed(seconds: Long): String {
|
||||
val minutes = seconds / 60
|
||||
val secs = seconds % 60
|
||||
return "$minutes:${secs.toString().padStart(2, '0')}"
|
||||
}
|
||||
@@ -4,6 +4,9 @@
|
||||
<!-- Chat -->
|
||||
<string name="chat_send_desc">Send</string>
|
||||
<string name="chat_mic_listening">Listening…</string>
|
||||
<string name="chat_voice_record_failed">Could not start voice recording</string>
|
||||
<string name="chat_voice_slide_to_cancel">Slide to cancel</string>
|
||||
<string name="chat_voice_stop_generating">Stop generating</string>
|
||||
<string name="stt_permission_denied">Microphone permission is required for voice input</string>
|
||||
<string name="stt_not_available">Speech recognition is not available on this device</string>
|
||||
<string name="chat_send">Send</string>
|
||||
|
||||
@@ -2,8 +2,10 @@ package com.m57.hermescontrol.data.remote
|
||||
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.mockwebserver.Dispatcher
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
@@ -59,6 +61,7 @@ class ProfileScopeInterceptorTest {
|
||||
server.enqueue(MockResponse().setResponseCode(200).setBody("{}"))
|
||||
val builder = Request.Builder().url(server.url(path))
|
||||
when (method) {
|
||||
"POST" -> builder.post("{}".toRequestBody("application/json".toMediaType()))
|
||||
"DELETE" -> builder.delete()
|
||||
else -> builder.get()
|
||||
}
|
||||
@@ -269,4 +272,27 @@ class ProfileScopeInterceptorTest {
|
||||
assertEquals("work", url.queryParameter("profile"))
|
||||
assertEquals("/api/status/health", url.encodedPath)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun transcribeRoute_getsTheActiveNonDefaultProfile() {
|
||||
// Review (PR #1250): /api/audio/transcribe resolves STT through the
|
||||
// request's profile on the backend — the desktop sends this route
|
||||
// `...profileScoped()`. Unscoped, a multi-profile host would
|
||||
// transcribe under the launch profile instead of the user's pick.
|
||||
val client = clientFor("work")
|
||||
|
||||
val url = requestedUrl(client, "api/audio/transcribe", method = "POST")
|
||||
|
||||
assertEquals("work", url.queryParameter("profile"))
|
||||
assertEquals("/api/audio/transcribe", url.encodedPath)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun lookalikeAudioPath_notScoped() {
|
||||
val client = clientFor("work")
|
||||
|
||||
val url = requestedUrl(client, "api/audio-extras")
|
||||
|
||||
assertNull(url.queryParameter("profile"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package com.m57.hermescontrol.data.repository
|
||||
|
||||
import com.m57.hermescontrol.data.model.AudioTranscriptionRequest
|
||||
import com.m57.hermescontrol.data.model.AudioTranscriptionResponse
|
||||
import com.m57.hermescontrol.data.remote.HermesApiService
|
||||
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.mockk
|
||||
import io.mockk.slot
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import retrofit2.Response
|
||||
import java.io.File
|
||||
import java.net.SocketTimeoutException
|
||||
|
||||
class VoiceNoteRepositoryTest {
|
||||
private val api = mockk<HermesApiService>()
|
||||
|
||||
private fun tempVoiceFile(bytes: ByteArray = byteArrayOf(1)): File =
|
||||
File.createTempFile("voice-note-test", ".m4a").apply {
|
||||
writeBytes(bytes)
|
||||
deleteOnExit()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun transcribe_uploadsBase64DataUrlAndReturnsTrimmedTranscript() =
|
||||
runTest {
|
||||
val file = tempVoiceFile(byteArrayOf(1, 2, 3))
|
||||
val bodySlot = slot<AudioTranscriptionRequest>()
|
||||
coEvery { api.transcribeAudio(capture(bodySlot)) } returns
|
||||
Response.success(
|
||||
AudioTranscriptionResponse(ok = true, transcript = " hello world "),
|
||||
)
|
||||
|
||||
val result = VoiceNoteRepository { _ -> api }.transcribe(file)
|
||||
|
||||
val success = result as NetworkResult.Success
|
||||
assertEquals("hello world", success.data)
|
||||
assertEquals("audio/mp4", bodySlot.captured.mimeType)
|
||||
assertEquals("data:audio/mp4;base64,AQID", bodySlot.captured.dataUrl)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun transcribe_propagatesServerFailureWithoutRetrying() =
|
||||
runTest {
|
||||
val file = tempVoiceFile()
|
||||
coEvery { api.transcribeAudio(any()) } returns
|
||||
Response.error(422, "no speech".toResponseBody("text/plain".toMediaType()))
|
||||
|
||||
val result = VoiceNoteRepository { _ -> api }.transcribe(file)
|
||||
|
||||
assertTrue(result is NetworkResult.Failure)
|
||||
coVerify(exactly = 1) { api.transcribeAudio(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun transcribe_doesNotRetryThePostAfterASocketTimeout() =
|
||||
runTest {
|
||||
// safeApiCall's default policy retries socket timeouts and 5xx; a
|
||||
// blind re-POST would repeat provider STT work after the request
|
||||
// may already have reached the server (review, PR #1250).
|
||||
val file = tempVoiceFile()
|
||||
coEvery { api.transcribeAudio(any()) } throws SocketTimeoutException("stt timeout")
|
||||
|
||||
val result = VoiceNoteRepository { _ -> api }.transcribe(file)
|
||||
|
||||
assertTrue(result is NetworkResult.Failure)
|
||||
coVerify(exactly = 1) { api.transcribeAudio(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun transcribe_resolvesTheServiceAtCallTime_soAConnectionSwitchUsesTheNewService() =
|
||||
runTest {
|
||||
val firstApi = mockk<HermesApiService>()
|
||||
val secondApi = mockk<HermesApiService>()
|
||||
coEvery { firstApi.transcribeAudio(any()) } returns
|
||||
Response.success(AudioTranscriptionResponse(ok = true, transcript = "old server"))
|
||||
coEvery { secondApi.transcribeAudio(any()) } returns
|
||||
Response.success(AudioTranscriptionResponse(ok = true, transcript = "new server"))
|
||||
var currentApi = firstApi
|
||||
val repository = VoiceNoteRepository { _ -> currentApi }
|
||||
|
||||
val before = repository.transcribe(tempVoiceFile())
|
||||
// Simulate ApiClient.rebuild() after a connection/profile switch:
|
||||
// the next call must use the new service, not the captured one.
|
||||
currentApi = secondApi
|
||||
val after = repository.transcribe(tempVoiceFile())
|
||||
|
||||
assertEquals("old server", (before as NetworkResult.Success).data)
|
||||
assertEquals("new server", (after as NetworkResult.Success).data)
|
||||
coVerify(exactly = 1) { firstApi.transcribeAudio(any()) }
|
||||
coVerify(exactly = 1) { secondApi.transcribeAudio(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun transcribe_requestsTheDesktopParitySttTimeout() =
|
||||
runTest {
|
||||
val requestedTimeouts = mutableListOf<Long>()
|
||||
coEvery { api.transcribeAudio(any()) } returns
|
||||
Response.success(AudioTranscriptionResponse(ok = true, transcript = "ok"))
|
||||
|
||||
VoiceNoteRepository { timeout ->
|
||||
requestedTimeouts += timeout
|
||||
api
|
||||
}.transcribe(tempVoiceFile())
|
||||
|
||||
// A tiny clip must still get the 180s floor, not the shared 30s client.
|
||||
assertEquals(listOf(VoiceNoteRepository.MIN_REQUEST_TIMEOUT_MS), requestedTimeouts)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun transcriptionTimeoutMs_scalesWithPayloadThenClamps() {
|
||||
assertEquals(180_000L, VoiceNoteRepository.transcriptionTimeoutMs("data:audio/mp4;base64,AQID"))
|
||||
assertEquals(200_000L, VoiceNoteRepository.transcriptionTimeoutMs("x".repeat(2_000_000)))
|
||||
assertEquals(600_000L, VoiceNoteRepository.transcriptionTimeoutMs("x".repeat(10_000_000)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun transcribe_unreadableFileReturnsFailureWithoutUploading() =
|
||||
runTest {
|
||||
val missing = tempVoiceFile().apply { delete() }
|
||||
|
||||
val result = VoiceNoteRepository { _ -> api }.transcribe(missing)
|
||||
|
||||
assertTrue(result is NetworkResult.Failure)
|
||||
coVerify(exactly = 0) { api.transcribeAudio(any()) }
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import android.app.Application
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.local.DataScope
|
||||
import com.m57.hermescontrol.data.local.HermesDatabase
|
||||
import com.m57.hermescontrol.data.model.ActionResponse
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
@@ -76,6 +77,14 @@ class ChatUpdateCommandTest {
|
||||
every { android.util.Log.e(any(), any(), any()) } returns 0
|
||||
|
||||
mockkObject(AuthManager)
|
||||
// ChatModelSwitchDelegate.preloadModelOptions() reads pinned models
|
||||
// when the catalog load succeeds; without this stub the spy falls
|
||||
// through to the real AuthManager and throws "not initialized".
|
||||
every { AuthManager.getPinnedModels() } returns emptyList()
|
||||
// The delegate and the shared catalog store both collect this flow;
|
||||
// park them on a never-emitting state so a later real-AuthManager
|
||||
// emission cannot resume a stale Main-dispatched collector.
|
||||
every { AuthManager.dataScopeFlow } returns MutableStateFlow<DataScope?>(null)
|
||||
mockkObject(HermesWsClient)
|
||||
mockkObject(ApiClient)
|
||||
mockkObject(HermesDatabase)
|
||||
|
||||
@@ -8,9 +8,11 @@ import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.core.content.FileProvider
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.local.DataScope
|
||||
import com.m57.hermescontrol.data.local.HermesDatabase
|
||||
import com.m57.hermescontrol.data.model.Attachment
|
||||
import com.m57.hermescontrol.data.model.AttachmentSource
|
||||
import com.m57.hermescontrol.data.model.AudioTranscriptionResponse
|
||||
import com.m57.hermescontrol.data.model.BusySendMode
|
||||
import com.m57.hermescontrol.data.model.PaginationInfo
|
||||
import com.m57.hermescontrol.data.model.SessionMessage
|
||||
@@ -30,6 +32,7 @@ import com.m57.hermescontrol.data.ws.ConnectionOperationParser
|
||||
import com.m57.hermescontrol.data.ws.ConnectionStatus
|
||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||
import com.m57.hermescontrol.data.ws.JsonRpcError
|
||||
import com.m57.hermescontrol.data.ws.ModelCatalogStore
|
||||
import com.m57.hermescontrol.data.ws.WsEvent
|
||||
import com.m57.hermescontrol.data.ws.WsMethods
|
||||
import com.m57.hermescontrol.notification.TurnCorrelationTracker
|
||||
@@ -65,6 +68,7 @@ import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.put
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
@@ -75,6 +79,7 @@ import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import retrofit2.Response
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class ChatViewModelTest {
|
||||
@@ -298,6 +303,18 @@ class ChatViewModelTest {
|
||||
every { AuthManager.getToken() } returns "test-token"
|
||||
every { AuthManager.getBaseUrl() } returns "http://test.local/"
|
||||
every { AuthManager.getSelectedProfileId() } returns null
|
||||
// sendVoiceNote snapshots the data scope for its ownership check
|
||||
// (review, PR #1250). Default it to null: an unstubbed mockk call
|
||||
// used to throw and be swallowed by the runCatching callers, which
|
||||
// kept SwrCache keys unscoped in this class; returning a concrete
|
||||
// scope here enables cross-test cache hits and skips sends other
|
||||
// tests count. Scope-specific tests re-stub this with real scopes.
|
||||
every { AuthManager.currentDataScope() } returns null
|
||||
// ChatViewModel's model-switch delegate and the shared catalog store
|
||||
// both collect this flow; park them on a never-emitting state so a
|
||||
// later real-AuthManager emission cannot resume a stale
|
||||
// Main-dispatched collector between test classes.
|
||||
every { AuthManager.dataScopeFlow } returns MutableStateFlow<DataScope?>(null)
|
||||
mockkObject(ProfileSwitchCoordinator)
|
||||
every { ProfileSwitchCoordinator.switched } returns mockSwitchFlow
|
||||
every { ProfileSwitchCoordinator.connectionSwitched } returns MutableSharedFlow<String>()
|
||||
@@ -1098,6 +1115,13 @@ class ChatViewModelTest {
|
||||
runTest {
|
||||
val (viewModel, _) = createViewModelWithSession()
|
||||
|
||||
// The shared catalog store may still hold another class's cached
|
||||
// (possibly empty) state — reset it so the picker's load below
|
||||
// fetches through this class's stubbed API.
|
||||
ModelCatalogStore.shared.onScopeChanged(
|
||||
DataScope("preload-reset", "http://preload-reset.test/", AuthManager.DEFAULT_PROFILE_ID),
|
||||
)
|
||||
|
||||
// A bare "/model" must NOT dispatch a slash command; it opens the picker.
|
||||
viewModel.sendMessage("/model")
|
||||
advanceUntilIdle()
|
||||
@@ -9746,4 +9770,414 @@ class ChatViewModelTest {
|
||||
}
|
||||
assertNull(TurnCorrelationTracker.boundaryFor("default", sessionId))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sendVoiceNote_transcribesViaServerAndSubmitsTranscript() =
|
||||
runTest {
|
||||
TurnCorrelationTracker.resetForTest()
|
||||
stubActiveProfile()
|
||||
val api = mockk<com.m57.hermescontrol.data.remote.HermesApiService>(relaxed = true)
|
||||
every { ApiClient.hermesApi } returns api
|
||||
every { ApiClient.transcriptionService(any()) } returns api
|
||||
coEvery { api.transcribeAudio(any()) } returns
|
||||
Response.success(
|
||||
AudioTranscriptionResponse(ok = true, transcript = "hello from voice"),
|
||||
)
|
||||
val (viewModel, _) = createViewModelWithSession()
|
||||
|
||||
val submittedTexts = mutableListOf<String>()
|
||||
every { HermesWsClient.sendMessage(any(), any(), any(), any()) } answers {
|
||||
reqCount++
|
||||
val id = "req-msg-$reqCount"
|
||||
submittedTexts += arg<String>(1)
|
||||
arg<((String) -> Unit)?>(2)?.invoke(id)
|
||||
id
|
||||
}
|
||||
|
||||
val voiceFile =
|
||||
java.io.File(attachmentCacheDir, "voice_note.m4a").apply {
|
||||
writeBytes(byteArrayOf(1, 2, 3))
|
||||
}
|
||||
viewModel.sendVoiceNote(voiceFile)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals("hello from voice", submittedTexts.lastOrNull())
|
||||
assertFalse(voiceFile.exists())
|
||||
assertNull(viewModel.uiState.value.errorMessage)
|
||||
assertFalse(viewModel.uiState.value.isTranscribingVoiceNote)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sendVoiceNote_reportsTranscriptionFailureAndDeletesClip() =
|
||||
runTest {
|
||||
val api = mockk<com.m57.hermescontrol.data.remote.HermesApiService>(relaxed = true)
|
||||
every { ApiClient.hermesApi } returns api
|
||||
every { ApiClient.transcriptionService(any()) } returns api
|
||||
coEvery { api.transcribeAudio(any()) } returns
|
||||
Response.error(500, "transcribe failed".toResponseBody("text/plain".toMediaType()))
|
||||
val (viewModel, _) = createViewModelWithSession()
|
||||
|
||||
val voiceFile =
|
||||
java.io.File(attachmentCacheDir, "voice_fail.m4a").apply {
|
||||
writeBytes(byteArrayOf(9))
|
||||
}
|
||||
viewModel.sendVoiceNote(voiceFile)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertTrue(
|
||||
viewModel.uiState.value.errorMessage
|
||||
?.contains("Voice note transcription failed") == true,
|
||||
)
|
||||
assertFalse(voiceFile.exists())
|
||||
assertFalse(viewModel.uiState.value.isTranscribingVoiceNote)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sendVoiceNote_emptyTranscriptShowsNoSpeechAndDoesNotSubmit() =
|
||||
runTest {
|
||||
val api = mockk<com.m57.hermescontrol.data.remote.HermesApiService>(relaxed = true)
|
||||
every { ApiClient.hermesApi } returns api
|
||||
every { ApiClient.transcriptionService(any()) } returns api
|
||||
coEvery { api.transcribeAudio(any()) } returns
|
||||
Response.success(AudioTranscriptionResponse(ok = true, transcript = " "))
|
||||
val (viewModel, _) = createViewModelWithSession()
|
||||
|
||||
val voiceFile =
|
||||
java.io.File(attachmentCacheDir, "voice_silent.m4a").apply {
|
||||
writeBytes(byteArrayOf(0))
|
||||
}
|
||||
viewModel.sendVoiceNote(voiceFile)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertTrue(
|
||||
viewModel.uiState.value.errorMessage
|
||||
?.contains("No speech detected") == true,
|
||||
)
|
||||
verify(exactly = 0) { HermesWsClient.sendMessage(any(), any(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sendVoiceNote_whileDisconnectedDropsClipLocallyWithoutUploading() =
|
||||
runTest {
|
||||
val viewModel = createViewModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
val voiceFile =
|
||||
java.io.File(attachmentCacheDir, "voice_offline.m4a").apply {
|
||||
writeBytes(byteArrayOf(1))
|
||||
}
|
||||
viewModel.sendVoiceNote(voiceFile)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertFalse(voiceFile.exists())
|
||||
assertTrue(
|
||||
viewModel.uiState.value.errorMessage
|
||||
?.contains("not connected") == true,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sendVoiceNote_whenSessionChangesMidTranscription_keepsTheTranscriptInTheComposer() =
|
||||
runTest {
|
||||
stubActiveProfile()
|
||||
val api = mockk<com.m57.hermescontrol.data.remote.HermesApiService>(relaxed = true)
|
||||
every { ApiClient.hermesApi } returns api
|
||||
every { ApiClient.transcriptionService(any()) } returns api
|
||||
val transcription = CompletableDeferred<Response<AudioTranscriptionResponse>>()
|
||||
coEvery { api.transcribeAudio(any()) } coAnswers { transcription.await() }
|
||||
val (viewModel, _) = createViewModelWithSession()
|
||||
|
||||
val voiceFile =
|
||||
java.io.File(attachmentCacheDir, "voice_session_race.m4a").apply {
|
||||
writeBytes(byteArrayOf(7))
|
||||
}
|
||||
viewModel.sendVoiceNote(voiceFile)
|
||||
advanceUntilIdle()
|
||||
|
||||
// The user moves to a new session while STT is still running.
|
||||
viewModel.createNewSession()
|
||||
advanceUntilIdle()
|
||||
|
||||
transcription.complete(
|
||||
Response.success(AudioTranscriptionResponse(ok = true, transcript = "late transcript")),
|
||||
)
|
||||
advanceUntilIdle()
|
||||
|
||||
// The recording belonged to the old session: the transcript must
|
||||
// survive in the composer instead of landing in a session it was
|
||||
// never recorded for, or vanishing (review, PR #1250).
|
||||
assertEquals("late transcript", viewModel.uiState.value.composerTextToRestore)
|
||||
assertTrue(
|
||||
viewModel.uiState.value.errorMessage
|
||||
?.contains("transcript kept") == true,
|
||||
)
|
||||
verify(exactly = 0) { HermesWsClient.sendMessage(any(), any(), any(), any()) }
|
||||
assertFalse(viewModel.uiState.value.isTranscribingVoiceNote)
|
||||
assertFalse(voiceFile.exists())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sendVoiceNote_whenTheSendIsRejected_keepsTheTranscriptInTheComposer() =
|
||||
runTest {
|
||||
stubActiveProfile()
|
||||
val api = mockk<com.m57.hermescontrol.data.remote.HermesApiService>(relaxed = true)
|
||||
every { ApiClient.hermesApi } returns api
|
||||
every { ApiClient.transcriptionService(any()) } returns api
|
||||
coEvery { api.transcribeAudio(any()) } returns
|
||||
Response.success(AudioTranscriptionResponse(ok = true, transcript = "keep me"))
|
||||
val (viewModel, _) = createViewModelWithSession()
|
||||
|
||||
val voiceFile =
|
||||
java.io.File(attachmentCacheDir, "voice_send_race.m4a").apply {
|
||||
writeBytes(byteArrayOf(8))
|
||||
}
|
||||
viewModel.sendVoiceNote(voiceFile)
|
||||
// The connection drops while the transcript is being produced.
|
||||
mockConnectionStatus.value = ConnectionStatus.DISCONNECTED
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals("keep me", viewModel.uiState.value.composerTextToRestore)
|
||||
assertTrue(
|
||||
viewModel.uiState.value.errorMessage
|
||||
?.contains("transcript kept") == true,
|
||||
)
|
||||
verify(exactly = 0) { HermesWsClient.sendMessage(any(), any(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sendVoiceNote_whileATranscriptionIsInFlight_dropsTheSecondClip() =
|
||||
runTest {
|
||||
stubActiveProfile()
|
||||
val api = mockk<com.m57.hermescontrol.data.remote.HermesApiService>(relaxed = true)
|
||||
every { ApiClient.hermesApi } returns api
|
||||
every { ApiClient.transcriptionService(any()) } returns api
|
||||
val transcription = CompletableDeferred<Response<AudioTranscriptionResponse>>()
|
||||
coEvery { api.transcribeAudio(any()) } coAnswers { transcription.await() }
|
||||
val (viewModel, _) = createViewModelWithSession()
|
||||
|
||||
val submittedTexts = mutableListOf<String>()
|
||||
every { HermesWsClient.sendMessage(any(), any(), any(), any()) } answers {
|
||||
reqCount++
|
||||
val id = "req-msg-$reqCount"
|
||||
submittedTexts += arg<String>(1)
|
||||
arg<((String) -> Unit)?>(2)?.invoke(id)
|
||||
id
|
||||
}
|
||||
|
||||
val firstFile =
|
||||
java.io.File(attachmentCacheDir, "voice_first.m4a").apply { writeBytes(byteArrayOf(1)) }
|
||||
val secondFile =
|
||||
java.io.File(attachmentCacheDir, "voice_second.m4a").apply { writeBytes(byteArrayOf(2)) }
|
||||
viewModel.sendVoiceNote(firstFile)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.sendVoiceNote(secondFile)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Single-flight: the second clip is dropped without a second upload.
|
||||
assertFalse(secondFile.exists())
|
||||
coVerify(exactly = 1) { api.transcribeAudio(any()) }
|
||||
|
||||
transcription.complete(
|
||||
Response.success(AudioTranscriptionResponse(ok = true, transcript = "only once")),
|
||||
)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(listOf("only once"), submittedTexts)
|
||||
assertFalse(viewModel.uiState.value.isTranscribingVoiceNote)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sendVoiceNote_whenDataScopeChangesDuringTranscription_doesNotSendIntoNewScope() =
|
||||
runTest {
|
||||
stubActiveProfile()
|
||||
val api = mockk<com.m57.hermescontrol.data.remote.HermesApiService>(relaxed = true)
|
||||
every { ApiClient.hermesApi } returns api
|
||||
every { ApiClient.transcriptionService(any()) } returns api
|
||||
val transcription = CompletableDeferred<Response<AudioTranscriptionResponse>>()
|
||||
coEvery { api.transcribeAudio(any()) } coAnswers { transcription.await() }
|
||||
|
||||
every { AuthManager.currentDataScope() } returns
|
||||
DataScope(
|
||||
connectionProfileId = "alpha",
|
||||
baseUrl = "http://alpha.test/",
|
||||
activeProfileId = AuthManager.DEFAULT_PROFILE_ID,
|
||||
)
|
||||
|
||||
val viewModel = createViewModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
mockConnectionStatus.value = ConnectionStatus.CONNECTED
|
||||
// A pending SESSION_CREATE is all this test needs; skipping the
|
||||
// gateway preload keeps the shared ModelCatalogStore out of it.
|
||||
viewModel.createNewSession()
|
||||
advanceUntilIdle()
|
||||
|
||||
// SESSION_CREATE is still pending, so the storage session ID is
|
||||
// null — exactly the window where the session-ID snapshot alone
|
||||
// cannot detect a scope move.
|
||||
assertNull(viewModel.uiState.value.currentSessionId)
|
||||
|
||||
val voiceFile =
|
||||
java.io.File(attachmentCacheDir, "voice_scope_switch.m4a").apply {
|
||||
writeBytes(byteArrayOf(1, 2, 3))
|
||||
}
|
||||
viewModel.sendVoiceNote(voiceFile)
|
||||
advanceUntilIdle()
|
||||
assertTrue(viewModel.uiState.value.isTranscribingVoiceNote)
|
||||
|
||||
// The user switches to another server/profile while STT runs; the
|
||||
// replacement session create is pending as well, so both session
|
||||
// IDs can stay null/null and only the data scope moves.
|
||||
every { AuthManager.currentDataScope() } returns
|
||||
DataScope(
|
||||
connectionProfileId = "beta",
|
||||
baseUrl = "http://beta.test/",
|
||||
activeProfileId = AuthManager.DEFAULT_PROFILE_ID,
|
||||
)
|
||||
advanceUntilIdle()
|
||||
|
||||
transcription.complete(
|
||||
Response.success(
|
||||
AudioTranscriptionResponse(ok = true, transcript = "do not send this to the new server"),
|
||||
),
|
||||
)
|
||||
advanceUntilIdle()
|
||||
|
||||
// The transcript must not become the initial prompt of the new
|
||||
// scope; it stays in the composer instead (review, PR #1250).
|
||||
verify(exactly = 0) {
|
||||
HermesWsClient.sendMessage(any(), "do not send this to the new server", any(), any())
|
||||
}
|
||||
assertEquals(
|
||||
"do not send this to the new server",
|
||||
viewModel.uiState.value.composerTextToRestore,
|
||||
)
|
||||
assertTrue(
|
||||
viewModel.uiState.value.errorMessage
|
||||
?.contains("transcript kept") == true,
|
||||
)
|
||||
assertFalse(viewModel.uiState.value.isTranscribingVoiceNote)
|
||||
assertFalse(voiceFile.exists())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sendVoiceNote_whenServerProfileChangesDuringTranscription_doesNotSendIntoNewScope() =
|
||||
runTest {
|
||||
stubActiveProfile()
|
||||
val api = mockk<com.m57.hermescontrol.data.remote.HermesApiService>(relaxed = true)
|
||||
every { ApiClient.hermesApi } returns api
|
||||
every { ApiClient.transcriptionService(any()) } returns api
|
||||
val transcription = CompletableDeferred<Response<AudioTranscriptionResponse>>()
|
||||
coEvery { api.transcribeAudio(any()) } coAnswers { transcription.await() }
|
||||
|
||||
every { AuthManager.currentDataScope() } returns
|
||||
DataScope(
|
||||
connectionProfileId = "alpha",
|
||||
baseUrl = "http://alpha.test/",
|
||||
activeProfileId = "default",
|
||||
)
|
||||
|
||||
val viewModel = createViewModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
mockConnectionStatus.value = ConnectionStatus.CONNECTED
|
||||
// A pending SESSION_CREATE is all this test needs; skipping the
|
||||
// gateway preload keeps the shared ModelCatalogStore out of it.
|
||||
viewModel.createNewSession()
|
||||
advanceUntilIdle()
|
||||
|
||||
val voiceFile =
|
||||
java.io.File(attachmentCacheDir, "voice_profile_switch.m4a").apply {
|
||||
writeBytes(byteArrayOf(4, 5, 6))
|
||||
}
|
||||
viewModel.sendVoiceNote(voiceFile)
|
||||
advanceUntilIdle()
|
||||
assertTrue(viewModel.uiState.value.isTranscribingVoiceNote)
|
||||
|
||||
// Same server, different server-side Hermes profile: the session
|
||||
// IDs cannot see this change either, so the scope must.
|
||||
every { AuthManager.currentDataScope() } returns
|
||||
DataScope(
|
||||
connectionProfileId = "alpha",
|
||||
baseUrl = "http://alpha.test/",
|
||||
activeProfileId = "work",
|
||||
)
|
||||
advanceUntilIdle()
|
||||
|
||||
transcription.complete(
|
||||
Response.success(
|
||||
AudioTranscriptionResponse(ok = true, transcript = "profile-scoped transcript"),
|
||||
),
|
||||
)
|
||||
advanceUntilIdle()
|
||||
|
||||
verify(exactly = 0) {
|
||||
HermesWsClient.sendMessage(any(), "profile-scoped transcript", any(), any())
|
||||
}
|
||||
assertEquals(
|
||||
"profile-scoped transcript",
|
||||
viewModel.uiState.value.composerTextToRestore,
|
||||
)
|
||||
assertTrue(
|
||||
viewModel.uiState.value.errorMessage
|
||||
?.contains("transcript kept") == true,
|
||||
)
|
||||
assertFalse(viewModel.uiState.value.isTranscribingVoiceNote)
|
||||
assertFalse(voiceFile.exists())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun canInterrupt_staysFalseWhileTypingWithNoRuntimeSession_thenTracksTheGeneration() =
|
||||
runTest {
|
||||
stubActiveProfile()
|
||||
val vm = createViewModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
mockConnectionStatus.value = ConnectionStatus.CONNECTED
|
||||
mockEventsFlow.emit(WsEvent.GatewayReady(null))
|
||||
advanceUntilIdle()
|
||||
|
||||
val sentPrompts = mutableListOf<String>()
|
||||
every { HermesWsClient.sendMessage(any(), any(), any(), any()) } answers {
|
||||
reqCount++
|
||||
val id = "req-msg-$reqCount"
|
||||
sentPrompts += arg<String>(1)
|
||||
arg<((String) -> Unit)?>(2)?.invoke(id)
|
||||
id
|
||||
}
|
||||
|
||||
// Queued while session create is in flight: sending is pending,
|
||||
// but the session has no runtime to interrupt yet, so Stop must stay
|
||||
// hidden and interruptSession() must not be reachable from it
|
||||
// (review, PR #1250).
|
||||
vm.sendMessage("held under session preparation")
|
||||
advanceUntilIdle()
|
||||
assertTrue(vm.uiState.value.isSending)
|
||||
assertFalse(vm.uiState.value.canInterrupt)
|
||||
|
||||
// Once the runtime session exists and the prompt is dispatched,
|
||||
// the running generation is interruptible. Read the captured
|
||||
// session.create id instead of counting sends: a singleton store
|
||||
// warmed by an earlier test can fire an extra model.options send
|
||||
// in this gateway flow and shift the counter (see the resume
|
||||
// tests, same pattern).
|
||||
val createReqId =
|
||||
sentRequestMethods.last { it.first == WsMethods.SESSION_CREATE }.second
|
||||
mockEventsFlow.emit(
|
||||
WsEvent.RpcResult(
|
||||
createReqId,
|
||||
mapOf("session_id" to "session-969", "stored_session_id" to "session-storage-969"),
|
||||
),
|
||||
)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(listOf("held under session preparation"), sentPrompts)
|
||||
// A successful prompt.submit acknowledgement only means the
|
||||
// gateway accepted it; the generation is interruptible only once
|
||||
// the runtime reports message.start for its session.
|
||||
mockEventsFlow.emit(WsEvent.MessageStart("session-969"))
|
||||
advanceUntilIdle()
|
||||
assertTrue(vm.uiState.value.canInterrupt)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import android.app.Application
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.local.DataScope
|
||||
import com.m57.hermescontrol.data.local.HermesDatabase
|
||||
import com.m57.hermescontrol.data.model.PaginationInfo
|
||||
import com.m57.hermescontrol.data.model.SessionMessagesResponse
|
||||
@@ -77,6 +78,14 @@ class SlashCommandDispatchRpcTest {
|
||||
// COMMAND_DISPATCH captures in suite order). setMain alone is safe —
|
||||
// the slash-dispatch send path is synchronous (no IO hop).
|
||||
mockkObject(AuthManager)
|
||||
// ChatModelSwitchDelegate.preloadModelOptions() reads pinned models
|
||||
// when the catalog load succeeds; without this stub the spy falls
|
||||
// through to the real AuthManager and throws "not initialized".
|
||||
every { AuthManager.getPinnedModels() } returns emptyList()
|
||||
// The delegate and the shared catalog store both collect this flow;
|
||||
// park them on a never-emitting state so a later real-AuthManager
|
||||
// emission cannot resume a stale Main-dispatched collector.
|
||||
every { AuthManager.dataScopeFlow } returns MutableStateFlow<DataScope?>(null)
|
||||
mockkObject(HermesWsClient)
|
||||
mockkObject(ApiClient)
|
||||
mockkObject(HermesDatabase)
|
||||
@@ -459,7 +468,11 @@ class SlashCommandDispatchRpcTest {
|
||||
every {
|
||||
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any())
|
||||
} answers {
|
||||
val m = methodCalls.last()
|
||||
// Read the method from THIS invocation, not from the shared
|
||||
// capture list: a concurrent request() landing between capture
|
||||
// and answer can leave .last() pointing at another call (CI:
|
||||
// "Value not yet captured" for the submitted prompt).
|
||||
val m = arg<String>(0)
|
||||
val d = CompletableDeferred<Any?>()
|
||||
if (m == WsMethods.COMMAND_DISPATCH) {
|
||||
// Backend rejects /status with the registry-miss 4018 (issue #576).
|
||||
@@ -729,7 +742,9 @@ class SlashCommandDispatchRpcTest {
|
||||
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any())
|
||||
} answers {
|
||||
val d = CompletableDeferred<Any?>()
|
||||
if (methodCalls.last() == WsMethods.COMMAND_DISPATCH) {
|
||||
// Read the method from THIS invocation, not from the shared
|
||||
// capture list (see the 4018 test above).
|
||||
if (arg<String>(0) == WsMethods.COMMAND_DISPATCH) {
|
||||
// Backend /init returns type:"send" with the AGENTS.md prompt.
|
||||
d.complete(mapOf("type" to "send", "message" to "Scan this repo and write AGENTS.md"))
|
||||
} else {
|
||||
|
||||
Reference in new issue
Block a user