Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 25 additions & 18 deletions crates/goose-server/src/routes/dictation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,15 +64,6 @@ pub enum DictationProvider {
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
Expand Down Expand Up @@ -261,8 +252,18 @@ async fn transcribe_elevenlabs(
audio_bytes: Vec<u8>,
extension: &str,
mime_type: &str,
client: &ApiClient,
) -> Result<String, ErrorResponse> {
let config = goose::config::Config::global();
let def = get_provider_def("elevenlabs")
.ok_or_else(|| ErrorResponse::bad_request("Unknown provider: elevenlabs"))?;

let api_key: String = config
.get_secret(def.config_key)
.map_err(|_| ErrorResponse {
message: format!("{} not configured", def.config_key),
status: StatusCode::PRECONDITION_FAILED,
})?;

let part = reqwest::multipart::Part::bytes(audio_bytes)
.file_name(format!("audio.{}", extension))
.mime_str(mime_type)
Expand All @@ -272,17 +273,24 @@ async fn transcribe_elevenlabs(
.part("file", part)
.text("model_id", "scribe_v1");

let response = client
.request(None, "")
.multipart_post(form)
let http_client = reqwest::Client::builder()
.timeout(REQUEST_TIMEOUT)
.build()
.map_err(|e| ErrorResponse::internal(format!("Failed to create HTTP client: {}", e)))?;
Comment on lines +276 to +279
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This creates a new reqwest::Client for every ElevenLabs transcription request, which prevents connection pooling and adds unnecessary overhead; consider reusing a shared client (e.g., stored in AppState or a static Lazy client) configured with the same timeout.

Copilot uses AI. Check for mistakes.

let response = http_client
.post(def.default_url)
.header("xi-api-key", &api_key)
.multipart(form)
.send()
.await
.map_err(|e| ErrorResponse {
message: if e.to_string().contains("timeout") {
message: if e.is_timeout() {
"Request timed out".to_string()
} else {
format!("Request failed: {}", e)
},
status: if e.to_string().contains("timeout") {
status: if e.is_timeout() {
StatusCode::GATEWAY_TIMEOUT
} else {
StatusCode::SERVICE_UNAVAILABLE
Expand Down Expand Up @@ -322,15 +330,14 @@ 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 => {
let client = build_api_client("openai")?;
transcribe_openai(audio_bytes, extension, &request.mime_type, &client).await?
}
DictationProvider::ElevenLabs => {
transcribe_elevenlabs(audio_bytes, extension, &request.mime_type, &client).await?
transcribe_elevenlabs(audio_bytes, extension, &request.mime_type).await?
}
};

Expand Down
69 changes: 24 additions & 45 deletions ui/desktop/src/components/settings/dictation/DictationSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ export const DictationSettings = () => {
);
const [apiKey, setApiKey] = useState('');
const [isEditingKey, setIsEditingKey] = useState(false);
const [keyValidationError, setKeyValidationError] = useState('');
const { read, upsert, remove } = useConfig();

useEffect(() => {
Expand Down Expand Up @@ -58,49 +57,33 @@ export const DictationSettings = () => {
if (!providerConfig || providerConfig.uses_provider_config) return;

const trimmedKey = apiKey.trim();
if (!trimmedKey) {
setKeyValidationError('API key is required');
return;
}
if (!trimmedKey) return;
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clicking “Save” with an empty/whitespace API key currently just returns with no UI feedback, which makes the button appear broken; disable the Save button until a non-empty key is entered or reintroduce a small inline validation message.

Suggested change
if (!trimmedKey) return;
if (!trimmedKey) {
window.alert('Please enter a non-empty API key before saving.');
return;
}

Copilot uses AI. Check for mistakes.

try {
const keyName = providerConfig.config_key!;
await upsert(keyName, trimmedKey, true);
setApiKey('');
setKeyValidationError('');
setIsEditingKey(false);
const keyName = providerConfig.config_key!;
await upsert(keyName, trimmedKey, true);
setApiKey('');
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 audioConfig = await getDictationConfig();
setProviderStatuses(audioConfig.data || {});
};

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 keyName = providerConfig.config_key!;
await remove(keyName, true);
setApiKey('');
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 audioConfig = await getDictationConfig();
setProviderStatuses(audioConfig.data || {});
};

const handleCancelEdit = () => {
setApiKey('');
setKeyValidationError('');
setIsEditingKey(false);
};

Expand Down Expand Up @@ -192,37 +175,33 @@ export const DictationSettings = () => {
</div>

{!isEditingKey ? (
<Button variant="outline" size="sm" onClick={() => setIsEditingKey(true)}>
{providerStatuses[provider]?.configured ? 'Update API Key' : 'Add API Key'}
</Button>
<div className="flex gap-2 flex-wrap">
<Button variant="outline" size="sm" onClick={() => setIsEditingKey(true)}>
{providerStatuses[provider]?.configured ? 'Update API Key' : 'Add API Key'}
</Button>
{providerStatuses[provider]?.configured && (
<Button variant="destructive" size="sm" onClick={handleRemoveKey}>
Remove API Key
</Button>
)}
</div>
) : (
<div className="space-y-2">
<Input
type="password"
value={apiKey}
onChange={(e) => {
setApiKey(e.target.value);
if (keyValidationError) setKeyValidationError('');
}}
onChange={(e) => setApiKey(e.target.value)}
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>
)}
Expand Down