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 = {},
|
onMicTap = {},
|
||||||
isListening = false,
|
isListening = false,
|
||||||
isAgentTyping = true,
|
isAgentTyping = true,
|
||||||
|
canInterrupt = ready.value,
|
||||||
isConnected = true,
|
isConnected = true,
|
||||||
commandCatalog = CommandCatalog(),
|
commandCatalog = CommandCatalog(),
|
||||||
isSessionReady = ready.value,
|
isSessionReady = ready.value,
|
||||||
@@ -42,13 +43,20 @@ class ComposerReadinessTest {
|
|||||||
compose.onNodeWithTag("chat_input").performTextInput("held draft")
|
compose.onNodeWithTag("chat_input").performTextInput("held draft")
|
||||||
compose.onNodeWithTag("chat_input").assertTextEquals("held draft")
|
compose.onNodeWithTag("chat_input").assertTextEquals("held draft")
|
||||||
compose.onNodeWithTag("chat_session_preparing").assertExists()
|
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.onNodeWithTag("send_button").assertIsNotEnabled().performClick()
|
||||||
compose.runOnIdle { assertEquals(0, sends) }
|
compose.runOnIdle { assertEquals(0, sends) }
|
||||||
compose.runOnIdle { ready.value = true }
|
compose.runOnIdle { ready.value = true }
|
||||||
compose.onNodeWithTag("chat_session_preparing").assertDoesNotExist()
|
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.onNodeWithTag("send_button").assertIsEnabled().performClick()
|
||||||
compose.runOnIdle { assertEquals(1, sends) }
|
compose.runOnIdle { assertEquals(1, sends) }
|
||||||
compose.runOnIdle { ready.value = false }
|
compose.runOnIdle { ready.value = false }
|
||||||
|
compose.onNodeWithTag("stop_button").assertDoesNotExist()
|
||||||
compose.onNodeWithTag("send_button").assertIsNotEnabled()
|
compose.onNodeWithTag("send_button").assertIsNotEnabled()
|
||||||
compose.onNodeWithTag("chat_input").assertTextEquals("held draft")
|
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 com.m57.hermescontrol.data.local.AuthManager
|
||||||
import okhttp3.Interceptor
|
import okhttp3.Interceptor
|
||||||
import okhttp3.MediaType.Companion.toMediaType
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.logging.HttpLoggingInterceptor
|
import okhttp3.logging.HttpLoggingInterceptor
|
||||||
import retrofit2.Retrofit
|
import retrofit2.Retrofit
|
||||||
import retrofit2.converter.kotlinx.serialization.asConverterFactory
|
import retrofit2.converter.kotlinx.serialization.asConverterFactory
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Provides a Retrofit-backed [HermesApiService].
|
* Provides a Retrofit-backed [HermesApiService].
|
||||||
@@ -22,6 +24,9 @@ object ApiClient {
|
|||||||
@Volatile
|
@Volatile
|
||||||
private var service: HermesApiService? = null
|
private var service: HermesApiService? = null
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var okHttpClient: OkHttpClient? = null
|
||||||
|
|
||||||
/** The current [HermesApiService] instance. Lazily created on first access. */
|
/** The current [HermesApiService] instance. Lazily created on first access. */
|
||||||
val hermesApi: HermesApiService
|
val hermesApi: HermesApiService
|
||||||
get() {
|
get() {
|
||||||
@@ -39,6 +44,7 @@ object ApiClient {
|
|||||||
synchronized(this) {
|
synchronized(this) {
|
||||||
retrofit = null
|
retrofit = null
|
||||||
service = null
|
service = null
|
||||||
|
okHttpClient = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,6 +87,64 @@ object ApiClient {
|
|||||||
return tempRetrofit.create(HermesApiService::class.java)
|
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 ─────────────────────────────────────────────────────────
|
// ── Internal ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private fun buildService(): HermesApiService =
|
private fun buildService(): HermesApiService =
|
||||||
@@ -111,25 +175,7 @@ object ApiClient {
|
|||||||
// must NOT stamp one here — otherwise every REST tab (skills, cron,
|
// must NOT stamp one here — otherwise every REST tab (skills, cron,
|
||||||
// config, ...) fails with "token expired" while the WS chat, which
|
// config, ...) fails with "token expired" while the WS chat, which
|
||||||
// authenticates via ?ticket=, keeps working.
|
// authenticates via ?ticket=, keeps working.
|
||||||
val authInterceptor =
|
val authInterceptor = restAuthInterceptor()
|
||||||
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 okHttp =
|
val okHttp =
|
||||||
OkHttpProvider
|
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.AddMcpServerRequest
|
||||||
import com.m57.hermescontrol.data.model.AgentPluginInstallBody
|
import com.m57.hermescontrol.data.model.AgentPluginInstallBody
|
||||||
import com.m57.hermescontrol.data.model.AnalyticsResponse
|
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.AuxiliaryModelsResponse
|
||||||
import com.m57.hermescontrol.data.model.BackupTriggerRequest
|
import com.m57.hermescontrol.data.model.BackupTriggerRequest
|
||||||
import com.m57.hermescontrol.data.model.BulkDeleteRequest
|
import com.m57.hermescontrol.data.model.BulkDeleteRequest
|
||||||
@@ -1030,4 +1032,14 @@ interface HermesApiService : KanbanApiService {
|
|||||||
suspend fun deleteManagedFile(
|
suspend fun deleteManagedFile(
|
||||||
@Body body: ManagedFileDelete,
|
@Body body: ManagedFileDelete,
|
||||||
): Response<ManagedFileActionResponse>
|
): 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 =
|
private val PROFILE_SCOPED_PREFIXES =
|
||||||
listOf(
|
listOf(
|
||||||
"/api/analytics",
|
"/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/config",
|
||||||
"/api/credentials/pool",
|
"/api/credentials/pool",
|
||||||
"/api/cron",
|
"/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 ->
|
onAddAttachments = { attachments ->
|
||||||
viewModel.addAttachments(attachments)
|
viewModel.addAttachments(attachments)
|
||||||
},
|
},
|
||||||
|
onVoiceNoteRecorded = { file ->
|
||||||
|
viewModel.sendVoiceNote(file)
|
||||||
|
},
|
||||||
onShowMessage = { msg ->
|
onShowMessage = { msg ->
|
||||||
scrollScope.launch {
|
scrollScope.launch {
|
||||||
snackbarHostState.showSnackbar(msg)
|
snackbarHostState.showSnackbar(msg)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
launchExternalActivity = launchExternalActivity,
|
launchExternalActivity = launchExternalActivity,
|
||||||
|
isTranscribingVoiceNote = state.isTranscribingVoiceNote,
|
||||||
context = context,
|
context = context,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -901,9 +905,16 @@ fun ChatScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onMicTap = mediaLaunchers.onMicTap,
|
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,
|
isAgentTyping = state.isAgentTyping,
|
||||||
isMainTurnBusy = state.isMainTurnBusy,
|
isMainTurnBusy = state.isMainTurnBusy,
|
||||||
|
canInterrupt = state.canInterrupt,
|
||||||
isConnected = state.isConnected,
|
isConnected = state.isConnected,
|
||||||
isSessionReady = state.isSessionReady && !timelineState.isHistorical,
|
isSessionReady = state.isSessionReady && !timelineState.isHistorical,
|
||||||
sessionPreparationFailed = state.resumeError != null,
|
sessionPreparationFailed = state.resumeError != null,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import android.util.Log
|
|||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.m57.hermescontrol.data.local.AuthManager
|
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.HermesDatabase
|
||||||
import com.m57.hermescontrol.data.local.SlashUsageStore
|
import com.m57.hermescontrol.data.local.SlashUsageStore
|
||||||
import com.m57.hermescontrol.data.model.Attachment
|
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.NetworkResult
|
||||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||||
import com.m57.hermescontrol.data.remote.safeApiCall
|
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.ActiveSessionHolder
|
||||||
import com.m57.hermescontrol.data.session.ProfileSwitchCoordinator
|
import com.m57.hermescontrol.data.session.ProfileSwitchCoordinator
|
||||||
import com.m57.hermescontrol.data.ws.CommandBlocklist
|
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 MESSAGE_PAGE_SIZE = 150
|
||||||
private const val TIMELINE_PAGE_SIZE = 500
|
private const val TIMELINE_PAGE_SIZE = 500
|
||||||
private const val HISTORY_WINDOW_SIZE = 120
|
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 =
|
private val REASONING_EFFORT_LEVELS =
|
||||||
setOf("none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra")
|
setOf("none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra")
|
||||||
@@ -135,6 +142,8 @@ data class ChatUiState(
|
|||||||
val pendingSends: List<PendingSend> = emptyList(),
|
val pendingSends: List<PendingSend> = emptyList(),
|
||||||
val isThinking: Boolean = false,
|
val isThinking: Boolean = false,
|
||||||
val thinkingText: String = "",
|
val thinkingText: String = "",
|
||||||
|
/** True while a recorded voice note uploads for server-side transcription. */
|
||||||
|
val isTranscribingVoiceNote: Boolean = false,
|
||||||
val isLoading: Boolean = false,
|
val isLoading: Boolean = false,
|
||||||
val isLoadingOlder: Boolean = false,
|
val isLoadingOlder: Boolean = false,
|
||||||
val hasOlderMessages: Boolean = false,
|
val hasOlderMessages: Boolean = false,
|
||||||
@@ -255,6 +264,15 @@ data class ChatUiState(
|
|||||||
) {
|
) {
|
||||||
/** Convenience — derived from [connectionStatus]. */
|
/** Convenience — derived from [connectionStatus]. */
|
||||||
val isConnected: Boolean get() = connectionStatus == ConnectionStatus.CONNECTED
|
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(
|
data class ChatTimelineState(
|
||||||
@@ -414,6 +432,7 @@ class ChatViewModel(
|
|||||||
private val ioDispatcher: kotlinx.coroutines.CoroutineDispatcher = kotlinx.coroutines.Dispatchers.IO,
|
private val ioDispatcher: kotlinx.coroutines.CoroutineDispatcher = kotlinx.coroutines.Dispatchers.IO,
|
||||||
private val historyDispatcher: kotlinx.coroutines.CoroutineDispatcher = searchDispatcher,
|
private val historyDispatcher: kotlinx.coroutines.CoroutineDispatcher = searchDispatcher,
|
||||||
private val sendStore: ChatSendStore = ChatSendStore(application),
|
private val sendStore: ChatSendStore = ChatSendStore(application),
|
||||||
|
private val voiceNoteRepository: VoiceNoteRepository = VoiceNoteRepository(),
|
||||||
) : AndroidViewModel(application) {
|
) : AndroidViewModel(application) {
|
||||||
constructor(application: Application) : this(application, startCleanup = true)
|
constructor(application: Application) : this(application, startCleanup = true)
|
||||||
|
|
||||||
@@ -2874,6 +2893,113 @@ class ChatViewModel(
|
|||||||
return true
|
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 =
|
private fun canSubmitMessage(): Boolean =
|
||||||
_uiState.value.pendingReasoningLevel == null &&
|
_uiState.value.pendingReasoningLevel == null &&
|
||||||
wsClient.connectionStatus.value == ConnectionStatus.CONNECTED &&
|
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.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.State
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -83,6 +84,7 @@ fun ChatInputBar(
|
|||||||
isListening: Boolean,
|
isListening: Boolean,
|
||||||
isAgentTyping: Boolean,
|
isAgentTyping: Boolean,
|
||||||
isMainTurnBusy: Boolean = false,
|
isMainTurnBusy: Boolean = false,
|
||||||
|
canInterrupt: Boolean = false,
|
||||||
isConnected: Boolean,
|
isConnected: Boolean,
|
||||||
commandCatalog: CommandCatalog,
|
commandCatalog: CommandCatalog,
|
||||||
isSessionReady: Boolean,
|
isSessionReady: Boolean,
|
||||||
@@ -108,6 +110,12 @@ fun ChatInputBar(
|
|||||||
showModelProvider: Boolean = false,
|
showModelProvider: Boolean = false,
|
||||||
reasoningWireLevel: String? = null,
|
reasoningWireLevel: String? = null,
|
||||||
pendingReasoningLevel: 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
|
// 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
|
// gateway's prompt.submit busy-input policy queues it as the next turn
|
||||||
@@ -269,55 +277,64 @@ fun ChatInputBar(
|
|||||||
}
|
}
|
||||||
val isInputRtl = inputLayoutDirection == LayoutDirection.Rtl
|
val isInputRtl = inputLayoutDirection == LayoutDirection.Rtl
|
||||||
|
|
||||||
CompositionLocalProvider(LocalLayoutDirection provides inputLayoutDirection) {
|
if (isRecordingVoice) {
|
||||||
BasicTextField(
|
VoiceNoteRecordingPanel(
|
||||||
value = inputFieldValue,
|
amplitude = voiceNoteAmplitude,
|
||||||
onValueChange = onInputChange,
|
modifier = Modifier.weight(1f),
|
||||||
modifier =
|
)
|
||||||
Modifier
|
} else {
|
||||||
.weight(1f)
|
CompositionLocalProvider(LocalLayoutDirection provides inputLayoutDirection) {
|
||||||
.heightIn(min = 42.dp, max = 200.dp)
|
BasicTextField(
|
||||||
.padding(vertical = 4.dp)
|
value = inputFieldValue,
|
||||||
.testTag("chat_input"),
|
onValueChange = onInputChange,
|
||||||
enabled = isConnected,
|
modifier =
|
||||||
textStyle =
|
Modifier
|
||||||
MaterialTheme.typography.bodyLarge.copy(
|
.weight(1f)
|
||||||
color = palette.text,
|
.heightIn(min = 42.dp, max = 200.dp)
|
||||||
textAlign = if (isInputRtl) TextAlign.Right else TextAlign.Left,
|
.padding(vertical = 4.dp)
|
||||||
textDirection = if (isInputRtl) TextDirection.Rtl else TextDirection.Ltr,
|
.testTag("chat_input"),
|
||||||
),
|
enabled = isConnected,
|
||||||
singleLine = false,
|
textStyle =
|
||||||
maxLines = 8,
|
MaterialTheme.typography.bodyLarge.copy(
|
||||||
cursorBrush = SolidColor(palette.text),
|
color = palette.text,
|
||||||
decorationBox = { innerTextField ->
|
textAlign = if (isInputRtl) TextAlign.Right else TextAlign.Left,
|
||||||
CompositionLocalProvider(LocalLayoutDirection provides ambientLayoutDirection) {
|
textDirection = if (isInputRtl) TextDirection.Rtl else TextDirection.Ltr,
|
||||||
Box(
|
),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
singleLine = false,
|
||||||
contentAlignment =
|
maxLines = 8,
|
||||||
if (isInputRtl) {
|
cursorBrush = SolidColor(palette.text),
|
||||||
Alignment.CenterEnd
|
decorationBox = { innerTextField ->
|
||||||
} else {
|
CompositionLocalProvider(LocalLayoutDirection provides ambientLayoutDirection) {
|
||||||
Alignment.CenterStart
|
Box(
|
||||||
},
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
contentAlignment =
|
||||||
CompositionLocalProvider(LocalLayoutDirection provides inputLayoutDirection) {
|
if (isInputRtl) {
|
||||||
if (inputFieldValue.text.isEmpty()) {
|
Alignment.CenterEnd
|
||||||
Text(
|
} else {
|
||||||
text = placeholderText,
|
Alignment.CenterStart
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
},
|
||||||
textAlign = if (isInputRtl) TextAlign.Right else TextAlign.Left,
|
) {
|
||||||
color = palette.placeholder,
|
CompositionLocalProvider(
|
||||||
maxLines = 1,
|
LocalLayoutDirection provides inputLayoutDirection,
|
||||||
overflow = TextOverflow.Ellipsis,
|
) {
|
||||||
modifier = Modifier.fillMaxWidth(),
|
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,
|
onSend = onSend,
|
||||||
onQueue = { onBusySend(BusySendMode.QUEUE) },
|
onQueue = { onBusySend(BusySendMode.QUEUE) },
|
||||||
onStopAndSend = { onBusySend(BusySendMode.INTERRUPT) },
|
onStopAndSend = { onBusySend(BusySendMode.INTERRUPT) },
|
||||||
|
canInterrupt = canInterrupt,
|
||||||
|
onStopGeneration = onStopGeneration,
|
||||||
onAttachTap = { showAttachmentTray = !showAttachmentTray },
|
onAttachTap = { showAttachmentTray = !showAttachmentTray },
|
||||||
onModelTap = onModelTap,
|
onModelTap = onModelTap,
|
||||||
onReasoningSelected = onReasoningTap,
|
onReasoningSelected = onReasoningTap,
|
||||||
onMicTap = onMicTap,
|
onMicTap = onMicTap,
|
||||||
|
onMicHoldStart = onMicHoldStart,
|
||||||
|
onMicHoldEnd = onMicHoldEnd,
|
||||||
|
onMicHoldCancel = onMicHoldCancel,
|
||||||
modifier = Modifier.testTag("chat_composer_toolbar"),
|
modifier = Modifier.testTag("chat_composer_toolbar"),
|
||||||
canDisableReasoning = canDisableReasoning,
|
canDisableReasoning = canDisableReasoning,
|
||||||
supportsReasoning = supportsReasoning,
|
supportsReasoning = supportsReasoning,
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ import android.util.Log
|
|||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.runtime.Composable
|
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.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
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.data.model.Attachment
|
||||||
import com.m57.hermescontrol.ui.chat.ChatInputPolicy
|
import com.m57.hermescontrol.ui.chat.ChatInputPolicy
|
||||||
import com.m57.hermescontrol.ui.chat.SpeechInputHelper
|
import com.m57.hermescontrol.ui.chat.SpeechInputHelper
|
||||||
|
import com.m57.hermescontrol.ui.chat.VoiceNoteRecorder
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
@@ -34,10 +39,15 @@ import java.util.Locale
|
|||||||
|
|
||||||
class ChatMediaLaunchers(
|
class ChatMediaLaunchers(
|
||||||
val isListening: Boolean,
|
val isListening: Boolean,
|
||||||
|
val isRecordingVoice: Boolean,
|
||||||
|
val voiceNoteAmplitude: State<Float>,
|
||||||
val onMicTap: () -> Unit,
|
val onMicTap: () -> Unit,
|
||||||
val onCameraTap: () -> Unit,
|
val onCameraTap: () -> Unit,
|
||||||
val onImageTap: () -> Unit,
|
val onImageTap: () -> Unit,
|
||||||
val onFileTap: () -> Unit,
|
val onFileTap: () -> Unit,
|
||||||
|
val onMicHoldStart: () -> Unit = {},
|
||||||
|
val onMicHoldEnd: () -> Unit = {},
|
||||||
|
val onMicHoldCancel: () -> Unit = {},
|
||||||
)
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -46,8 +56,10 @@ fun rememberChatMediaLaunchers(
|
|||||||
onInputFieldValueChange: (TextFieldValue) -> Unit,
|
onInputFieldValueChange: (TextFieldValue) -> Unit,
|
||||||
onAddAttachment: (uri: String, name: String, mimeType: String, size: Long) -> Unit,
|
onAddAttachment: (uri: String, name: String, mimeType: String, size: Long) -> Unit,
|
||||||
onAddAttachments: (List<Attachment>) -> Unit,
|
onAddAttachments: (List<Attachment>) -> Unit,
|
||||||
|
onVoiceNoteRecorded: (file: File) -> Unit,
|
||||||
onShowMessage: (String) -> Unit,
|
onShowMessage: (String) -> Unit,
|
||||||
launchExternalActivity: (() -> Unit) -> Unit,
|
launchExternalActivity: (() -> Unit) -> Unit,
|
||||||
|
isTranscribingVoiceNote: Boolean,
|
||||||
context: Context = LocalContext.current,
|
context: Context = LocalContext.current,
|
||||||
): ChatMediaLaunchers {
|
): ChatMediaLaunchers {
|
||||||
var isListening by remember { mutableStateOf(false) }
|
var isListening by remember { mutableStateOf(false) }
|
||||||
@@ -57,6 +69,7 @@ fun rememberChatMediaLaunchers(
|
|||||||
val currentOnInputFieldValueChange by rememberUpdatedState(onInputFieldValueChange)
|
val currentOnInputFieldValueChange by rememberUpdatedState(onInputFieldValueChange)
|
||||||
val currentOnAddAttachment by rememberUpdatedState(onAddAttachment)
|
val currentOnAddAttachment by rememberUpdatedState(onAddAttachment)
|
||||||
val currentOnAddAttachments by rememberUpdatedState(onAddAttachments)
|
val currentOnAddAttachments by rememberUpdatedState(onAddAttachments)
|
||||||
|
val currentOnVoiceNoteRecorded by rememberUpdatedState(onVoiceNoteRecorded)
|
||||||
val currentOnShowMessage by rememberUpdatedState(onShowMessage)
|
val currentOnShowMessage by rememberUpdatedState(onShowMessage)
|
||||||
val currentLaunchExternalActivity by rememberUpdatedState(launchExternalActivity)
|
val currentLaunchExternalActivity by rememberUpdatedState(launchExternalActivity)
|
||||||
|
|
||||||
@@ -64,6 +77,59 @@ fun rememberChatMediaLaunchers(
|
|||||||
val sttNotAvailableMsg = stringResource(R.string.stt_not_available)
|
val sttNotAvailableMsg = stringResource(R.string.stt_not_available)
|
||||||
val sttPermissionDeniedMsg = stringResource(R.string.stt_permission_denied)
|
val sttPermissionDeniedMsg = stringResource(R.string.stt_permission_denied)
|
||||||
val cameraErrorMsg = stringResource(R.string.chat_camera_error)
|
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)
|
// Speech-to-text recognition launcher (issue #194)
|
||||||
val speechLauncher =
|
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)
|
// Multi-file picker for attachments (issue #195)
|
||||||
val filePickerLauncher =
|
val filePickerLauncher =
|
||||||
rememberLauncherForActivityResult(
|
rememberLauncherForActivityResult(
|
||||||
@@ -183,7 +261,14 @@ fun rememberChatMediaLaunchers(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val onMicTap: () -> Unit = {
|
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
|
isListening = false
|
||||||
} else if (
|
} else if (
|
||||||
ContextCompat.checkSelfPermission(
|
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 = {
|
val onCameraTap: () -> Unit = {
|
||||||
try {
|
try {
|
||||||
val timeStamp =
|
val timeStamp =
|
||||||
@@ -245,13 +369,18 @@ fun rememberChatMediaLaunchers(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return remember(isListening) {
|
return remember(isListening, isRecordingVoice, isTranscribingVoiceNote) {
|
||||||
ChatMediaLaunchers(
|
ChatMediaLaunchers(
|
||||||
isListening = isListening,
|
isListening = isListening || isRecordingVoice,
|
||||||
|
isRecordingVoice = isRecordingVoice,
|
||||||
|
voiceNoteAmplitude = voiceNoteAmplitude,
|
||||||
onMicTap = onMicTap,
|
onMicTap = onMicTap,
|
||||||
onCameraTap = onCameraTap,
|
onCameraTap = onCameraTap,
|
||||||
onImageTap = onImageTap,
|
onImageTap = onImageTap,
|
||||||
onFileTap = onFileTap,
|
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.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.combinedClickable
|
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.horizontalScroll
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@@ -51,12 +53,16 @@ import androidx.compose.runtime.LaunchedEffect
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.draw.clipToBounds
|
import androidx.compose.ui.draw.clipToBounds
|
||||||
import androidx.compose.ui.draw.scale
|
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.platform.testTag
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.semantics.CustomAccessibilityAction
|
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.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.m57.hermescontrol.R
|
import com.m57.hermescontrol.R
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bottom controls row for the chat composer, rendered inside the composer card.
|
* Bottom controls row for the chat composer, rendered inside the composer card.
|
||||||
@@ -116,12 +123,50 @@ fun ComposerToolbar(
|
|||||||
reasoningWireLevel: String? = null,
|
reasoningWireLevel: String? = null,
|
||||||
pendingReasoningLevel: String? = null,
|
pendingReasoningLevel: String? = null,
|
||||||
isSessionReady: Boolean = true,
|
isSessionReady: Boolean = true,
|
||||||
|
canInterrupt: Boolean = false,
|
||||||
|
onMicHoldStart: () -> Unit = {},
|
||||||
|
onMicHoldEnd: () -> Unit = {},
|
||||||
|
onMicHoldCancel: () -> Unit = {},
|
||||||
|
onStopGeneration: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
var showReasoningMenu by remember { mutableStateOf(false) }
|
var showReasoningMenu by remember { mutableStateOf(false) }
|
||||||
val palette = composerPalette()
|
val palette = composerPalette()
|
||||||
val reasoningDisabledForModel = supportsReasoning == false
|
val reasoningDisabledForModel = supportsReasoning == false
|
||||||
val canDisable = canDisableReasoning
|
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(
|
Row(
|
||||||
modifier =
|
modifier =
|
||||||
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(
|
AnimatedVisibility(
|
||||||
visible = showSend,
|
visible = showSend,
|
||||||
enter = fadeIn() + scaleIn(initialScale = 0.8f),
|
enter = fadeIn() + scaleIn(initialScale = 0.8f),
|
||||||
exit = fadeOut() + scaleOut(targetScale = 0.8f),
|
exit = fadeOut() + scaleOut(targetScale = 0.8f),
|
||||||
) {
|
) {
|
||||||
FilledIconButton(
|
if (canInterrupt) {
|
||||||
onClick = onMicTap,
|
FilledIconButton(
|
||||||
enabled = isConnected,
|
onClick = onSend,
|
||||||
colors = if (isListening) listeningIconButtonColors() else flatIconButtonColors(palette),
|
enabled = canSend,
|
||||||
modifier =
|
colors = flatIconButtonColors(palette),
|
||||||
Modifier
|
modifier =
|
||||||
.size(ControlSize)
|
Modifier
|
||||||
.testTag(if (isListening) "mic_stop_button" else "mic_button"),
|
.size(ControlSize)
|
||||||
) {
|
.testTag("send_button"),
|
||||||
Icon(
|
) {
|
||||||
imageVector = if (isListening) Icons.Default.Stop else Icons.Outlined.Mic,
|
Icon(
|
||||||
contentDescription = if (isListening) "Stop listening" else "Mic",
|
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(
|
}.combinedClickable(
|
||||||
enabled = actionEnabled,
|
enabled = actionEnabled,
|
||||||
onClick = if (showSend) onSend else onMicTap,
|
onClick = {
|
||||||
|
when {
|
||||||
|
canInterrupt -> onStopGeneration()
|
||||||
|
showSend -> onSend()
|
||||||
|
else -> onMicTap()
|
||||||
|
}
|
||||||
|
},
|
||||||
onLongClick = if (showSend && showQueue) onStopAndSend else null,
|
onLongClick = if (showSend && showQueue) onStopAndSend else null,
|
||||||
onLongClickLabel = if (showSend && showQueue) stopAndSendLabel else null,
|
onLongClickLabel = if (showSend && showQueue) stopAndSendLabel else null,
|
||||||
).testTag(
|
).testTag(
|
||||||
when {
|
when {
|
||||||
|
canInterrupt -> "stop_button"
|
||||||
showSend -> "send_button"
|
showSend -> "send_button"
|
||||||
isListening -> "mic_stop_button"
|
isListening -> "mic_stop_button"
|
||||||
else -> "mic_button"
|
else -> "mic_button"
|
||||||
},
|
},
|
||||||
),
|
).then(if (showSend || canInterrupt) Modifier else actionMicGesture),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Crossfade(
|
Crossfade(
|
||||||
targetState =
|
targetState =
|
||||||
when {
|
when {
|
||||||
|
canInterrupt -> ActionGlyph.STOP
|
||||||
showSend -> ActionGlyph.SEND
|
showSend -> ActionGlyph.SEND
|
||||||
isListening -> ActionGlyph.STOP
|
isListening -> ActionGlyph.STOP
|
||||||
else -> ActionGlyph.VOICE
|
else -> ActionGlyph.VOICE
|
||||||
@@ -517,8 +592,18 @@ fun ComposerToolbar(
|
|||||||
ActionGlyph.STOP -> {
|
ActionGlyph.STOP -> {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Stop,
|
imageVector = Icons.Default.Stop,
|
||||||
contentDescription = "Stop listening",
|
contentDescription =
|
||||||
tint = if (isListening) MaterialTheme.colorScheme.onErrorContainer else palette.onAction,
|
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
|
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 -->
|
<!-- Chat -->
|
||||||
<string name="chat_send_desc">Send</string>
|
<string name="chat_send_desc">Send</string>
|
||||||
<string name="chat_mic_listening">Listening…</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_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="stt_not_available">Speech recognition is not available on this device</string>
|
||||||
<string name="chat_send">Send</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 com.m57.hermescontrol.data.local.AuthManager
|
||||||
import okhttp3.HttpUrl
|
import okhttp3.HttpUrl
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
import okhttp3.mockwebserver.Dispatcher
|
import okhttp3.mockwebserver.Dispatcher
|
||||||
import okhttp3.mockwebserver.MockResponse
|
import okhttp3.mockwebserver.MockResponse
|
||||||
import okhttp3.mockwebserver.MockWebServer
|
import okhttp3.mockwebserver.MockWebServer
|
||||||
@@ -59,6 +61,7 @@ class ProfileScopeInterceptorTest {
|
|||||||
server.enqueue(MockResponse().setResponseCode(200).setBody("{}"))
|
server.enqueue(MockResponse().setResponseCode(200).setBody("{}"))
|
||||||
val builder = Request.Builder().url(server.url(path))
|
val builder = Request.Builder().url(server.url(path))
|
||||||
when (method) {
|
when (method) {
|
||||||
|
"POST" -> builder.post("{}".toRequestBody("application/json".toMediaType()))
|
||||||
"DELETE" -> builder.delete()
|
"DELETE" -> builder.delete()
|
||||||
else -> builder.get()
|
else -> builder.get()
|
||||||
}
|
}
|
||||||
@@ -269,4 +272,27 @@ class ProfileScopeInterceptorTest {
|
|||||||
assertEquals("work", url.queryParameter("profile"))
|
assertEquals("work", url.queryParameter("profile"))
|
||||||
assertEquals("/api/status/health", url.encodedPath)
|
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 android.app.Application
|
||||||
import com.m57.hermescontrol.data.local.AuthManager
|
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.HermesDatabase
|
||||||
import com.m57.hermescontrol.data.model.ActionResponse
|
import com.m57.hermescontrol.data.model.ActionResponse
|
||||||
import com.m57.hermescontrol.data.remote.ApiClient
|
import com.m57.hermescontrol.data.remote.ApiClient
|
||||||
@@ -76,6 +77,14 @@ class ChatUpdateCommandTest {
|
|||||||
every { android.util.Log.e(any(), any(), any()) } returns 0
|
every { android.util.Log.e(any(), any(), any()) } returns 0
|
||||||
|
|
||||||
mockkObject(AuthManager)
|
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(HermesWsClient)
|
||||||
mockkObject(ApiClient)
|
mockkObject(ApiClient)
|
||||||
mockkObject(HermesDatabase)
|
mockkObject(HermesDatabase)
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ import android.net.Uri
|
|||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.core.content.FileProvider
|
import androidx.core.content.FileProvider
|
||||||
import com.m57.hermescontrol.data.local.AuthManager
|
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.HermesDatabase
|
||||||
import com.m57.hermescontrol.data.model.Attachment
|
import com.m57.hermescontrol.data.model.Attachment
|
||||||
import com.m57.hermescontrol.data.model.AttachmentSource
|
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.BusySendMode
|
||||||
import com.m57.hermescontrol.data.model.PaginationInfo
|
import com.m57.hermescontrol.data.model.PaginationInfo
|
||||||
import com.m57.hermescontrol.data.model.SessionMessage
|
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.ConnectionStatus
|
||||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||||
import com.m57.hermescontrol.data.ws.JsonRpcError
|
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.WsEvent
|
||||||
import com.m57.hermescontrol.data.ws.WsMethods
|
import com.m57.hermescontrol.data.ws.WsMethods
|
||||||
import com.m57.hermescontrol.notification.TurnCorrelationTracker
|
import com.m57.hermescontrol.notification.TurnCorrelationTracker
|
||||||
@@ -65,6 +68,7 @@ import kotlinx.serialization.json.buildJsonObject
|
|||||||
import kotlinx.serialization.json.jsonObject
|
import kotlinx.serialization.json.jsonObject
|
||||||
import kotlinx.serialization.json.jsonPrimitive
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
import kotlinx.serialization.json.put
|
import kotlinx.serialization.json.put
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
import org.junit.Assert.assertArrayEquals
|
import org.junit.Assert.assertArrayEquals
|
||||||
@@ -75,6 +79,7 @@ import org.junit.Assert.assertNull
|
|||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
import retrofit2.Response
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
class ChatViewModelTest {
|
class ChatViewModelTest {
|
||||||
@@ -298,6 +303,18 @@ class ChatViewModelTest {
|
|||||||
every { AuthManager.getToken() } returns "test-token"
|
every { AuthManager.getToken() } returns "test-token"
|
||||||
every { AuthManager.getBaseUrl() } returns "http://test.local/"
|
every { AuthManager.getBaseUrl() } returns "http://test.local/"
|
||||||
every { AuthManager.getSelectedProfileId() } returns null
|
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)
|
mockkObject(ProfileSwitchCoordinator)
|
||||||
every { ProfileSwitchCoordinator.switched } returns mockSwitchFlow
|
every { ProfileSwitchCoordinator.switched } returns mockSwitchFlow
|
||||||
every { ProfileSwitchCoordinator.connectionSwitched } returns MutableSharedFlow<String>()
|
every { ProfileSwitchCoordinator.connectionSwitched } returns MutableSharedFlow<String>()
|
||||||
@@ -1098,6 +1115,13 @@ class ChatViewModelTest {
|
|||||||
runTest {
|
runTest {
|
||||||
val (viewModel, _) = createViewModelWithSession()
|
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.
|
// A bare "/model" must NOT dispatch a slash command; it opens the picker.
|
||||||
viewModel.sendMessage("/model")
|
viewModel.sendMessage("/model")
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
@@ -9746,4 +9770,414 @@ class ChatViewModelTest {
|
|||||||
}
|
}
|
||||||
assertNull(TurnCorrelationTracker.boundaryFor("default", sessionId))
|
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 android.app.Application
|
||||||
import com.m57.hermescontrol.data.local.AuthManager
|
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.HermesDatabase
|
||||||
import com.m57.hermescontrol.data.model.PaginationInfo
|
import com.m57.hermescontrol.data.model.PaginationInfo
|
||||||
import com.m57.hermescontrol.data.model.SessionMessagesResponse
|
import com.m57.hermescontrol.data.model.SessionMessagesResponse
|
||||||
@@ -77,6 +78,14 @@ class SlashCommandDispatchRpcTest {
|
|||||||
// COMMAND_DISPATCH captures in suite order). setMain alone is safe —
|
// COMMAND_DISPATCH captures in suite order). setMain alone is safe —
|
||||||
// the slash-dispatch send path is synchronous (no IO hop).
|
// the slash-dispatch send path is synchronous (no IO hop).
|
||||||
mockkObject(AuthManager)
|
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(HermesWsClient)
|
||||||
mockkObject(ApiClient)
|
mockkObject(ApiClient)
|
||||||
mockkObject(HermesDatabase)
|
mockkObject(HermesDatabase)
|
||||||
@@ -459,7 +468,11 @@ class SlashCommandDispatchRpcTest {
|
|||||||
every {
|
every {
|
||||||
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any())
|
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any())
|
||||||
} answers {
|
} 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?>()
|
val d = CompletableDeferred<Any?>()
|
||||||
if (m == WsMethods.COMMAND_DISPATCH) {
|
if (m == WsMethods.COMMAND_DISPATCH) {
|
||||||
// Backend rejects /status with the registry-miss 4018 (issue #576).
|
// Backend rejects /status with the registry-miss 4018 (issue #576).
|
||||||
@@ -729,7 +742,9 @@ class SlashCommandDispatchRpcTest {
|
|||||||
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any())
|
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any())
|
||||||
} answers {
|
} answers {
|
||||||
val d = CompletableDeferred<Any?>()
|
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.
|
// 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"))
|
d.complete(mapOf("type" to "send", "message" to "Scan this repo and write AGENTS.md"))
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
Reference in new issue
Block a user