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
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user