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
@@ -0,0 +1,30 @@
|
||||
package com.m57.hermescontrol.data.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Request body for `POST /api/audio/transcribe` — the dashboard's server-side
|
||||
* transcription relay, the same endpoint the desktop client's voice path uses.
|
||||
*
|
||||
* The recording rides inline as a base64 `data:` URL (the desktop wire shape).
|
||||
* [OkHttpProvider.json] carries [kotlinx.serialization.json.JsonNamingStrategy.SnakeCase],
|
||||
* so [dataUrl]/[mimeType] serialize as `data_url`/`mime_type`.
|
||||
*/
|
||||
@Serializable
|
||||
data class AudioTranscriptionRequest(
|
||||
val dataUrl: String,
|
||||
val mimeType: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* Response of `POST /api/audio/transcribe`.
|
||||
*
|
||||
* [transcript] is empty (not an error) when the server detects no speech in
|
||||
* the recording.
|
||||
*/
|
||||
@Serializable
|
||||
data class AudioTranscriptionResponse(
|
||||
val ok: Boolean = false,
|
||||
val transcript: String? = null,
|
||||
val provider: String? = null,
|
||||
)
|
||||
@@ -4,9 +4,11 @@ import com.m57.hermescontrol.BuildConfig
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.kotlinx.serialization.asConverterFactory
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Provides a Retrofit-backed [HermesApiService].
|
||||
@@ -22,6 +24,9 @@ object ApiClient {
|
||||
@Volatile
|
||||
private var service: HermesApiService? = null
|
||||
|
||||
@Volatile
|
||||
private var okHttpClient: OkHttpClient? = null
|
||||
|
||||
/** The current [HermesApiService] instance. Lazily created on first access. */
|
||||
val hermesApi: HermesApiService
|
||||
get() {
|
||||
@@ -39,6 +44,7 @@ object ApiClient {
|
||||
synchronized(this) {
|
||||
retrofit = null
|
||||
service = null
|
||||
okHttpClient = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,6 +87,64 @@ object ApiClient {
|
||||
return tempRetrofit.create(HermesApiService::class.java)
|
||||
}
|
||||
|
||||
/**
|
||||
* Dedicated [HermesApiService] for the blocking voice-note transcription
|
||||
* POST (`/api/audio/transcribe`).
|
||||
*
|
||||
* STT blocks until the profile's provider finishes, so it cannot run on
|
||||
* the shared 30s client, and a re-submitted POST would repeat expensive
|
||||
* provider work after the request may already have reached the server
|
||||
* (the repository also passes `retries = 0`). The returned service reuses
|
||||
* the shared connection pool and the auth/profile interceptor stack, and
|
||||
* applies [readTimeoutMs] to reads and writes with transport retries
|
||||
* disabled (review, PR #1250).
|
||||
*/
|
||||
fun transcriptionService(readTimeoutMs: Long): HermesApiService {
|
||||
val client =
|
||||
OkHttpProvider
|
||||
.base
|
||||
.newBuilder()
|
||||
.addInterceptor(restAuthInterceptor())
|
||||
.addInterceptor(ProfileScopeInterceptor)
|
||||
.authenticator(TokenRefreshAuthenticator)
|
||||
.readTimeout(readTimeoutMs, TimeUnit.MILLISECONDS)
|
||||
.writeTimeout(readTimeoutMs, TimeUnit.MILLISECONDS)
|
||||
.retryOnConnectionFailure(false)
|
||||
.build()
|
||||
return Retrofit
|
||||
.Builder()
|
||||
.baseUrl(AuthManager.endpointForBuild().baseUrl)
|
||||
.client(client)
|
||||
.addConverterFactory(OkHttpProvider.json.asConverterFactory("application/json".toMediaType()))
|
||||
.build()
|
||||
.create(HermesApiService::class.java)
|
||||
}
|
||||
|
||||
/**
|
||||
* REST auth stamping shared by every client: token mode sends the bearer
|
||||
* header; gated (basic-auth cookie) mode leaves the request alone because
|
||||
* the shared CookieManager jar carries the session cookie and a stray
|
||||
* Authorization header would 401 (issue #470).
|
||||
*/
|
||||
private fun restAuthInterceptor(): Interceptor =
|
||||
Interceptor { chain ->
|
||||
val request = chain.request()
|
||||
if (AuthManager.isGatedMode()) {
|
||||
return@Interceptor chain.proceed(request)
|
||||
}
|
||||
val token = AuthManager.getToken()
|
||||
if (!token.isNullOrBlank()) {
|
||||
chain.proceed(
|
||||
request
|
||||
.newBuilder()
|
||||
.addHeader("Authorization", "Bearer $token")
|
||||
.build(),
|
||||
)
|
||||
} else {
|
||||
chain.proceed(request)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Internal ─────────────────────────────────────────────────────────
|
||||
|
||||
private fun buildService(): HermesApiService =
|
||||
@@ -111,25 +175,7 @@ object ApiClient {
|
||||
// must NOT stamp one here — otherwise every REST tab (skills, cron,
|
||||
// config, ...) fails with "token expired" while the WS chat, which
|
||||
// authenticates via ?ticket=, keeps working.
|
||||
val authInterceptor =
|
||||
Interceptor { chain ->
|
||||
val request = chain.request()
|
||||
if (AuthManager.isGatedMode()) {
|
||||
// Cookie in the shared jar is the only valid REST credential.
|
||||
return@Interceptor chain.proceed(request)
|
||||
}
|
||||
val token = AuthManager.getToken()
|
||||
if (!token.isNullOrBlank()) {
|
||||
chain.proceed(
|
||||
request
|
||||
.newBuilder()
|
||||
.addHeader("Authorization", "Bearer $token")
|
||||
.build(),
|
||||
)
|
||||
} else {
|
||||
chain.proceed(request)
|
||||
}
|
||||
}
|
||||
val authInterceptor = restAuthInterceptor()
|
||||
|
||||
val okHttp =
|
||||
OkHttpProvider
|
||||
|
||||
@@ -7,6 +7,8 @@ import com.m57.hermescontrol.data.model.ActiveProfileResponse
|
||||
import com.m57.hermescontrol.data.model.AddMcpServerRequest
|
||||
import com.m57.hermescontrol.data.model.AgentPluginInstallBody
|
||||
import com.m57.hermescontrol.data.model.AnalyticsResponse
|
||||
import com.m57.hermescontrol.data.model.AudioTranscriptionRequest
|
||||
import com.m57.hermescontrol.data.model.AudioTranscriptionResponse
|
||||
import com.m57.hermescontrol.data.model.AuxiliaryModelsResponse
|
||||
import com.m57.hermescontrol.data.model.BackupTriggerRequest
|
||||
import com.m57.hermescontrol.data.model.BulkDeleteRequest
|
||||
@@ -1030,4 +1032,14 @@ interface HermesApiService : KanbanApiService {
|
||||
suspend fun deleteManagedFile(
|
||||
@Body body: ManagedFileDelete,
|
||||
): Response<ManagedFileActionResponse>
|
||||
|
||||
// ── Audio: server-side transcription ────────────────────────────────
|
||||
// The dashboard's voice relay (hermes_cli/web_routers/audio.py) — the
|
||||
// same endpoint the desktop client's voice notes use. The clip rides
|
||||
// inline as a base64 data URL; the server resolves STT through the
|
||||
// active profile's configured provider.
|
||||
@POST("api/audio/transcribe")
|
||||
suspend fun transcribeAudio(
|
||||
@Body body: AudioTranscriptionRequest,
|
||||
): Response<AudioTranscriptionResponse>
|
||||
}
|
||||
@@ -26,6 +26,13 @@ object ProfileScopeInterceptor : Interceptor {
|
||||
private val PROFILE_SCOPED_PREFIXES =
|
||||
listOf(
|
||||
"/api/analytics",
|
||||
// Voice-note transcription resolves STT through the profile's
|
||||
// configured provider, so the request must carry the active
|
||||
// profile (the desktop sends this route `...profileScoped()`;
|
||||
// hermes_cli/web_routers/audio.py is config-scoped). Unscoped, a
|
||||
// multi-profile host transcribes under the launch profile
|
||||
// (review, PR #1250).
|
||||
"/api/audio",
|
||||
"/api/config",
|
||||
"/api/credentials/pool",
|
||||
"/api/cron",
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package com.m57.hermescontrol.data.repository
|
||||
|
||||
import com.m57.hermescontrol.data.model.AudioTranscriptionRequest
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
import com.m57.hermescontrol.data.remote.HermesApiService
|
||||
import com.m57.hermescontrol.data.remote.NetworkError
|
||||
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||
import com.m57.hermescontrol.data.remote.safeApiCall
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.util.Base64
|
||||
|
||||
/**
|
||||
* MIME type of the recorder's output: MPEG-4 container with AAC audio — see
|
||||
* `VoiceNoteRecorder` in the chat UI layer.
|
||||
*/
|
||||
const val VOICE_NOTE_MIME_TYPE = "audio/mp4"
|
||||
|
||||
/**
|
||||
* Transcribes a recorded voice note through the dashboard's server-side
|
||||
* transcription endpoint (`POST /api/audio/transcribe`).
|
||||
*
|
||||
* The wire shape is the desktop client's: base64 `data:` URL plus MIME type.
|
||||
* The server resolves STT through the active profile's configured provider
|
||||
* (the request is profile-scoped by ProfileScopeInterceptor, matching the
|
||||
* desktop's `...profileScoped()` on this route), so the transcript matches
|
||||
* the rest of Hermes instead of the phone's on-device recognizer.
|
||||
*
|
||||
* Two contracts differ from a plain REST call (review, PR #1250):
|
||||
* - [serviceProvider] resolves the Retrofit service at CALL time, so a
|
||||
* connection/profile switch (ApiClient.rebuild) applies to the next
|
||||
* transcription instead of pinning the construction-time service.
|
||||
* - The STT POST runs on a dedicated long-timeout client with `retries = 0`:
|
||||
* re-submitting after a timeout or 5xx would repeat expensive provider
|
||||
* work although the request may already have reached the server.
|
||||
*
|
||||
* Open for test substitution (mirrors [ChatPersistenceRepository]'s pattern).
|
||||
*/
|
||||
open class VoiceNoteRepository(
|
||||
private val serviceProvider: (timeoutMs: Long) -> HermesApiService = { timeoutMs ->
|
||||
ApiClient.transcriptionService(timeoutMs)
|
||||
},
|
||||
) {
|
||||
open suspend fun transcribe(file: File): NetworkResult<String> {
|
||||
val encoded =
|
||||
try {
|
||||
file.readBytes()
|
||||
} catch (e: IOException) {
|
||||
// A local read failure must return a Failure instead of
|
||||
// escaping as an exception — the caller resets its
|
||||
// transcription state from this result (review, PR #1250).
|
||||
return NetworkResult.Failure(
|
||||
NetworkError.Unknown("Failed to read the voice note: ${e.message}", e),
|
||||
)
|
||||
}
|
||||
val dataUrl = "data:$VOICE_NOTE_MIME_TYPE;base64," + Base64.getEncoder().encodeToString(encoded)
|
||||
val result =
|
||||
safeApiCall(retries = 0) {
|
||||
serviceProvider(transcriptionTimeoutMs(dataUrl)).transcribeAudio(
|
||||
AudioTranscriptionRequest(dataUrl = dataUrl, mimeType = VOICE_NOTE_MIME_TYPE),
|
||||
)
|
||||
}
|
||||
return when (result) {
|
||||
is NetworkResult.Success -> {
|
||||
NetworkResult.Success(
|
||||
result.data
|
||||
?.transcript
|
||||
.orEmpty()
|
||||
.trim(),
|
||||
)
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Floor for STT requests — desktop parity (`AUDIO_TRANSCRIBE_MIN_REQUEST_TIMEOUT_MS`). */
|
||||
const val MIN_REQUEST_TIMEOUT_MS = 180_000L
|
||||
|
||||
/** Cap so a hung transcription cannot pin the call forever — desktop parity. */
|
||||
const val MAX_REQUEST_TIMEOUT_MS = 600_000L
|
||||
|
||||
private const val BASE64_CHARS_PER_TIMEOUT_MS = 10L
|
||||
|
||||
/**
|
||||
* Timeout for the blocking STT endpoint, mirroring the desktop's
|
||||
* `audioTranscribeRequestTimeoutMs`: ~0.1ms of budget per base64 char
|
||||
* keeps short clips at the floor while multi-minute recordings scale
|
||||
* toward the cap. Integer ceil() keeps the arithmetic exact.
|
||||
*/
|
||||
fun transcriptionTimeoutMs(dataUrl: String): Long =
|
||||
((dataUrl.length + BASE64_CHARS_PER_TIMEOUT_MS - 1) / BASE64_CHARS_PER_TIMEOUT_MS)
|
||||
.coerceIn(MIN_REQUEST_TIMEOUT_MS, MAX_REQUEST_TIMEOUT_MS)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user