Rejig dictation (#6844)
Co-authored-by: Douwe Osinga <douwe@squareup.com>
This commit is contained in:
@@ -412,6 +412,8 @@ derive_utoipa!(Icon as IconSchema);
|
||||
super::routes::tunnel::stop_tunnel,
|
||||
super::routes::tunnel::get_tunnel_status,
|
||||
super::routes::telemetry::send_telemetry_event,
|
||||
super::routes::dictation::transcribe_dictation,
|
||||
super::routes::dictation::get_dictation_config,
|
||||
),
|
||||
components(schemas(
|
||||
super::routes::config_management::UpsertConfigQuery,
|
||||
@@ -570,6 +572,10 @@ derive_utoipa!(Icon as IconSchema);
|
||||
goose::goose_apps::CspMetadata,
|
||||
goose::goose_apps::UiMetadata,
|
||||
goose::goose_apps::ResourceMetadata,
|
||||
super::routes::dictation::TranscribeRequest,
|
||||
super::routes::dictation::TranscribeResponse,
|
||||
super::routes::dictation::DictationProvider,
|
||||
super::routes::dictation::DictationProviderStatus,
|
||||
))
|
||||
)]
|
||||
pub struct ApiDoc;
|
||||
|
||||
@@ -1,512 +0,0 @@
|
||||
/// Audio transcription route handler
|
||||
///
|
||||
/// This module provides endpoints for audio transcription using OpenAI's Whisper API.
|
||||
/// The OpenAI API key must be configured in the backend for this to work.
|
||||
use crate::routes::errors::ErrorResponse;
|
||||
use crate::state::AppState;
|
||||
use axum::{
|
||||
http::StatusCode,
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
// Constants
|
||||
const MAX_AUDIO_SIZE_BYTES: usize = 25 * 1024 * 1024; // 25MB
|
||||
const OPENAI_TIMEOUT_SECONDS: u64 = 30;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TranscribeRequest {
|
||||
audio: String, // Base64 encoded audio data
|
||||
mime_type: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TranscribeElevenLabsRequest {
|
||||
audio: String, // Base64 encoded audio data
|
||||
mime_type: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct TranscribeResponse {
|
||||
text: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct WhisperResponse {
|
||||
text: String,
|
||||
}
|
||||
|
||||
/// Validate audio input and return decoded bytes and file extension
|
||||
fn validate_audio_input(
|
||||
audio: &str,
|
||||
mime_type: &str,
|
||||
) -> Result<(Vec<u8>, &'static str), ErrorResponse> {
|
||||
// Decode the base64 audio data
|
||||
let audio_bytes = BASE64
|
||||
.decode(audio)
|
||||
.map_err(|_| ErrorResponse::bad_request("Invalid base64 audio data"))?;
|
||||
|
||||
// Check file size
|
||||
if audio_bytes.len() > MAX_AUDIO_SIZE_BYTES {
|
||||
return Err(ErrorResponse {
|
||||
message: format!(
|
||||
"Audio file too large: {} bytes (max: {} bytes)",
|
||||
audio_bytes.len(),
|
||||
MAX_AUDIO_SIZE_BYTES
|
||||
),
|
||||
status: StatusCode::PAYLOAD_TOO_LARGE,
|
||||
});
|
||||
}
|
||||
|
||||
// Determine file extension based on MIME type
|
||||
let file_extension = match mime_type {
|
||||
"audio/webm" => "webm",
|
||||
"audio/webm;codecs=opus" => "webm",
|
||||
"audio/mp4" => "mp4",
|
||||
"audio/mpeg" => "mp3",
|
||||
"audio/mpga" => "mpga",
|
||||
"audio/m4a" => "m4a",
|
||||
"audio/wav" => "wav",
|
||||
"audio/x-wav" => "wav",
|
||||
_ => {
|
||||
return Err(ErrorResponse {
|
||||
message: format!("Unsupported audio format: {}", mime_type),
|
||||
status: StatusCode::UNSUPPORTED_MEDIA_TYPE,
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
Ok((audio_bytes, file_extension))
|
||||
}
|
||||
|
||||
/// Get OpenAI configuration (API key and host)
|
||||
fn get_openai_config() -> Result<(String, String), ErrorResponse> {
|
||||
let config = goose::config::Config::global();
|
||||
|
||||
let api_key: String = config.get_secret("OPENAI_API_KEY").map_err(|e| match e {
|
||||
goose::config::ConfigError::NotFound(_) => ErrorResponse {
|
||||
message: "OpenAI API key not configured. Please set OPENAI_API_KEY in settings."
|
||||
.to_string(),
|
||||
status: StatusCode::PRECONDITION_FAILED,
|
||||
},
|
||||
_ => ErrorResponse::internal(format!("Failed to get OpenAI API key: {:?}", e)),
|
||||
})?;
|
||||
|
||||
let openai_host = match config.get("OPENAI_HOST", false) {
|
||||
Ok(value) => value
|
||||
.as_str()
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| "https://api.openai.com".to_string()),
|
||||
Err(_) => "https://api.openai.com".to_string(),
|
||||
};
|
||||
|
||||
Ok((api_key, openai_host))
|
||||
}
|
||||
|
||||
/// Send transcription request to OpenAI Whisper API
|
||||
async fn send_openai_request(
|
||||
audio_bytes: Vec<u8>,
|
||||
file_extension: &str,
|
||||
mime_type: &str,
|
||||
api_key: &str,
|
||||
openai_host: &str,
|
||||
) -> Result<WhisperResponse, ErrorResponse> {
|
||||
tracing::info!("Using OpenAI host: {}", openai_host);
|
||||
tracing::info!(
|
||||
"Audio file size: {} bytes, extension: {}, mime_type: {}",
|
||||
audio_bytes.len(),
|
||||
file_extension,
|
||||
mime_type
|
||||
);
|
||||
|
||||
// Create a multipart form with the audio file
|
||||
let part = reqwest::multipart::Part::bytes(audio_bytes)
|
||||
.file_name(format!("audio.{}", file_extension))
|
||||
.mime_str(mime_type)
|
||||
.map_err(|e| {
|
||||
ErrorResponse::internal(format!("Failed to create multipart part: {:?}", e))
|
||||
})?;
|
||||
|
||||
let form = reqwest::multipart::Form::new()
|
||||
.part("file", part)
|
||||
.text("model", "whisper-1")
|
||||
.text("response_format", "json");
|
||||
|
||||
tracing::info!("Created multipart form for OpenAI Whisper API");
|
||||
|
||||
// Make request to OpenAI Whisper API
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::from_secs(OPENAI_TIMEOUT_SECONDS))
|
||||
.build()
|
||||
.map_err(|e| ErrorResponse::internal(format!("Failed to create HTTP client: {}", e)))?;
|
||||
|
||||
tracing::info!(
|
||||
"Sending request to OpenAI: {}/v1/audio/transcriptions",
|
||||
openai_host
|
||||
);
|
||||
|
||||
let response = client
|
||||
.post(format!("{}/v1/audio/transcriptions", openai_host))
|
||||
.header("Authorization", format!("Bearer {}", api_key))
|
||||
.multipart(form)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
if e.is_timeout() {
|
||||
ErrorResponse {
|
||||
message: format!(
|
||||
"OpenAI API request timed out after {}s",
|
||||
OPENAI_TIMEOUT_SECONDS
|
||||
),
|
||||
status: StatusCode::GATEWAY_TIMEOUT,
|
||||
}
|
||||
} else {
|
||||
ErrorResponse {
|
||||
message: format!("Failed to send request to OpenAI: {}", e),
|
||||
status: StatusCode::SERVICE_UNAVAILABLE,
|
||||
}
|
||||
}
|
||||
})?;
|
||||
|
||||
tracing::info!(
|
||||
"Received response from OpenAI with status: {}",
|
||||
response.status()
|
||||
);
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let error_text = response.text().await.unwrap_or_default();
|
||||
tracing::error!("OpenAI API error (status: {}): {}", status, error_text);
|
||||
|
||||
// Check for specific error codes
|
||||
if status == 401 {
|
||||
return Err(ErrorResponse {
|
||||
message: "OpenAI API key appears to be invalid or unauthorized".to_string(),
|
||||
status: StatusCode::UNAUTHORIZED,
|
||||
});
|
||||
} else if status == 429 {
|
||||
return Err(ErrorResponse {
|
||||
message: "OpenAI API quota or rate limit exceeded".to_string(),
|
||||
status: StatusCode::TOO_MANY_REQUESTS,
|
||||
});
|
||||
}
|
||||
|
||||
return Err(ErrorResponse {
|
||||
message: format!("OpenAI API error: {}", error_text),
|
||||
status: StatusCode::BAD_GATEWAY,
|
||||
});
|
||||
}
|
||||
|
||||
let whisper_response: WhisperResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| ErrorResponse::internal(format!("Failed to parse OpenAI response: {}", e)))?;
|
||||
|
||||
Ok(whisper_response)
|
||||
}
|
||||
|
||||
/// Transcribe audio using OpenAI's Whisper API
|
||||
///
|
||||
/// # Request
|
||||
/// - `audio`: Base64 encoded audio data
|
||||
/// - `mime_type`: MIME type of the audio (e.g., "audio/webm", "audio/wav")
|
||||
///
|
||||
/// # Response
|
||||
/// - `text`: Transcribed text from the audio
|
||||
///
|
||||
/// # Errors
|
||||
/// - 401: Unauthorized (missing or invalid X-Secret-Key header)
|
||||
/// - 412: Precondition Failed (OpenAI API key not configured)
|
||||
/// - 400: Bad Request (invalid base64 audio data)
|
||||
/// - 413: Payload Too Large (audio file exceeds 25MB limit)
|
||||
/// - 415: Unsupported Media Type (unsupported audio format)
|
||||
/// - 502: Bad Gateway (OpenAI API error)
|
||||
/// - 503: Service Unavailable (network error)
|
||||
async fn transcribe_handler(
|
||||
Json(request): Json<TranscribeRequest>,
|
||||
) -> Result<Json<TranscribeResponse>, ErrorResponse> {
|
||||
let (audio_bytes, file_extension) = validate_audio_input(&request.audio, &request.mime_type)?;
|
||||
let (api_key, openai_host) = get_openai_config()?;
|
||||
|
||||
let whisper_response = send_openai_request(
|
||||
audio_bytes,
|
||||
file_extension,
|
||||
&request.mime_type,
|
||||
&api_key,
|
||||
&openai_host,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(TranscribeResponse {
|
||||
text: whisper_response.text,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Transcribe audio using ElevenLabs Speech-to-Text API
|
||||
///
|
||||
/// Uses ElevenLabs' speech-to-text endpoint for transcription.
|
||||
/// Requires an ElevenLabs API key with speech-to-text access.
|
||||
async fn transcribe_elevenlabs_handler(
|
||||
Json(request): Json<TranscribeElevenLabsRequest>,
|
||||
) -> Result<Json<TranscribeResponse>, ErrorResponse> {
|
||||
let (audio_bytes, file_extension) = validate_audio_input(&request.audio, &request.mime_type)?;
|
||||
|
||||
// Get the ElevenLabs API key from config (after input validation)
|
||||
let config = goose::config::Config::global();
|
||||
|
||||
// First try to get it as a secret
|
||||
let api_key: String = match config.get_secret::<String>("ELEVENLABS_API_KEY") {
|
||||
Ok(key) => key,
|
||||
Err(_) => {
|
||||
// Try to get it as non-secret (for backward compatibility)
|
||||
match config.get("ELEVENLABS_API_KEY", false) {
|
||||
Ok(value) => {
|
||||
match value.as_str() {
|
||||
Some(key_str) => {
|
||||
let key = key_str.to_string();
|
||||
// Migrate to secret storage
|
||||
if let Err(e) = config.set(
|
||||
"ELEVENLABS_API_KEY",
|
||||
&serde_json::Value::String(key.clone()),
|
||||
true,
|
||||
) {
|
||||
tracing::error!("Failed to migrate ElevenLabs API key: {:?}", e);
|
||||
}
|
||||
// Delete the non-secret version
|
||||
if let Err(e) = config.delete("ELEVENLABS_API_KEY") {
|
||||
tracing::warn!(
|
||||
"Failed to delete non-secret ElevenLabs API key: {:?}",
|
||||
e
|
||||
);
|
||||
}
|
||||
key
|
||||
}
|
||||
None => {
|
||||
return Err(ErrorResponse::bad_request(format!(
|
||||
"ElevenLabs API key is not a string, found: {:?}",
|
||||
value
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
return Err(ErrorResponse::bad_request(
|
||||
"No ElevenLabs API key found in configuration",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Create multipart form for ElevenLabs API
|
||||
let part = reqwest::multipart::Part::bytes(audio_bytes)
|
||||
.file_name(format!("audio.{}", file_extension))
|
||||
.mime_str(&request.mime_type)
|
||||
.map_err(|_| ErrorResponse::internal("Failed to create multipart part"))?;
|
||||
|
||||
let form = reqwest::multipart::Form::new()
|
||||
.part("file", part) // Changed from "audio" to "file"
|
||||
.text("model_id", "scribe_v1") // Use the correct model_id for speech-to-text
|
||||
.text("tag_audio_events", "false")
|
||||
.text("diarize", "false");
|
||||
|
||||
// Make request to ElevenLabs Speech-to-Text API
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::from_secs(OPENAI_TIMEOUT_SECONDS))
|
||||
.build()
|
||||
.map_err(|e| ErrorResponse::internal(format!("Failed to create HTTP client: {}", e)))?;
|
||||
|
||||
let response = client
|
||||
.post("https://api.elevenlabs.io/v1/speech-to-text")
|
||||
.header("xi-api-key", &api_key)
|
||||
.multipart(form)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
if e.is_timeout() {
|
||||
ErrorResponse {
|
||||
message: format!(
|
||||
"ElevenLabs API request timed out after {}s",
|
||||
OPENAI_TIMEOUT_SECONDS
|
||||
),
|
||||
status: StatusCode::GATEWAY_TIMEOUT,
|
||||
}
|
||||
} else {
|
||||
ErrorResponse {
|
||||
message: format!("Failed to send request to ElevenLabs: {}", e),
|
||||
status: StatusCode::SERVICE_UNAVAILABLE,
|
||||
}
|
||||
}
|
||||
})?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let error_text = response.text().await.unwrap_or_default();
|
||||
tracing::error!("ElevenLabs API error (status: {}): {}", status, error_text);
|
||||
|
||||
// Check for specific error codes
|
||||
if error_text.contains("Unauthorized") || error_text.contains("Invalid API key") {
|
||||
return Err(ErrorResponse {
|
||||
message: "ElevenLabs API key is invalid or unauthorized".to_string(),
|
||||
status: StatusCode::UNAUTHORIZED,
|
||||
});
|
||||
} else if error_text.contains("quota") || error_text.contains("limit") {
|
||||
return Err(ErrorResponse {
|
||||
message: "ElevenLabs API quota or rate limit exceeded".to_string(),
|
||||
status: StatusCode::PAYMENT_REQUIRED,
|
||||
});
|
||||
}
|
||||
|
||||
return Err(ErrorResponse {
|
||||
message: format!("ElevenLabs API error: {}", error_text),
|
||||
status: StatusCode::BAD_GATEWAY,
|
||||
});
|
||||
}
|
||||
|
||||
// Parse ElevenLabs response
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ElevenLabsResponse {
|
||||
text: String,
|
||||
#[serde(rename = "chunks")]
|
||||
#[allow(dead_code)]
|
||||
_chunks: Option<Vec<serde_json::Value>>,
|
||||
}
|
||||
|
||||
let elevenlabs_response: ElevenLabsResponse = response.json().await.map_err(|e| {
|
||||
ErrorResponse::internal(format!("Failed to parse ElevenLabs response: {}", e))
|
||||
})?;
|
||||
|
||||
Ok(Json(TranscribeResponse {
|
||||
text: elevenlabs_response.text,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Check if dictation providers are configured
|
||||
///
|
||||
/// Returns configuration status for dictation providers
|
||||
async fn check_dictation_config() -> Result<Json<serde_json::Value>, ErrorResponse> {
|
||||
let config = goose::config::Config::global();
|
||||
|
||||
// Check if ElevenLabs API key is configured
|
||||
let has_elevenlabs = match config.get_secret::<String>("ELEVENLABS_API_KEY") {
|
||||
Ok(_) => true,
|
||||
Err(_) => {
|
||||
// Check non-secret for backward compatibility
|
||||
config.get("ELEVENLABS_API_KEY", false).is_ok()
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"elevenlabs": has_elevenlabs
|
||||
})))
|
||||
}
|
||||
|
||||
pub fn routes(state: Arc<AppState>) -> Router {
|
||||
Router::new()
|
||||
.route("/audio/transcribe", post(transcribe_handler))
|
||||
.route(
|
||||
"/audio/transcribe/elevenlabs",
|
||||
post(transcribe_elevenlabs_handler),
|
||||
)
|
||||
.route("/audio/config", get(check_dictation_config))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::{body::Body, http::Request};
|
||||
use tower::ServiceExt;
|
||||
use wiremock::matchers::{method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn test_transcribe_endpoint_requires_auth() {
|
||||
let mock_server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/audio/transcriptions"))
|
||||
.respond_with(ResponseTemplate::new(401))
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
let _guard = env_lock::lock_env([
|
||||
("OPENAI_API_KEY", Some("fake-key")),
|
||||
("OPENAI_HOST", Some(mock_server.uri().as_str())),
|
||||
]);
|
||||
|
||||
let state = AppState::new().await.unwrap();
|
||||
let app = routes(state);
|
||||
let request = Request::builder()
|
||||
.uri("/audio/transcribe")
|
||||
.method("POST")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::to_string(&serde_json::json!({
|
||||
"audio": "dGVzdA==",
|
||||
"mime_type": "audio/webm"
|
||||
}))
|
||||
.unwrap(),
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
let response = app.oneshot(request).await.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn test_transcribe_endpoint_validates_size() {
|
||||
let state = AppState::new().await.unwrap();
|
||||
let app = routes(state);
|
||||
|
||||
let request = Request::builder()
|
||||
.uri("/audio/transcribe")
|
||||
.method("POST")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-secret-key", "test-secret")
|
||||
.body(Body::from(
|
||||
serde_json::to_string(&serde_json::json!({
|
||||
"audio": "dGVzdA==",
|
||||
"mime_type": "application/pdf" // Invalid MIME type
|
||||
}))
|
||||
.unwrap(),
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
let response = app.oneshot(request).await.unwrap();
|
||||
assert!(
|
||||
response.status() == StatusCode::UNSUPPORTED_MEDIA_TYPE
|
||||
|| response.status() == StatusCode::PRECONDITION_FAILED
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn test_transcribe_endpoint_validates_mime_type() {
|
||||
let state = AppState::new().await.unwrap();
|
||||
let app = routes(state);
|
||||
|
||||
let request = Request::builder()
|
||||
.uri("/audio/transcribe")
|
||||
.method("POST")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-secret-key", "test-secret")
|
||||
.body(Body::from(
|
||||
serde_json::to_string(&serde_json::json!({
|
||||
"audio": "invalid-base64-!@#$%",
|
||||
"mime_type": "audio/webm"
|
||||
}))
|
||||
.unwrap(),
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
let response = app.oneshot(request).await.unwrap();
|
||||
assert!(
|
||||
response.status() == StatusCode::BAD_REQUEST
|
||||
|| response.status() == StatusCode::PRECONDITION_FAILED
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
use crate::routes::errors::ErrorResponse;
|
||||
use crate::state::AppState;
|
||||
use axum::{
|
||||
http::StatusCode,
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
|
||||
use goose::providers::api_client::{ApiClient, AuthMethod};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
const MAX_AUDIO_SIZE_BYTES: usize = 25 * 1024 * 1024;
|
||||
const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
// DictationProvider definitions
|
||||
struct DictationProviderDef {
|
||||
config_key: &'static str,
|
||||
default_url: &'static str,
|
||||
host_key: Option<&'static str>,
|
||||
description: &'static str,
|
||||
uses_provider_config: bool,
|
||||
settings_path: Option<&'static str>,
|
||||
}
|
||||
|
||||
const PROVIDERS: &[(&str, DictationProviderDef)] = &[
|
||||
(
|
||||
"openai",
|
||||
DictationProviderDef {
|
||||
config_key: "OPENAI_API_KEY",
|
||||
default_url: "https://api.openai.com/v1/audio/transcriptions",
|
||||
host_key: Some("OPENAI_HOST"),
|
||||
description: "Uses OpenAI Whisper API for high-quality transcription.",
|
||||
uses_provider_config: true,
|
||||
settings_path: Some("Settings > Models"),
|
||||
},
|
||||
),
|
||||
(
|
||||
"elevenlabs",
|
||||
DictationProviderDef {
|
||||
config_key: "ELEVENLABS_API_KEY",
|
||||
default_url: "https://api.elevenlabs.io/v1/speech-to-text",
|
||||
host_key: None,
|
||||
description: "Uses ElevenLabs speech-to-text API for advanced voice processing.",
|
||||
uses_provider_config: false,
|
||||
settings_path: None,
|
||||
},
|
||||
),
|
||||
];
|
||||
|
||||
fn get_provider_def(name: &str) -> Option<&'static DictationProviderDef> {
|
||||
PROVIDERS
|
||||
.iter()
|
||||
.find_map(|(n, def)| if *n == name { Some(def) } else { None })
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum DictationProvider {
|
||||
OpenAI,
|
||||
ElevenLabs,
|
||||
}
|
||||
|
||||
impl DictationProvider {
|
||||
fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
DictationProvider::OpenAI => "openai",
|
||||
DictationProvider::ElevenLabs => "elevenlabs",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct TranscribeRequest {
|
||||
/// Base64 encoded audio data
|
||||
pub audio: String,
|
||||
/// MIME type of the audio (e.g., "audio/webm", "audio/wav")
|
||||
pub mime_type: String,
|
||||
/// Transcription provider to use
|
||||
pub provider: DictationProvider,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct TranscribeResponse {
|
||||
/// Transcribed text from the audio
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct DictationProviderStatus {
|
||||
/// Whether the provider is fully configured and ready to use
|
||||
pub configured: bool,
|
||||
/// Custom host URL if configured (only for providers that support it)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub host: Option<String>,
|
||||
/// Description of what this provider does
|
||||
pub description: String,
|
||||
/// Whether this provider uses the main provider config (true) or has its own key (false)
|
||||
pub uses_provider_config: bool,
|
||||
/// Path to settings if uses_provider_config is true
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub settings_path: Option<String>,
|
||||
/// Config key name if uses_provider_config is false
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub config_key: Option<String>,
|
||||
}
|
||||
|
||||
fn validate_audio(audio: &str, mime_type: &str) -> Result<(Vec<u8>, &'static str), ErrorResponse> {
|
||||
let audio_bytes = BASE64
|
||||
.decode(audio)
|
||||
.map_err(|_| ErrorResponse::bad_request("Invalid base64 audio data"))?;
|
||||
|
||||
if audio_bytes.len() > MAX_AUDIO_SIZE_BYTES {
|
||||
return Err(ErrorResponse {
|
||||
message: format!(
|
||||
"Audio file too large: {} bytes (max: {} bytes)",
|
||||
audio_bytes.len(),
|
||||
MAX_AUDIO_SIZE_BYTES
|
||||
),
|
||||
status: StatusCode::PAYLOAD_TOO_LARGE,
|
||||
});
|
||||
}
|
||||
|
||||
let extension = match mime_type {
|
||||
"audio/webm" | "audio/webm;codecs=opus" => "webm",
|
||||
"audio/mp4" => "mp4",
|
||||
"audio/mpeg" | "audio/mpga" => "mp3",
|
||||
"audio/m4a" => "m4a",
|
||||
"audio/wav" | "audio/x-wav" => "wav",
|
||||
_ => {
|
||||
return Err(ErrorResponse {
|
||||
message: format!("Unsupported audio format: {}", mime_type),
|
||||
status: StatusCode::UNSUPPORTED_MEDIA_TYPE,
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
Ok((audio_bytes, extension))
|
||||
}
|
||||
|
||||
async fn handle_response_error(response: reqwest::Response) -> ErrorResponse {
|
||||
let status = response.status();
|
||||
let error_text = response.text().await.unwrap_or_default();
|
||||
|
||||
ErrorResponse {
|
||||
message: if status == 401
|
||||
|| error_text.contains("Invalid API key")
|
||||
|| error_text.contains("Unauthorized")
|
||||
{
|
||||
"Invalid API key".to_string()
|
||||
} else if status == 429 || error_text.contains("quota") || error_text.contains("limit") {
|
||||
"Rate limit exceeded".to_string()
|
||||
} else {
|
||||
format!("API error: {}", error_text)
|
||||
},
|
||||
status: if status.is_client_error() {
|
||||
status
|
||||
} else {
|
||||
StatusCode::BAD_GATEWAY
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn build_api_client(provider: &str) -> Result<ApiClient, ErrorResponse> {
|
||||
let config = goose::config::Config::global();
|
||||
let def = get_provider_def(provider)
|
||||
.ok_or_else(|| ErrorResponse::bad_request(format!("Unknown provider: {}", provider)))?;
|
||||
|
||||
let api_key = config
|
||||
.get_secret(def.config_key)
|
||||
.map_err(|_| ErrorResponse {
|
||||
message: format!("{} not configured", def.config_key),
|
||||
status: StatusCode::PRECONDITION_FAILED,
|
||||
})?;
|
||||
|
||||
let url = if let Some(host_key) = def.host_key {
|
||||
config
|
||||
.get(host_key, false)
|
||||
.ok()
|
||||
.and_then(|v| v.as_str().map(|s| s.to_string()))
|
||||
.map(|custom_host| {
|
||||
let path = def
|
||||
.default_url
|
||||
.splitn(4, '/')
|
||||
.nth(3)
|
||||
.map(|p| format!("/{}", p))
|
||||
.unwrap_or_default();
|
||||
format!("{}{}", custom_host.trim_end_matches('/'), path)
|
||||
})
|
||||
.unwrap_or_else(|| def.default_url.to_string())
|
||||
} else {
|
||||
def.default_url.to_string()
|
||||
};
|
||||
|
||||
let auth = match provider {
|
||||
"openai" => AuthMethod::BearerToken(api_key),
|
||||
"elevenlabs" => AuthMethod::ApiKey {
|
||||
header_name: "xi-api-key".to_string(),
|
||||
key: api_key,
|
||||
},
|
||||
_ => {
|
||||
return Err(ErrorResponse::bad_request(format!(
|
||||
"Unknown provider: {}",
|
||||
provider
|
||||
)))
|
||||
}
|
||||
};
|
||||
|
||||
ApiClient::with_timeout(url, auth, REQUEST_TIMEOUT)
|
||||
.map_err(|e| ErrorResponse::internal(format!("Failed to create client: {}", e)))
|
||||
}
|
||||
|
||||
async fn transcribe_openai(
|
||||
audio_bytes: Vec<u8>,
|
||||
extension: &str,
|
||||
mime_type: &str,
|
||||
client: &ApiClient,
|
||||
) -> Result<String, ErrorResponse> {
|
||||
let part = reqwest::multipart::Part::bytes(audio_bytes)
|
||||
.file_name(format!("audio.{}", extension))
|
||||
.mime_str(mime_type)
|
||||
.map_err(|e| ErrorResponse::internal(format!("Failed to create multipart: {}", e)))?;
|
||||
|
||||
let form = reqwest::multipart::Form::new()
|
||||
.part("file", part)
|
||||
.text("model", "whisper-1");
|
||||
|
||||
let response = client
|
||||
.request(None, "")
|
||||
.multipart_post(form)
|
||||
.await
|
||||
.map_err(|e| ErrorResponse {
|
||||
message: if e.to_string().contains("timeout") {
|
||||
"Request timed out".to_string()
|
||||
} else {
|
||||
format!("Request failed: {}", e)
|
||||
},
|
||||
status: if e.to_string().contains("timeout") {
|
||||
StatusCode::GATEWAY_TIMEOUT
|
||||
} else {
|
||||
StatusCode::SERVICE_UNAVAILABLE
|
||||
},
|
||||
})?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(handle_response_error(response).await);
|
||||
}
|
||||
|
||||
let data: TranscribeResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| ErrorResponse::internal(format!("Failed to parse response: {}", e)))?;
|
||||
|
||||
Ok(data.text)
|
||||
}
|
||||
|
||||
async fn transcribe_elevenlabs(
|
||||
audio_bytes: Vec<u8>,
|
||||
extension: &str,
|
||||
mime_type: &str,
|
||||
client: &ApiClient,
|
||||
) -> Result<String, ErrorResponse> {
|
||||
let part = reqwest::multipart::Part::bytes(audio_bytes)
|
||||
.file_name(format!("audio.{}", extension))
|
||||
.mime_str(mime_type)
|
||||
.map_err(|_| ErrorResponse::internal("Failed to create multipart"))?;
|
||||
|
||||
let form = reqwest::multipart::Form::new()
|
||||
.part("file", part)
|
||||
.text("model_id", "scribe_v1");
|
||||
|
||||
let response = client
|
||||
.request(None, "")
|
||||
.multipart_post(form)
|
||||
.await
|
||||
.map_err(|e| ErrorResponse {
|
||||
message: if e.to_string().contains("timeout") {
|
||||
"Request timed out".to_string()
|
||||
} else {
|
||||
format!("Request failed: {}", e)
|
||||
},
|
||||
status: if e.to_string().contains("timeout") {
|
||||
StatusCode::GATEWAY_TIMEOUT
|
||||
} else {
|
||||
StatusCode::SERVICE_UNAVAILABLE
|
||||
},
|
||||
})?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(handle_response_error(response).await);
|
||||
}
|
||||
|
||||
let data: TranscribeResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| ErrorResponse::internal(format!("Failed to parse response: {}", e)))?;
|
||||
|
||||
Ok(data.text)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/dictation/transcribe",
|
||||
request_body = TranscribeRequest,
|
||||
responses(
|
||||
(status = 200, description = "Audio transcribed successfully", body = TranscribeResponse),
|
||||
(status = 400, description = "Invalid request (bad base64 or unsupported format)"),
|
||||
(status = 401, description = "Invalid API key"),
|
||||
(status = 412, description = "DictationProvider not configured"),
|
||||
(status = 413, description = "Audio file too large (max 25MB)"),
|
||||
(status = 429, description = "Rate limit exceeded"),
|
||||
(status = 500, description = "Internal server error"),
|
||||
(status = 502, description = "DictationProvider API error"),
|
||||
(status = 503, description = "Service unavailable"),
|
||||
(status = 504, description = "Request timeout")
|
||||
)
|
||||
)]
|
||||
pub async fn transcribe_dictation(
|
||||
Json(request): Json<TranscribeRequest>,
|
||||
) -> Result<Json<TranscribeResponse>, ErrorResponse> {
|
||||
let (audio_bytes, extension) = validate_audio(&request.audio, &request.mime_type)?;
|
||||
let provider_name = request.provider.as_str();
|
||||
let client = build_api_client(provider_name)?;
|
||||
|
||||
let text = match request.provider {
|
||||
DictationProvider::OpenAI => {
|
||||
transcribe_openai(audio_bytes, extension, &request.mime_type, &client).await?
|
||||
}
|
||||
DictationProvider::ElevenLabs => {
|
||||
transcribe_elevenlabs(audio_bytes, extension, &request.mime_type, &client).await?
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Json(TranscribeResponse { text }))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/dictation/config",
|
||||
responses(
|
||||
(status = 200, description = "Audio transcription provider configurations", body = HashMap<String, DictationProviderStatus>)
|
||||
)
|
||||
)]
|
||||
pub async fn get_dictation_config(
|
||||
) -> Result<Json<HashMap<String, DictationProviderStatus>>, ErrorResponse> {
|
||||
let config = goose::config::Config::global();
|
||||
let mut providers = HashMap::new();
|
||||
|
||||
for (name, def) in PROVIDERS.iter() {
|
||||
let configured = config.get_secret::<String>(def.config_key).is_ok();
|
||||
|
||||
let host = if let Some(host_key) = def.host_key {
|
||||
config
|
||||
.get(host_key, false)
|
||||
.ok()
|
||||
.and_then(|v| v.as_str().map(|s| s.to_string()))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
providers.insert(
|
||||
name.to_string(),
|
||||
DictationProviderStatus {
|
||||
configured,
|
||||
host,
|
||||
description: def.description.to_string(),
|
||||
uses_provider_config: def.uses_provider_config,
|
||||
settings_path: def.settings_path.map(|s| s.to_string()),
|
||||
config_key: if !def.uses_provider_config {
|
||||
Some(def.config_key.to_string())
|
||||
} else {
|
||||
None
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Ok(Json(providers))
|
||||
}
|
||||
|
||||
pub fn routes(state: Arc<AppState>) -> Router {
|
||||
Router::new()
|
||||
.route("/dictation/transcribe", post(transcribe_dictation))
|
||||
.route("/dictation/config", get(get_dictation_config))
|
||||
.with_state(state)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
pub mod action_required;
|
||||
pub mod agent;
|
||||
pub mod audio;
|
||||
pub mod config_management;
|
||||
pub mod dictation;
|
||||
pub mod errors;
|
||||
pub mod mcp_app_proxy;
|
||||
pub mod mcp_ui_proxy;
|
||||
@@ -28,7 +28,7 @@ pub fn configure(state: Arc<crate::state::AppState>, secret_key: String) -> Rout
|
||||
.merge(reply::routes(state.clone()))
|
||||
.merge(action_required::routes(state.clone()))
|
||||
.merge(agent::routes(state.clone()))
|
||||
.merge(audio::routes(state.clone()))
|
||||
.merge(dictation::routes(state.clone()))
|
||||
.merge(config_management::routes(state.clone()))
|
||||
.merge(prompts::routes())
|
||||
.merge(recipe::routes(state.clone()))
|
||||
|
||||
@@ -365,6 +365,11 @@ impl<'a> ApiRequestBuilder<'a> {
|
||||
Ok(request.json(payload).send().await?)
|
||||
}
|
||||
|
||||
pub async fn multipart_post(self, form: reqwest::multipart::Form) -> Result<Response> {
|
||||
let request = self.send_request(|url, client| client.post(url)).await?;
|
||||
Ok(request.multipart(form).send().await?)
|
||||
}
|
||||
|
||||
pub async fn api_get(self) -> Result<ApiResponse> {
|
||||
let response = self.response_get().await?;
|
||||
ApiResponse::from_response(response).await
|
||||
|
||||
@@ -1571,6 +1571,86 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/dictation/config": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"super::routes::dictation"
|
||||
],
|
||||
"operationId": "get_dictation_config",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Audio transcription provider configurations",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"$ref": "#/components/schemas/DictationProviderStatus"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/dictation/transcribe": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"super::routes::dictation"
|
||||
],
|
||||
"operationId": "transcribe_dictation",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/TranscribeRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Audio transcribed successfully",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/TranscribeResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid request (bad base64 or unsupported format)"
|
||||
},
|
||||
"401": {
|
||||
"description": "Invalid API key"
|
||||
},
|
||||
"412": {
|
||||
"description": "DictationProvider not configured"
|
||||
},
|
||||
"413": {
|
||||
"description": "Audio file too large (max 25MB)"
|
||||
},
|
||||
"429": {
|
||||
"description": "Rate limit exceeded"
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error"
|
||||
},
|
||||
"502": {
|
||||
"description": "DictationProvider API error"
|
||||
},
|
||||
"503": {
|
||||
"description": "Service unavailable"
|
||||
},
|
||||
"504": {
|
||||
"description": "Request timeout"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/handle_openrouter": {
|
||||
"post": {
|
||||
"tags": [
|
||||
@@ -3593,6 +3673,50 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"DictationProvider": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"openai",
|
||||
"elevenlabs"
|
||||
]
|
||||
},
|
||||
"DictationProviderStatus": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"configured",
|
||||
"description",
|
||||
"uses_provider_config"
|
||||
],
|
||||
"properties": {
|
||||
"config_key": {
|
||||
"type": "string",
|
||||
"description": "Config key name if uses_provider_config is false",
|
||||
"nullable": true
|
||||
},
|
||||
"configured": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the provider is fully configured and ready to use"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Description of what this provider does"
|
||||
},
|
||||
"host": {
|
||||
"type": "string",
|
||||
"description": "Custom host URL if configured (only for providers that support it)",
|
||||
"nullable": true
|
||||
},
|
||||
"settings_path": {
|
||||
"type": "string",
|
||||
"description": "Path to settings if uses_provider_config is true",
|
||||
"nullable": true
|
||||
},
|
||||
"uses_provider_config": {
|
||||
"type": "boolean",
|
||||
"description": "Whether this provider uses the main provider config (true) or has its own key (false)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"EmbeddedResource": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
@@ -6612,6 +6736,39 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"TranscribeRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"audio",
|
||||
"mime_type",
|
||||
"provider"
|
||||
],
|
||||
"properties": {
|
||||
"audio": {
|
||||
"type": "string",
|
||||
"description": "Base64 encoded audio data"
|
||||
},
|
||||
"mime_type": {
|
||||
"type": "string",
|
||||
"description": "MIME type of the audio (e.g., \"audio/webm\", \"audio/wav\")"
|
||||
},
|
||||
"provider": {
|
||||
"$ref": "#/components/schemas/DictationProvider"
|
||||
}
|
||||
}
|
||||
},
|
||||
"TranscribeResponse": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"text"
|
||||
],
|
||||
"properties": {
|
||||
"text": {
|
||||
"type": "string",
|
||||
"description": "Transcribed text from the audio"
|
||||
}
|
||||
}
|
||||
},
|
||||
"TunnelInfo": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -190,6 +190,35 @@ export type DetectProviderResponse = {
|
||||
provider_name: string;
|
||||
};
|
||||
|
||||
export type DictationProvider = 'openai' | 'elevenlabs';
|
||||
|
||||
export type DictationProviderStatus = {
|
||||
/**
|
||||
* Config key name if uses_provider_config is false
|
||||
*/
|
||||
config_key?: string | null;
|
||||
/**
|
||||
* Whether the provider is fully configured and ready to use
|
||||
*/
|
||||
configured: boolean;
|
||||
/**
|
||||
* Description of what this provider does
|
||||
*/
|
||||
description: string;
|
||||
/**
|
||||
* Custom host URL if configured (only for providers that support it)
|
||||
*/
|
||||
host?: string | null;
|
||||
/**
|
||||
* Path to settings if uses_provider_config is true
|
||||
*/
|
||||
settings_path?: string | null;
|
||||
/**
|
||||
* Whether this provider uses the main provider config (true) or has its own key (false)
|
||||
*/
|
||||
uses_provider_config: boolean;
|
||||
};
|
||||
|
||||
export type EmbeddedResource = {
|
||||
_meta?: {
|
||||
[key: string]: unknown;
|
||||
@@ -1173,6 +1202,25 @@ export type ToolResponse = {
|
||||
};
|
||||
};
|
||||
|
||||
export type TranscribeRequest = {
|
||||
/**
|
||||
* Base64 encoded audio data
|
||||
*/
|
||||
audio: string;
|
||||
/**
|
||||
* MIME type of the audio (e.g., "audio/webm", "audio/wav")
|
||||
*/
|
||||
mime_type: string;
|
||||
provider: DictationProvider;
|
||||
};
|
||||
|
||||
export type TranscribeResponse = {
|
||||
/**
|
||||
* Transcribed text from the audio
|
||||
*/
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type TunnelInfo = {
|
||||
hostname: string;
|
||||
secret: string;
|
||||
@@ -2456,6 +2504,79 @@ export type DiagnosticsResponses = {
|
||||
|
||||
export type DiagnosticsResponse = DiagnosticsResponses[keyof DiagnosticsResponses];
|
||||
|
||||
export type GetDictationConfigData = {
|
||||
body?: never;
|
||||
path?: never;
|
||||
query?: never;
|
||||
url: '/dictation/config';
|
||||
};
|
||||
|
||||
export type GetDictationConfigResponses = {
|
||||
/**
|
||||
* Audio transcription provider configurations
|
||||
*/
|
||||
200: {
|
||||
[key: string]: DictationProviderStatus;
|
||||
};
|
||||
};
|
||||
|
||||
export type GetDictationConfigResponse = GetDictationConfigResponses[keyof GetDictationConfigResponses];
|
||||
|
||||
export type TranscribeDictationData = {
|
||||
body: TranscribeRequest;
|
||||
path?: never;
|
||||
query?: never;
|
||||
url: '/dictation/transcribe';
|
||||
};
|
||||
|
||||
export type TranscribeDictationErrors = {
|
||||
/**
|
||||
* Invalid request (bad base64 or unsupported format)
|
||||
*/
|
||||
400: unknown;
|
||||
/**
|
||||
* Invalid API key
|
||||
*/
|
||||
401: unknown;
|
||||
/**
|
||||
* DictationProvider not configured
|
||||
*/
|
||||
412: unknown;
|
||||
/**
|
||||
* Audio file too large (max 25MB)
|
||||
*/
|
||||
413: unknown;
|
||||
/**
|
||||
* Rate limit exceeded
|
||||
*/
|
||||
429: unknown;
|
||||
/**
|
||||
* Internal server error
|
||||
*/
|
||||
500: unknown;
|
||||
/**
|
||||
* DictationProvider API error
|
||||
*/
|
||||
502: unknown;
|
||||
/**
|
||||
* Service unavailable
|
||||
*/
|
||||
503: unknown;
|
||||
/**
|
||||
* Request timeout
|
||||
*/
|
||||
504: unknown;
|
||||
};
|
||||
|
||||
export type TranscribeDictationResponses = {
|
||||
/**
|
||||
* Audio transcribed successfully
|
||||
*/
|
||||
200: TranscribeResponse;
|
||||
};
|
||||
|
||||
export type TranscribeDictationResponse = TranscribeDictationResponses[keyof TranscribeDictationResponses];
|
||||
|
||||
export type StartOpenrouterSetupData = {
|
||||
body?: never;
|
||||
path?: never;
|
||||
|
||||
@@ -16,12 +16,10 @@ import { BottomMenuExtensionSelection } from './bottom_menu/BottomMenuExtensionS
|
||||
import { AlertType, useAlerts } from './alerts';
|
||||
import { useConfig } from './ConfigContext';
|
||||
import { useModelAndProvider } from './ModelAndProviderContext';
|
||||
import { useWhisper } from '../hooks/useWhisper';
|
||||
import { DICTATION_PROVIDER_ELEVENLABS } from '../hooks/dictationConstants';
|
||||
import { WaveformVisualizer } from './WaveformVisualizer';
|
||||
import { useAudioRecorder } from '../hooks/useAudioRecorder';
|
||||
import { toastError } from '../toasts';
|
||||
import MentionPopover, { DisplayItemWithMatch } from './MentionPopover';
|
||||
import { COST_TRACKING_ENABLED, VOICE_DICTATION_ELEVENLABS_ENABLED } from '../updates';
|
||||
import { COST_TRACKING_ENABLED } from '../updates';
|
||||
import { CostTracker } from './bottom_menu/CostTracker';
|
||||
import { DroppedFile, useFileDrop } from '../hooks/useFileDrop';
|
||||
import { Recipe } from '../recipe';
|
||||
@@ -254,19 +252,17 @@ export default function ChatInput({
|
||||
selectFile: (index: number) => void;
|
||||
}>(null);
|
||||
|
||||
// Whisper hook for voice dictation
|
||||
// Audio recorder hook for voice dictation
|
||||
const {
|
||||
isEnabled,
|
||||
dictationProvider,
|
||||
isRecording,
|
||||
isTranscribing,
|
||||
canUseDictation,
|
||||
audioContext,
|
||||
analyser,
|
||||
startRecording,
|
||||
stopRecording,
|
||||
recordingDuration,
|
||||
estimatedSize,
|
||||
dictationSettings,
|
||||
} = useWhisper({
|
||||
} = useAudioRecorder({
|
||||
onTranscription: (text) => {
|
||||
trackVoiceDictation('transcribed');
|
||||
// Append transcribed text to the current input
|
||||
@@ -275,18 +271,12 @@ export default function ChatInput({
|
||||
setValue(newValue);
|
||||
textAreaRef.current?.focus();
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorType = error.name || 'DictationError';
|
||||
onError: (message) => {
|
||||
const errorType = 'DictationError';
|
||||
trackVoiceDictation('error', undefined, errorType);
|
||||
toastError({
|
||||
title: 'Dictation Error',
|
||||
msg: error.message,
|
||||
});
|
||||
},
|
||||
onSizeWarning: (sizeMB) => {
|
||||
toastError({
|
||||
title: 'Recording Size Warning',
|
||||
msg: `Recording is ${sizeMB.toFixed(1)}MB. Maximum size is 25MB.`,
|
||||
msg: message,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -1236,26 +1226,25 @@ export default function ChatInput({
|
||||
maxHeight: `${maxHeight}px`,
|
||||
overflowY: 'auto',
|
||||
opacity: isRecording ? 0 : 1,
|
||||
paddingRight: dictationSettings?.enabled ? '180px' : '120px',
|
||||
paddingRight: dictationProvider ? '180px' : '120px',
|
||||
}}
|
||||
className="w-full outline-none border-none focus:ring-0 bg-transparent px-3 pt-3 pb-1.5 text-sm resize-none text-textStandard placeholder:text-textPlaceholder"
|
||||
/>
|
||||
{isRecording && (
|
||||
<div className="absolute inset-0 flex items-center pl-4 pr-32 pt-3 pb-1.5">
|
||||
<WaveformVisualizer
|
||||
audioContext={audioContext}
|
||||
analyser={analyser}
|
||||
isRecording={isRecording}
|
||||
/>
|
||||
<div className="flex items-center gap-2 text-textSubtle">
|
||||
<span className="inline-block w-2 h-2 bg-red-500 rounded-full animate-pulse" />
|
||||
<span>Recording...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Inline action buttons - absolutely positioned on the right */}
|
||||
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1">
|
||||
{/* Microphone button - show only if dictation is enabled */}
|
||||
{dictationSettings?.enabled && (
|
||||
{/* Microphone button - show only if provider is selected */}
|
||||
{dictationProvider && (
|
||||
<>
|
||||
{!canUseDictation ? (
|
||||
{!isEnabled ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex">
|
||||
@@ -1273,22 +1262,16 @@ export default function ChatInput({
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{dictationSettings.provider === 'openai' ? (
|
||||
{dictationProvider === 'openai' ? (
|
||||
<p>
|
||||
OpenAI API key is not configured. Set it up in <b>Settings</b> {'>'}{' '}
|
||||
<b>Models.</b>
|
||||
</p>
|
||||
) : VOICE_DICTATION_ELEVENLABS_ENABLED &&
|
||||
dictationSettings.provider === DICTATION_PROVIDER_ELEVENLABS ? (
|
||||
) : dictationProvider === 'elevenlabs' ? (
|
||||
<p>
|
||||
ElevenLabs API key is not configured. Set it up in <b>Settings</b> {'>'}{' '}
|
||||
<b>Chat</b> {'>'} <b>Voice Dictation.</b>
|
||||
</p>
|
||||
) : dictationSettings.provider === null ? (
|
||||
<p>
|
||||
Dictation is not configured. Configure it in <b>Settings</b> {'>'}{' '}
|
||||
<b>Chat</b> {'>'} <b>Voice Dictation.</b>
|
||||
</p>
|
||||
) : (
|
||||
<p>Dictation provider is not properly configured.</p>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ModeSection } from '../mode/ModeSection';
|
||||
import DictationSection from '../dictation/DictationSection';
|
||||
import { DictationSettings } from '../dictation/DictationSettings';
|
||||
import { SecurityToggle } from '../security/SecurityToggle';
|
||||
import { ResponseStylesSection } from '../response_styles/ResponseStylesSection';
|
||||
import { GoosehintsSection } from './GoosehintsSection';
|
||||
@@ -21,7 +21,14 @@ export default function ChatSettingsSection() {
|
||||
|
||||
<Card className="pb-2 rounded-lg">
|
||||
<CardContent className="px-2">
|
||||
<SecurityToggle />
|
||||
<GoosehintsSection />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="pb-2 rounded-lg">
|
||||
<CardContent className="px-2">
|
||||
<DictationSettings />
|
||||
<SpellcheckToggle />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -37,14 +44,7 @@ export default function ChatSettingsSection() {
|
||||
|
||||
<Card className="pb-2 rounded-lg">
|
||||
<CardContent className="px-2">
|
||||
<DictationSection />
|
||||
<SpellcheckToggle />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="pb-2 rounded-lg">
|
||||
<CardContent className="px-2">
|
||||
<GoosehintsSection />
|
||||
<SecurityToggle />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import { VoiceDictationToggle } from './VoiceDictationToggle';
|
||||
|
||||
export default function DictationSection() {
|
||||
return <VoiceDictationToggle />;
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import { DictationProvider, getDictationConfig, DictationProviderStatus } from '../../../api';
|
||||
import { useConfig } from '../../ConfigContext';
|
||||
import { Input } from '../../ui/input';
|
||||
import { Button } from '../../ui/button';
|
||||
import { trackSettingToggled } from '../../../utils/analytics';
|
||||
|
||||
export const DictationSettings = () => {
|
||||
const [provider, setProvider] = useState<DictationProvider | null>(null);
|
||||
const [showProviderDropdown, setShowProviderDropdown] = useState(false);
|
||||
const [providerStatuses, setProviderStatuses] = useState<Record<string, DictationProviderStatus>>(
|
||||
{}
|
||||
);
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
const [isEditingKey, setIsEditingKey] = useState(false);
|
||||
const [keyValidationError, setKeyValidationError] = useState('');
|
||||
const { read, upsert, remove } = useConfig();
|
||||
|
||||
useEffect(() => {
|
||||
const loadSettings = async () => {
|
||||
const providerValue = await read('voice_dictation_provider', false);
|
||||
const loadedProvider: DictationProvider | null = (providerValue as DictationProvider) || null;
|
||||
setProvider(loadedProvider);
|
||||
|
||||
const audioConfig = await getDictationConfig();
|
||||
setProviderStatuses(audioConfig.data || {});
|
||||
};
|
||||
|
||||
loadSettings();
|
||||
}, [read]);
|
||||
|
||||
const saveProvider = async (newProvider: DictationProvider | null) => {
|
||||
console.log('Saving dictation provider to backend config:', newProvider);
|
||||
setProvider(newProvider);
|
||||
await upsert('voice_dictation_provider', newProvider || '', false);
|
||||
trackSettingToggled('voice_dictation', newProvider !== null);
|
||||
};
|
||||
|
||||
const handleProviderChange = (newProvider: DictationProvider | null) => {
|
||||
saveProvider(newProvider);
|
||||
setShowProviderDropdown(false);
|
||||
};
|
||||
|
||||
const handleDropdownToggle = async () => {
|
||||
const newShowState = !showProviderDropdown;
|
||||
setShowProviderDropdown(newShowState);
|
||||
|
||||
if (newShowState) {
|
||||
const audioConfig = await getDictationConfig();
|
||||
setProviderStatuses(audioConfig.data || {});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveKey = async () => {
|
||||
if (!provider) return;
|
||||
const providerConfig = providerStatuses[provider];
|
||||
if (!providerConfig || providerConfig.uses_provider_config) return;
|
||||
|
||||
const trimmedKey = apiKey.trim();
|
||||
if (!trimmedKey) {
|
||||
setKeyValidationError('API key is required');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const keyName = providerConfig.config_key!;
|
||||
await upsert(keyName, trimmedKey, true);
|
||||
setApiKey('');
|
||||
setKeyValidationError('');
|
||||
setIsEditingKey(false);
|
||||
|
||||
const audioConfig = await getDictationConfig();
|
||||
setProviderStatuses(audioConfig.data || {});
|
||||
} catch (error) {
|
||||
console.error('Error saving API key:', error);
|
||||
setKeyValidationError('Failed to save API key');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveKey = async () => {
|
||||
if (!provider) return;
|
||||
const providerConfig = providerStatuses[provider];
|
||||
if (!providerConfig || providerConfig.uses_provider_config) return;
|
||||
|
||||
try {
|
||||
const keyName = providerConfig.config_key!;
|
||||
await remove(keyName, true);
|
||||
setApiKey('');
|
||||
setKeyValidationError('');
|
||||
setIsEditingKey(false);
|
||||
|
||||
const audioConfig = await getDictationConfig();
|
||||
setProviderStatuses(audioConfig.data || {});
|
||||
} catch (error) {
|
||||
console.error('Error removing API key:', error);
|
||||
setKeyValidationError('Failed to remove API key');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setApiKey('');
|
||||
setKeyValidationError('');
|
||||
setIsEditingKey(false);
|
||||
};
|
||||
|
||||
const getProviderLabel = (provider: DictationProvider | null): string => {
|
||||
if (!provider) return 'Disabled';
|
||||
return provider.charAt(0).toUpperCase() + provider.slice(1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between py-2 px-2 hover:bg-background-muted rounded-lg transition-all">
|
||||
<div>
|
||||
<h3 className="text-text-default">Voice Dictation Provider</h3>
|
||||
<p className="text-xs text-text-muted max-w-md mt-[2px]">
|
||||
Choose how voice is converted to text
|
||||
</p>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={handleDropdownToggle}
|
||||
className="flex items-center gap-2 px-3 py-1.5 text-sm border border-border-subtle rounded-md hover:border-border-default transition-colors text-text-default bg-background-default"
|
||||
>
|
||||
{getProviderLabel(provider)}
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{showProviderDropdown && (
|
||||
<div className="absolute right-0 mt-1 w-max min-w-[250px] max-w-[350px] bg-background-default border border-border-default rounded-md shadow-lg z-50">
|
||||
<button
|
||||
onClick={() => handleProviderChange(null)}
|
||||
className="w-full px-3 py-2 text-left text-sm transition-colors hover:bg-background-subtle text-text-default whitespace-nowrap first:rounded-t-md"
|
||||
>
|
||||
<span className="flex items-center justify-between gap-2">
|
||||
<span>Disabled</span>
|
||||
{provider === null && <span>✓</span>}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{(Object.keys(providerStatuses) as DictationProvider[]).map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => handleProviderChange(p)}
|
||||
className="w-full px-3 py-2 text-left text-sm transition-colors hover:bg-background-subtle text-text-default whitespace-nowrap last:rounded-b-md"
|
||||
>
|
||||
<span className="flex items-center justify-between gap-2">
|
||||
<span>
|
||||
{getProviderLabel(p)}
|
||||
{!providerStatuses[p]?.configured && (
|
||||
<span className="text-xs ml-1 text-text-muted">(not configured)</span>
|
||||
)}
|
||||
</span>
|
||||
{provider === p && <span>✓</span>}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{provider && providerStatuses[provider] && (
|
||||
<>
|
||||
<div className="py-2 px-2">
|
||||
<p className="text-xs text-text-muted">{providerStatuses[provider].description}</p>
|
||||
</div>
|
||||
|
||||
{providerStatuses[provider].uses_provider_config ? (
|
||||
<div className="py-2 px-2 bg-background-subtle rounded-lg">
|
||||
{!providerStatuses[provider].configured ? (
|
||||
<p className="text-xs text-text-muted">
|
||||
Configure the API key in <b>{providerStatuses[provider].settings_path}</b>
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-green-600">
|
||||
✓ Configured in {providerStatuses[provider].settings_path}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-2 px-2 bg-background-subtle rounded-lg">
|
||||
<div className="mb-2">
|
||||
<h4 className="text-text-default text-sm">API Key</h4>
|
||||
<p className="text-xs text-text-muted mt-[2px]">
|
||||
Required for transcription
|
||||
{providerStatuses[provider]?.configured && (
|
||||
<span className="text-green-600 ml-2">(Configured)</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!isEditingKey ? (
|
||||
<Button variant="outline" size="sm" onClick={() => setIsEditingKey(true)}>
|
||||
{providerStatuses[provider]?.configured ? 'Update API Key' : 'Add API Key'}
|
||||
</Button>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(e) => {
|
||||
setApiKey(e.target.value);
|
||||
if (keyValidationError) setKeyValidationError('');
|
||||
}}
|
||||
placeholder="Enter your API key"
|
||||
className="max-w-md"
|
||||
autoFocus
|
||||
/>
|
||||
{keyValidationError && (
|
||||
<p className="text-xs text-red-600 mt-1">{keyValidationError}</p>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={handleSaveKey}>
|
||||
Save
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleCancelEdit}>
|
||||
Cancel
|
||||
</Button>
|
||||
{providerStatuses[provider]?.configured && (
|
||||
<Button variant="destructive" size="sm" onClick={handleRemoveKey}>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,128 +0,0 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Input } from '../../ui/input';
|
||||
import { Button } from '../../ui/button';
|
||||
import { useConfig } from '../../ConfigContext';
|
||||
import { ELEVENLABS_API_KEY, isSecretKeyConfigured } from '../../../hooks/dictationConstants';
|
||||
import { setElevenLabsKeyCache } from '../../../hooks/useDictationSettings';
|
||||
|
||||
export const ElevenLabsKeyInput = () => {
|
||||
const [elevenLabsApiKey, setElevenLabsApiKey] = useState('');
|
||||
const [isLoadingKey, setIsLoadingKey] = useState(false);
|
||||
const [hasElevenLabsKey, setHasElevenLabsKey] = useState(false);
|
||||
const [validationError, setValidationError] = useState('');
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const { upsert, read, remove } = useConfig();
|
||||
|
||||
const loadKey = useCallback(async () => {
|
||||
setIsLoadingKey(true);
|
||||
try {
|
||||
const response = await read(ELEVENLABS_API_KEY, true);
|
||||
const hasKey = isSecretKeyConfigured(response);
|
||||
setHasElevenLabsKey(hasKey);
|
||||
setElevenLabsKeyCache(hasKey);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setElevenLabsKeyCache(false);
|
||||
} finally {
|
||||
setIsLoadingKey(false);
|
||||
}
|
||||
}, [read]);
|
||||
|
||||
useEffect(() => {
|
||||
loadKey();
|
||||
}, [loadKey]);
|
||||
|
||||
const handleElevenLabsKeyChange = (key: string) => {
|
||||
setElevenLabsApiKey(key);
|
||||
if (validationError) {
|
||||
setValidationError('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const trimmedKey = elevenLabsApiKey.trim();
|
||||
|
||||
if (!trimmedKey) {
|
||||
setValidationError('API key is required');
|
||||
return;
|
||||
}
|
||||
|
||||
await upsert(ELEVENLABS_API_KEY, trimmedKey, true);
|
||||
setElevenLabsApiKey('');
|
||||
setValidationError('');
|
||||
setIsEditing(false);
|
||||
await loadKey();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setValidationError('Failed to save API key');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = async () => {
|
||||
try {
|
||||
await remove(ELEVENLABS_API_KEY, true);
|
||||
await loadKey();
|
||||
setElevenLabsApiKey('');
|
||||
setValidationError('');
|
||||
setIsEditing(false);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setValidationError('Failed to remove API key');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setElevenLabsApiKey('');
|
||||
setValidationError('');
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="py-2 px-2 bg-background-subtle rounded-lg">
|
||||
<div className="mb-2">
|
||||
<h4 className="text-text-default text-sm">ElevenLabs API Key</h4>
|
||||
<p className="text-xs text-text-muted mt-[2px]">
|
||||
Required for ElevenLabs voice recognition
|
||||
{hasElevenLabsKey && <span className="text-green-600 ml-2">(Configured)</span>}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!isEditing ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsEditing(true)}
|
||||
disabled={isLoadingKey}
|
||||
>
|
||||
{hasElevenLabsKey ? 'Update API Key' : 'Add API Key'}
|
||||
</Button>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
type="password"
|
||||
value={elevenLabsApiKey}
|
||||
onChange={(e) => handleElevenLabsKeyChange(e.target.value)}
|
||||
placeholder="Enter your ElevenLabs API key"
|
||||
className="max-w-md"
|
||||
autoFocus
|
||||
/>
|
||||
{validationError && <p className="text-xs text-red-600 mt-1">{validationError}</p>}
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={handleSave}>
|
||||
Save
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
{hasElevenLabsKey && (
|
||||
<Button variant="destructive" size="sm" onClick={handleRemove}>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,41 +0,0 @@
|
||||
import { DictationProvider } from '../../../hooks/useDictationSettings';
|
||||
import { DICTATION_PROVIDER_ELEVENLABS } from '../../../hooks/dictationConstants';
|
||||
import { VOICE_DICTATION_ELEVENLABS_ENABLED } from '../../../updates';
|
||||
|
||||
interface ProviderInfoProps {
|
||||
provider: DictationProvider;
|
||||
}
|
||||
|
||||
export const ProviderInfo = ({ provider }: ProviderInfoProps) => {
|
||||
if (!provider) return null;
|
||||
|
||||
return (
|
||||
<div className="p-3 bg-background-subtle rounded-md">
|
||||
{provider === 'openai' && (
|
||||
<p className="text-xs text-text-muted">
|
||||
Uses OpenAI's Whisper API for high-quality transcription. Requires an OpenAI API key
|
||||
configured in the Models section.
|
||||
</p>
|
||||
)}
|
||||
{VOICE_DICTATION_ELEVENLABS_ENABLED && provider === DICTATION_PROVIDER_ELEVENLABS && (
|
||||
<div>
|
||||
<p className="text-xs text-text-muted">
|
||||
Uses ElevenLabs speech-to-text API for high-quality transcription.
|
||||
</p>
|
||||
<p className="text-xs text-text-muted mt-2">
|
||||
<strong>Features:</strong>
|
||||
</p>
|
||||
<ul className="text-xs text-text-muted ml-4 mt-1 list-disc">
|
||||
<li>Advanced voice processing</li>
|
||||
<li>High accuracy transcription</li>
|
||||
<li>Multiple language support</li>
|
||||
<li>Fast processing</li>
|
||||
</ul>
|
||||
<p className="text-xs text-text-muted mt-2">
|
||||
<strong>Note:</strong> Requires an ElevenLabs API key with speech-to-text access.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,128 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import { DictationProvider, DictationSettings } from '../../../hooks/useDictationSettings';
|
||||
import {
|
||||
DICTATION_PROVIDER_OPENAI,
|
||||
DICTATION_PROVIDER_ELEVENLABS,
|
||||
} from '../../../hooks/dictationConstants';
|
||||
import { useConfig } from '../../ConfigContext';
|
||||
import { ElevenLabsKeyInput } from './ElevenLabsKeyInput';
|
||||
import { ProviderInfo } from './ProviderInfo';
|
||||
import { VOICE_DICTATION_ELEVENLABS_ENABLED } from '../../../updates';
|
||||
|
||||
interface ProviderSelectorProps {
|
||||
settings: DictationSettings;
|
||||
onProviderChange: (provider: DictationProvider) => void;
|
||||
}
|
||||
|
||||
export const ProviderSelector = ({ settings, onProviderChange }: ProviderSelectorProps) => {
|
||||
const [hasOpenAIKey, setHasOpenAIKey] = useState(false);
|
||||
const [showProviderDropdown, setShowProviderDropdown] = useState(false);
|
||||
const { getProviders } = useConfig();
|
||||
|
||||
useEffect(() => {
|
||||
const checkOpenAIKey = async () => {
|
||||
try {
|
||||
const providers = await getProviders(false);
|
||||
const openAIProvider = providers.find((p) => p.name === 'openai');
|
||||
setHasOpenAIKey(openAIProvider?.is_configured || false);
|
||||
} catch (error) {
|
||||
console.error('Error checking OpenAI configuration:', error);
|
||||
setHasOpenAIKey(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkOpenAIKey();
|
||||
}, [getProviders]);
|
||||
|
||||
const handleDropdownToggle = async () => {
|
||||
const newShowState = !showProviderDropdown;
|
||||
setShowProviderDropdown(newShowState);
|
||||
|
||||
if (newShowState) {
|
||||
try {
|
||||
const providers = await getProviders(true);
|
||||
const openAIProvider = providers.find((p) => p.name === 'openai');
|
||||
const isConfigured = !!openAIProvider?.is_configured;
|
||||
setHasOpenAIKey(isConfigured);
|
||||
} catch (error) {
|
||||
console.error('Error checking OpenAI configuration:', error);
|
||||
setHasOpenAIKey(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleProviderChange = (provider: DictationProvider) => {
|
||||
onProviderChange(provider);
|
||||
setShowProviderDropdown(false);
|
||||
};
|
||||
|
||||
const getProviderLabel = (provider: DictationProvider): string => {
|
||||
switch (provider) {
|
||||
case DICTATION_PROVIDER_OPENAI:
|
||||
return 'OpenAI Whisper';
|
||||
case DICTATION_PROVIDER_ELEVENLABS:
|
||||
return 'ElevenLabs';
|
||||
default:
|
||||
return 'None (disabled)';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between py-2 px-2 hover:bg-background-muted rounded-lg transition-all">
|
||||
<div>
|
||||
<h3 className="text-text-default">Dictation Provider</h3>
|
||||
<p className="text-xs text-text-muted max-w-md mt-[2px]">
|
||||
Choose how voice is converted to text
|
||||
</p>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={handleDropdownToggle}
|
||||
className="flex items-center gap-2 px-3 py-1.5 text-sm border border-border-subtle rounded-md hover:border-border-default transition-colors text-text-default bg-background-default"
|
||||
>
|
||||
{getProviderLabel(settings.provider)}
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{showProviderDropdown && (
|
||||
<div className="absolute right-0 mt-1 w-max min-w-[250px] max-w-[350px] bg-background-default border border-border-default rounded-md shadow-lg z-50">
|
||||
<button
|
||||
onClick={() => handleProviderChange(DICTATION_PROVIDER_OPENAI)}
|
||||
className={`w-full px-3 py-2 text-left text-sm transition-colors hover:bg-background-subtle text-text-default whitespace-nowrap ${!VOICE_DICTATION_ELEVENLABS_ENABLED ? 'first:rounded-t-md last:rounded-b-md' : 'first:rounded-t-md'}`}
|
||||
>
|
||||
<span className="flex items-center justify-between gap-2">
|
||||
<span>
|
||||
OpenAI Whisper
|
||||
{!hasOpenAIKey && (
|
||||
<span className="text-xs ml-1 text-text-muted">(not configured)</span>
|
||||
)}
|
||||
</span>
|
||||
{settings.provider === DICTATION_PROVIDER_OPENAI && <span>✓</span>}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{VOICE_DICTATION_ELEVENLABS_ENABLED && (
|
||||
<button
|
||||
onClick={() => handleProviderChange(DICTATION_PROVIDER_ELEVENLABS)}
|
||||
className="w-full px-3 py-2 text-left text-sm hover:bg-background-subtle transition-colors text-text-default last:rounded-b-md whitespace-nowrap"
|
||||
>
|
||||
<span className="flex items-center justify-between gap-2">
|
||||
<span>ElevenLabs</span>
|
||||
{settings.provider === DICTATION_PROVIDER_ELEVENLABS && <span>✓</span>}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{VOICE_DICTATION_ELEVENLABS_ENABLED &&
|
||||
settings.provider === DICTATION_PROVIDER_ELEVENLABS && <ElevenLabsKeyInput />}
|
||||
|
||||
<ProviderInfo provider={settings.provider} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,97 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Switch } from '../../ui/switch';
|
||||
import { DictationProvider, DictationSettings } from '../../../hooks/useDictationSettings';
|
||||
import {
|
||||
DICTATION_SETTINGS_KEY,
|
||||
DICTATION_PROVIDER_OPENAI,
|
||||
DICTATION_PROVIDER_ELEVENLABS,
|
||||
getDefaultDictationSettings,
|
||||
} from '../../../hooks/dictationConstants';
|
||||
import { useConfig } from '../../ConfigContext';
|
||||
import { ProviderSelector } from './ProviderSelector';
|
||||
import { VOICE_DICTATION_ELEVENLABS_ENABLED } from '../../../updates';
|
||||
import { trackSettingToggled } from '../../../utils/analytics';
|
||||
|
||||
export const VoiceDictationToggle = () => {
|
||||
const [settings, setSettings] = useState<DictationSettings>({
|
||||
enabled: false,
|
||||
provider: null,
|
||||
});
|
||||
const { getProviders } = useConfig();
|
||||
|
||||
useEffect(() => {
|
||||
const loadSettings = async () => {
|
||||
const savedSettings = localStorage.getItem(DICTATION_SETTINGS_KEY);
|
||||
|
||||
let loadedSettings: DictationSettings;
|
||||
|
||||
if (savedSettings) {
|
||||
const parsed = JSON.parse(savedSettings);
|
||||
loadedSettings = parsed;
|
||||
|
||||
// If ElevenLabs is disabled and user has it selected, reset to OpenAI
|
||||
if (
|
||||
!VOICE_DICTATION_ELEVENLABS_ENABLED &&
|
||||
loadedSettings.provider === DICTATION_PROVIDER_ELEVENLABS
|
||||
) {
|
||||
loadedSettings = {
|
||||
...loadedSettings,
|
||||
provider: DICTATION_PROVIDER_OPENAI,
|
||||
};
|
||||
localStorage.setItem(DICTATION_SETTINGS_KEY, JSON.stringify(loadedSettings));
|
||||
}
|
||||
} else {
|
||||
loadedSettings = await getDefaultDictationSettings(getProviders);
|
||||
}
|
||||
|
||||
setSettings(loadedSettings);
|
||||
};
|
||||
|
||||
loadSettings();
|
||||
}, [getProviders]);
|
||||
|
||||
const saveSettings = (newSettings: DictationSettings) => {
|
||||
console.log('Saving dictation settings to localStorage:', newSettings);
|
||||
setSettings(newSettings);
|
||||
localStorage.setItem(DICTATION_SETTINGS_KEY, JSON.stringify(newSettings));
|
||||
};
|
||||
|
||||
const handleToggle = (enabled: boolean) => {
|
||||
saveSettings({
|
||||
...settings,
|
||||
enabled,
|
||||
provider: settings.provider === null ? DICTATION_PROVIDER_OPENAI : settings.provider,
|
||||
});
|
||||
trackSettingToggled('voice_dictation', enabled);
|
||||
};
|
||||
|
||||
const handleProviderChange = (provider: DictationProvider) => {
|
||||
saveSettings({ ...settings, provider });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between py-2 px-2 hover:bg-background-muted rounded-lg transition-all">
|
||||
<div>
|
||||
<h3 className="text-text-default">Enable Voice Dictation</h3>
|
||||
<p className="text-xs text-text-muted max-w-md mt-[2px]">
|
||||
Show microphone button for voice input
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Switch checked={settings.enabled} onCheckedChange={handleToggle} variant="mono" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`overflow-visible transition-all duration-300 ease-in-out ${
|
||||
settings.enabled ? 'max-h-96 opacity-100 mt-2' : 'max-h-0 opacity-0 mt-0'
|
||||
}`}
|
||||
>
|
||||
<div className="space-y-3 pb-2">
|
||||
<ProviderSelector settings={settings} onProviderChange={handleProviderChange} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,31 +0,0 @@
|
||||
import { DictationSettings, DictationProvider } from './useDictationSettings';
|
||||
|
||||
export const DICTATION_SETTINGS_KEY = 'dictation_settings';
|
||||
export const ELEVENLABS_API_KEY = 'ELEVENLABS_API_KEY';
|
||||
export const DICTATION_PROVIDER_OPENAI = 'openai' as const;
|
||||
export const DICTATION_PROVIDER_ELEVENLABS = 'elevenlabs' as const;
|
||||
|
||||
export const isSecretKeyConfigured = (response: unknown): boolean =>
|
||||
typeof response === 'object' &&
|
||||
response !== null &&
|
||||
'maskedValue' in response &&
|
||||
!!(response as { maskedValue: string }).maskedValue;
|
||||
|
||||
export const getDefaultDictationSettings = async (
|
||||
getProviders: (refresh: boolean) => Promise<Array<{ name: string; is_configured: boolean }>>
|
||||
): Promise<DictationSettings> => {
|
||||
const providers = await getProviders(false);
|
||||
const openAIProvider = providers.find((p) => p.name === 'openai');
|
||||
|
||||
if (openAIProvider && openAIProvider.is_configured) {
|
||||
return {
|
||||
enabled: true,
|
||||
provider: DICTATION_PROVIDER_OPENAI,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
enabled: false,
|
||||
provider: null as DictationProvider,
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,219 @@
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { transcribeDictation, getDictationConfig, DictationProvider } from '../api';
|
||||
import { useConfig } from '../components/ConfigContext';
|
||||
import { errorMessage } from '../utils/conversionUtils';
|
||||
|
||||
interface UseAudioRecorderOptions {
|
||||
onTranscription: (text: string) => void;
|
||||
onError: (message: string) => void;
|
||||
}
|
||||
|
||||
const MAX_AUDIO_SIZE_MB = 25;
|
||||
const MAX_RECORDING_DURATION_SECONDS = 10 * 60;
|
||||
|
||||
export const useAudioRecorder = ({ onTranscription, onError }: UseAudioRecorderOptions) => {
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [isTranscribing, setIsTranscribing] = useState(false);
|
||||
const [recordingDuration, setRecordingDuration] = useState(0);
|
||||
const [estimatedSize, setEstimatedSize] = useState(0);
|
||||
const [isEnabled, setIsEnabled] = useState(false);
|
||||
const [provider, setProvider] = useState<DictationProvider | null>(null);
|
||||
|
||||
const { read } = useConfig();
|
||||
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const audioChunksRef = useRef<Blob[]>([]);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const durationIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const checkProviderConfig = async () => {
|
||||
try {
|
||||
const providerValue = await read('voice_dictation_provider', false);
|
||||
const preferredProvider = (providerValue as DictationProvider) || null;
|
||||
|
||||
if (!preferredProvider) {
|
||||
setIsEnabled(false);
|
||||
setProvider(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const audioConfigResponse = await getDictationConfig();
|
||||
const providerStatus = audioConfigResponse.data?.[preferredProvider];
|
||||
|
||||
setIsEnabled(!!providerStatus?.configured);
|
||||
setProvider(preferredProvider);
|
||||
} catch (error) {
|
||||
console.error('Error checking audio config:', error);
|
||||
setIsEnabled(false);
|
||||
setProvider(null);
|
||||
}
|
||||
};
|
||||
|
||||
checkProviderConfig();
|
||||
}, [read]);
|
||||
|
||||
const stopRecording = useCallback(() => {
|
||||
setIsRecording(false);
|
||||
|
||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
|
||||
mediaRecorderRef.current.stop();
|
||||
}
|
||||
|
||||
if (durationIntervalRef.current) {
|
||||
clearInterval(durationIntervalRef.current);
|
||||
durationIntervalRef.current = null;
|
||||
}
|
||||
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach((track) => track.stop());
|
||||
streamRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (durationIntervalRef.current) {
|
||||
clearInterval(durationIntervalRef.current);
|
||||
}
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach((track) => track.stop());
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const transcribeAudio = useCallback(
|
||||
async (audioBlob: Blob) => {
|
||||
if (!provider) {
|
||||
onError('No transcription provider configured');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsTranscribing(true);
|
||||
|
||||
try {
|
||||
const sizeMB = audioBlob.size / (1024 * 1024);
|
||||
if (sizeMB > MAX_AUDIO_SIZE_MB) {
|
||||
onError(
|
||||
`Audio file too large (${sizeMB.toFixed(1)}MB). Maximum size is ${MAX_AUDIO_SIZE_MB}MB.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
const base64Audio = await new Promise<string>((resolve, reject) => {
|
||||
reader.onloadend = () => {
|
||||
const base64 = reader.result as string;
|
||||
resolve(base64.split(',')[1]);
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(audioBlob);
|
||||
});
|
||||
|
||||
const mimeType = audioBlob.type;
|
||||
if (!mimeType) {
|
||||
throw new Error('Unable to determine audio format');
|
||||
}
|
||||
|
||||
const result = await transcribeDictation({
|
||||
body: {
|
||||
audio: base64Audio,
|
||||
mime_type: mimeType,
|
||||
provider: provider,
|
||||
},
|
||||
throwOnError: true,
|
||||
});
|
||||
|
||||
if (result.data?.text) {
|
||||
onTranscription(result.data.text);
|
||||
}
|
||||
} catch (error) {
|
||||
onError(errorMessage(error));
|
||||
} finally {
|
||||
setIsTranscribing(false);
|
||||
setRecordingDuration(0);
|
||||
setEstimatedSize(0);
|
||||
}
|
||||
},
|
||||
[provider, onTranscription, onError]
|
||||
);
|
||||
|
||||
const startRecording = useCallback(async () => {
|
||||
if (!isEnabled) {
|
||||
onError('Voice dictation is not enabled');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: true,
|
||||
},
|
||||
});
|
||||
streamRef.current = stream;
|
||||
|
||||
const supportedTypes = ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4', 'audio/wav'];
|
||||
const mimeType = supportedTypes.find((type) => MediaRecorder.isTypeSupported(type)) || '';
|
||||
|
||||
const mediaRecorder = new MediaRecorder(stream, mimeType ? { mimeType } : {});
|
||||
mediaRecorderRef.current = mediaRecorder;
|
||||
audioChunksRef.current = [];
|
||||
|
||||
const startTime = Date.now();
|
||||
durationIntervalRef.current = setInterval(() => {
|
||||
const elapsed = (Date.now() - startTime) / 1000;
|
||||
setRecordingDuration(elapsed);
|
||||
|
||||
const estimatedSizeMB = (elapsed * 128 * 1024) / (8 * 1024 * 1024);
|
||||
setEstimatedSize(estimatedSizeMB);
|
||||
|
||||
if (elapsed >= MAX_RECORDING_DURATION_SECONDS) {
|
||||
stopRecording();
|
||||
onError(
|
||||
`Maximum recording duration (${MAX_RECORDING_DURATION_SECONDS / 60} minutes) reached`
|
||||
);
|
||||
}
|
||||
}, 100);
|
||||
|
||||
mediaRecorder.ondataavailable = (event) => {
|
||||
if (event.data.size > 0) {
|
||||
audioChunksRef.current.push(event.data);
|
||||
}
|
||||
};
|
||||
|
||||
mediaRecorder.onstop = async () => {
|
||||
const audioBlob = new Blob(audioChunksRef.current, { type: mimeType || 'audio/webm' });
|
||||
|
||||
if (audioBlob.size === 0) {
|
||||
onError('No audio data was recorded. Please check your microphone.');
|
||||
return;
|
||||
}
|
||||
|
||||
await transcribeAudio(audioBlob);
|
||||
};
|
||||
|
||||
mediaRecorder.onerror = (_event) => {
|
||||
onError('Recording failed');
|
||||
};
|
||||
|
||||
mediaRecorder.start(100);
|
||||
setIsRecording(true);
|
||||
} catch (error) {
|
||||
stopRecording();
|
||||
onError(errorMessage(error));
|
||||
}
|
||||
}, [isEnabled, onError, transcribeAudio, stopRecording]);
|
||||
|
||||
return {
|
||||
isEnabled,
|
||||
dictationProvider: provider,
|
||||
isRecording,
|
||||
isTranscribing,
|
||||
recordingDuration,
|
||||
estimatedSize,
|
||||
startRecording,
|
||||
stopRecording,
|
||||
};
|
||||
};
|
||||
@@ -1,76 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useConfig } from '../components/ConfigContext';
|
||||
import {
|
||||
DICTATION_SETTINGS_KEY,
|
||||
ELEVENLABS_API_KEY,
|
||||
DICTATION_PROVIDER_OPENAI,
|
||||
DICTATION_PROVIDER_ELEVENLABS,
|
||||
getDefaultDictationSettings,
|
||||
isSecretKeyConfigured,
|
||||
} from './dictationConstants';
|
||||
|
||||
export type DictationProvider =
|
||||
| typeof DICTATION_PROVIDER_OPENAI
|
||||
| typeof DICTATION_PROVIDER_ELEVENLABS
|
||||
| null;
|
||||
|
||||
export interface DictationSettings {
|
||||
enabled: boolean;
|
||||
provider: DictationProvider;
|
||||
}
|
||||
|
||||
let elevenLabsKeyCache: boolean | null = null;
|
||||
|
||||
export const setElevenLabsKeyCache = (value: boolean) => {
|
||||
elevenLabsKeyCache = value;
|
||||
};
|
||||
|
||||
export const useDictationSettings = () => {
|
||||
const [settings, setSettings] = useState<DictationSettings | null>(null);
|
||||
const [hasElevenLabsKey, setHasElevenLabsKey] = useState<boolean>(elevenLabsKeyCache ?? false);
|
||||
const { read, getProviders } = useConfig();
|
||||
|
||||
useEffect(() => {
|
||||
const loadSettings = async () => {
|
||||
const saved = localStorage.getItem(DICTATION_SETTINGS_KEY);
|
||||
|
||||
let currentSettings: DictationSettings;
|
||||
if (saved) {
|
||||
currentSettings = JSON.parse(saved);
|
||||
} else {
|
||||
currentSettings = await getDefaultDictationSettings(getProviders);
|
||||
}
|
||||
setSettings(currentSettings);
|
||||
if (
|
||||
currentSettings.provider === DICTATION_PROVIDER_ELEVENLABS &&
|
||||
elevenLabsKeyCache === null
|
||||
) {
|
||||
try {
|
||||
const response = await read(ELEVENLABS_API_KEY, true);
|
||||
const hasKey = isSecretKeyConfigured(response);
|
||||
elevenLabsKeyCache = hasKey;
|
||||
setHasElevenLabsKey(hasKey);
|
||||
} catch (error) {
|
||||
elevenLabsKeyCache = false;
|
||||
setHasElevenLabsKey(false);
|
||||
console.error('[useDictationSettings] Error checking ElevenLabs API key:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadSettings();
|
||||
|
||||
// Listen for storage changes from other tabs/windows
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const handleStorageChange = (e: any) => {
|
||||
if (e.key === DICTATION_SETTINGS_KEY && e.newValue) {
|
||||
setSettings(JSON.parse(e.newValue));
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('storage', handleStorageChange);
|
||||
return () => window.removeEventListener('storage', handleStorageChange);
|
||||
}, [read, getProviders]);
|
||||
|
||||
return { settings, hasElevenLabsKey };
|
||||
};
|
||||
@@ -1,378 +0,0 @@
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { useConfig } from '../components/ConfigContext';
|
||||
import { getApiUrl } from '../config';
|
||||
import { useDictationSettings } from './useDictationSettings';
|
||||
import { DICTATION_PROVIDER_OPENAI, DICTATION_PROVIDER_ELEVENLABS } from './dictationConstants';
|
||||
import { safeJsonParse, errorMessage } from '../utils/conversionUtils';
|
||||
|
||||
interface UseWhisperOptions {
|
||||
onTranscription?: (text: string) => void;
|
||||
onError?: (error: Error) => void;
|
||||
onSizeWarning?: (sizeInMB: number) => void;
|
||||
}
|
||||
|
||||
// Constants
|
||||
const MAX_AUDIO_SIZE_MB = 25;
|
||||
const MAX_RECORDING_DURATION_SECONDS = 600; // 10 minutes
|
||||
const WARNING_SIZE_MB = 20; // Warn at 20MB
|
||||
|
||||
export const useWhisper = ({ onTranscription, onError, onSizeWarning }: UseWhisperOptions = {}) => {
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [isTranscribing, setIsTranscribing] = useState(false);
|
||||
const [hasOpenAIKey, setHasOpenAIKey] = useState(false);
|
||||
const [canUseDictation, setCanUseDictation] = useState(false);
|
||||
const [audioContext, setAudioContext] = useState<AudioContext | null>(null);
|
||||
const [analyser, setAnalyser] = useState<AnalyserNode | null>(null);
|
||||
const [recordingDuration, setRecordingDuration] = useState(0);
|
||||
const [estimatedSize, setEstimatedSize] = useState(0);
|
||||
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const audioChunksRef = useRef<Blob[]>([]);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const recordingStartTimeRef = useRef<number | null>(null);
|
||||
const durationIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const currentSizeRef = useRef<number>(0);
|
||||
|
||||
const { getProviders } = useConfig();
|
||||
const { settings: dictationSettings, hasElevenLabsKey } = useDictationSettings();
|
||||
|
||||
// Check if OpenAI API key is configured (regardless of current provider)
|
||||
useEffect(() => {
|
||||
const checkOpenAIKey = async () => {
|
||||
try {
|
||||
// Get all configured providers
|
||||
const providers = await getProviders(false);
|
||||
|
||||
// Find OpenAI provider
|
||||
const openAIProvider = providers.find((p) => p.name === 'openai');
|
||||
|
||||
// Check if OpenAI is configured
|
||||
if (openAIProvider && openAIProvider.is_configured) {
|
||||
setHasOpenAIKey(true);
|
||||
} else {
|
||||
setHasOpenAIKey(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking OpenAI configuration:', error);
|
||||
setHasOpenAIKey(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkOpenAIKey();
|
||||
}, [getProviders]); // Re-check when providers change
|
||||
|
||||
// Check if dictation can be used based on settings
|
||||
useEffect(() => {
|
||||
if (!dictationSettings) {
|
||||
setCanUseDictation(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!dictationSettings.enabled) {
|
||||
setCanUseDictation(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check provider availability
|
||||
switch (dictationSettings.provider) {
|
||||
case DICTATION_PROVIDER_OPENAI:
|
||||
setCanUseDictation(hasOpenAIKey);
|
||||
break;
|
||||
case DICTATION_PROVIDER_ELEVENLABS:
|
||||
setCanUseDictation(hasElevenLabsKey);
|
||||
break;
|
||||
default:
|
||||
setCanUseDictation(false);
|
||||
}
|
||||
}, [dictationSettings, hasOpenAIKey, hasElevenLabsKey]);
|
||||
|
||||
// Define stopRecording before startRecording to avoid circular dependency
|
||||
const stopRecording = useCallback(() => {
|
||||
setIsRecording(false);
|
||||
|
||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
|
||||
mediaRecorderRef.current.stop();
|
||||
}
|
||||
|
||||
// Clear interval
|
||||
if (durationIntervalRef.current) {
|
||||
clearInterval(durationIntervalRef.current);
|
||||
durationIntervalRef.current = null;
|
||||
}
|
||||
|
||||
// Stop all tracks in the stream
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach((track) => track.stop());
|
||||
streamRef.current = null;
|
||||
}
|
||||
|
||||
// Close audio context
|
||||
if (audioContext && audioContext.state !== 'closed') {
|
||||
audioContext.close().catch(console.error);
|
||||
setAudioContext(null);
|
||||
setAnalyser(null);
|
||||
}
|
||||
}, [audioContext]);
|
||||
|
||||
// Cleanup effect to prevent memory leaks
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// Cleanup on unmount
|
||||
if (durationIntervalRef.current) {
|
||||
clearInterval(durationIntervalRef.current);
|
||||
}
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach((track) => track.stop());
|
||||
}
|
||||
if (audioContext && audioContext.state !== 'closed') {
|
||||
audioContext.close().catch(console.error);
|
||||
}
|
||||
};
|
||||
}, [audioContext]);
|
||||
|
||||
const transcribeAudio = useCallback(
|
||||
async (audioBlob: Blob) => {
|
||||
if (!dictationSettings) {
|
||||
stopRecording();
|
||||
onError?.(new Error('Dictation settings not loaded'));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsTranscribing(true);
|
||||
|
||||
try {
|
||||
// Check final size
|
||||
const sizeMB = audioBlob.size / (1024 * 1024);
|
||||
if (sizeMB > MAX_AUDIO_SIZE_MB) {
|
||||
throw new Error(
|
||||
`Audio file too large (${sizeMB.toFixed(1)}MB). Maximum size is ${MAX_AUDIO_SIZE_MB}MB.`
|
||||
);
|
||||
}
|
||||
|
||||
// Convert blob to base64 for easier transport
|
||||
const reader = new FileReader();
|
||||
const base64Audio = await new Promise<string>((resolve, reject) => {
|
||||
reader.onloadend = () => {
|
||||
const base64 = reader.result as string;
|
||||
resolve(base64.split(',')[1]); // Remove data:audio/webm;base64, prefix
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(audioBlob);
|
||||
});
|
||||
|
||||
const mimeType = audioBlob.type;
|
||||
if (!mimeType) {
|
||||
throw new Error('Unable to determine audio format. Please try again.');
|
||||
}
|
||||
|
||||
let endpoint = '';
|
||||
let headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Secret-Key': await window.electron.getSecretKey(),
|
||||
};
|
||||
|
||||
let body: Record<string, string> = {
|
||||
audio: base64Audio,
|
||||
mime_type: mimeType,
|
||||
};
|
||||
|
||||
// Choose endpoint based on provider
|
||||
switch (dictationSettings.provider) {
|
||||
case DICTATION_PROVIDER_OPENAI:
|
||||
endpoint = '/audio/transcribe';
|
||||
break;
|
||||
case DICTATION_PROVIDER_ELEVENLABS:
|
||||
endpoint = '/audio/transcribe/elevenlabs';
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unsupported provider: ${dictationSettings.provider}`);
|
||||
}
|
||||
|
||||
const response = await fetch(getApiUrl(endpoint), {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
throw new Error(
|
||||
`Audio transcription endpoint not found. Please implement ${endpoint} endpoint in the Goose backend.`
|
||||
);
|
||||
} else if (response.status === 401) {
|
||||
throw new Error('Invalid API key. Please check your API key is correct.');
|
||||
} else if (response.status === 402) {
|
||||
throw new Error('API quota exceeded. Please check your account limits.');
|
||||
}
|
||||
const errorData = await safeJsonParse<{
|
||||
error: { message: string };
|
||||
}>(response, 'Failed to parse error response').catch(() => ({
|
||||
error: { message: 'Transcription failed' },
|
||||
}));
|
||||
throw new Error(errorData.error?.message || 'Transcription failed');
|
||||
}
|
||||
|
||||
const data = await safeJsonParse<{ text: string }>(
|
||||
response,
|
||||
'Failed to parse transcription response'
|
||||
);
|
||||
if (data.text) {
|
||||
onTranscription?.(data.text);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error transcribing audio:', error);
|
||||
stopRecording();
|
||||
onError?.(error as Error);
|
||||
} finally {
|
||||
setIsTranscribing(false);
|
||||
setRecordingDuration(0);
|
||||
setEstimatedSize(0);
|
||||
}
|
||||
},
|
||||
[onTranscription, onError, dictationSettings, stopRecording]
|
||||
);
|
||||
|
||||
const startRecording = useCallback(async () => {
|
||||
if (!dictationSettings) {
|
||||
stopRecording();
|
||||
onError?.(new Error('Dictation settings not loaded'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Request microphone permission
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: true,
|
||||
sampleRate: 44100,
|
||||
},
|
||||
});
|
||||
streamRef.current = stream;
|
||||
|
||||
// Verify we have valid audio tracks
|
||||
const audioTracks = stream.getAudioTracks();
|
||||
if (audioTracks.length === 0) {
|
||||
throw new Error('No audio tracks available in the microphone stream');
|
||||
}
|
||||
|
||||
// AudioContext creation is disabled to prevent MediaRecorder conflicts
|
||||
setAudioContext(null);
|
||||
setAnalyser(null);
|
||||
|
||||
// Determine best supported MIME type
|
||||
const supportedTypes = ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4', 'audio/wav'];
|
||||
|
||||
const mimeType = supportedTypes.find((type) => MediaRecorder.isTypeSupported(type)) || '';
|
||||
|
||||
const mediaRecorder = new MediaRecorder(stream, mimeType ? { mimeType } : {});
|
||||
|
||||
mediaRecorderRef.current = mediaRecorder;
|
||||
audioChunksRef.current = [];
|
||||
currentSizeRef.current = 0;
|
||||
recordingStartTimeRef.current = Date.now();
|
||||
|
||||
// Start duration and size tracking
|
||||
durationIntervalRef.current = setInterval(() => {
|
||||
const elapsed = (Date.now() - (recordingStartTimeRef.current || 0)) / 1000;
|
||||
setRecordingDuration(elapsed);
|
||||
|
||||
// Estimate size based on typical webm bitrate (~128kbps)
|
||||
const estimatedSizeMB = (elapsed * 128 * 1024) / (8 * 1024 * 1024);
|
||||
setEstimatedSize(estimatedSizeMB);
|
||||
|
||||
// Check if we're approaching the limit
|
||||
if (estimatedSizeMB > WARNING_SIZE_MB) {
|
||||
onSizeWarning?.(estimatedSizeMB);
|
||||
}
|
||||
|
||||
// Auto-stop if we hit the duration limit
|
||||
if (elapsed >= MAX_RECORDING_DURATION_SECONDS) {
|
||||
stopRecording();
|
||||
onError?.(
|
||||
new Error(
|
||||
`Maximum recording duration (${MAX_RECORDING_DURATION_SECONDS / 60} minutes) reached.`
|
||||
)
|
||||
);
|
||||
}
|
||||
}, 100);
|
||||
|
||||
mediaRecorder.ondataavailable = (event) => {
|
||||
if (event.data.size > 0) {
|
||||
audioChunksRef.current.push(event.data);
|
||||
currentSizeRef.current += event.data.size;
|
||||
|
||||
// Check actual size
|
||||
const actualSizeMB = currentSizeRef.current / (1024 * 1024);
|
||||
if (actualSizeMB > MAX_AUDIO_SIZE_MB) {
|
||||
stopRecording();
|
||||
onError?.(new Error(`Maximum file size (${MAX_AUDIO_SIZE_MB}MB) reached.`));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
mediaRecorder.onstop = async () => {
|
||||
const audioBlob = new Blob(audioChunksRef.current, { type: mimeType || 'audio/webm' });
|
||||
|
||||
// Check if the blob is empty
|
||||
if (audioBlob.size === 0) {
|
||||
onError?.(
|
||||
new Error(
|
||||
'No audio data was recorded. Please check your microphone permissions and try again.'
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await transcribeAudio(audioBlob);
|
||||
};
|
||||
|
||||
// Add error handler for MediaRecorder
|
||||
mediaRecorder.onerror = (event) => {
|
||||
console.error('MediaRecorder error:', event);
|
||||
onError?.(new Error('Recording failed: Unknown error'));
|
||||
};
|
||||
|
||||
if (!stream.active) {
|
||||
throw new Error('Audio stream became inactive before recording could start');
|
||||
}
|
||||
|
||||
// Check audio tracks again before starting recording
|
||||
if (audioTracks.length === 0) {
|
||||
throw new Error('No audio tracks available in the stream');
|
||||
}
|
||||
|
||||
const activeAudioTracks = audioTracks.filter((track) => track.readyState === 'live');
|
||||
if (activeAudioTracks.length === 0) {
|
||||
throw new Error('No live audio tracks available');
|
||||
}
|
||||
|
||||
try {
|
||||
mediaRecorder.start(100);
|
||||
setIsRecording(true);
|
||||
} catch (startError) {
|
||||
console.error('Error calling mediaRecorder.start():', startError);
|
||||
throw new Error(`Failed to start recording: ${errorMessage(startError)}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error starting recording:', error);
|
||||
stopRecording();
|
||||
onError?.(error as Error);
|
||||
}
|
||||
}, [onError, onSizeWarning, transcribeAudio, stopRecording, dictationSettings]);
|
||||
|
||||
return {
|
||||
isRecording,
|
||||
isTranscribing,
|
||||
hasOpenAIKey,
|
||||
canUseDictation,
|
||||
audioContext,
|
||||
analyser,
|
||||
startRecording,
|
||||
stopRecording,
|
||||
recordingDuration,
|
||||
estimatedSize,
|
||||
dictationSettings,
|
||||
};
|
||||
};
|
||||
@@ -2,5 +2,4 @@ export const UPDATES_ENABLED = true;
|
||||
export const COST_TRACKING_ENABLED = true;
|
||||
export const ANNOUNCEMENTS_ENABLED = false;
|
||||
export const CONFIGURATION_ENABLED = true;
|
||||
export const VOICE_DICTATION_ELEVENLABS_ENABLED = true;
|
||||
export const TELEMETRY_UI_ENABLED = true;
|
||||
|
||||
Reference in New Issue
Block a user