mirror of
https://github.com/open-webui/desktop.git
synced 2026-07-10 00:14:15 +00:00
feat: add voice input troubleshooting notifications and mic permission checks
- Wire up dead voiceInput:micPermission IPC handler via preload API - Add pre-flight mic permission check before starting recording (macOS) - Add pre-flight connection/auth validation in toggleVoiceInput - Show OS notifications for all voice input failure points: shortcut registration, mic denied, no connection, auth missing, transcription HTTP errors, and generic voice input errors - Improve renderer error messages for NotAllowedError/NotFoundError - Forward renderer errors to main process for OS-level notifications
This commit is contained in:
parent
ef66b1b21a
commit
a889d0e607
3 changed files with 156 additions and 9 deletions
|
|
@ -112,7 +112,7 @@ let voiceInputRecording = false
|
|||
|
||||
// ─── Global Shortcuts ───────────────────────────────────
|
||||
|
||||
const registerShortcuts = (globalAccel?: string, spotlightAccel?: string, voiceInputAccel?: string): void => {
|
||||
const registerShortcuts = (globalAccel?: string, spotlightAccel?: string, voiceInputAccel?: string, callAccel?: string): void => {
|
||||
globalShortcut.unregisterAll()
|
||||
|
||||
// Global shortcut – bring main window to foreground
|
||||
|
|
@ -150,12 +150,46 @@ const registerShortcuts = (globalAccel?: string, spotlightAccel?: string, voiceI
|
|||
toggleVoiceInput()
|
||||
})
|
||||
log.info(`Voice input shortcut "${voiceInputAccel}" registered: ${ok}`)
|
||||
if (!ok) {
|
||||
new Notification({
|
||||
title: 'Voice Input',
|
||||
body: `Could not register shortcut "${voiceInputAccel}". It may be in use by another application.`
|
||||
}).show()
|
||||
}
|
||||
} catch (error) {
|
||||
log.warn('Failed to register voice input shortcut:', voiceInputAccel, error)
|
||||
new Notification({
|
||||
title: 'Voice Input',
|
||||
body: `Failed to register shortcut "${voiceInputAccel}". It may conflict with another application.`
|
||||
}).show()
|
||||
}
|
||||
} else {
|
||||
log.info(`Voice input shortcut skipped — accel="${voiceInputAccel}", enabled=${CONFIG?.voiceInputEnabled}`)
|
||||
}
|
||||
|
||||
// Call shortcut – open the voice/video call overlay
|
||||
if (callAccel && CONFIG?.callEnabled !== false) {
|
||||
try {
|
||||
const ok = globalShortcut.register(callAccel, () => {
|
||||
toggleCall()
|
||||
})
|
||||
log.info(`Call shortcut "${callAccel}" registered: ${ok}`)
|
||||
if (!ok) {
|
||||
new Notification({
|
||||
title: 'Call',
|
||||
body: `Could not register shortcut "${callAccel}". It may be in use by another application.`
|
||||
}).show()
|
||||
}
|
||||
} catch (error) {
|
||||
log.warn('Failed to register call shortcut:', callAccel, error)
|
||||
new Notification({
|
||||
title: 'Call',
|
||||
body: `Failed to register shortcut "${callAccel}". It may conflict with another application.`
|
||||
}).show()
|
||||
}
|
||||
} else {
|
||||
log.info(`Call shortcut skipped — accel="${callAccel}", enabled=${CONFIG?.callEnabled}`)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Spotlight Window ───────────────────────────────────
|
||||
|
|
@ -370,6 +404,46 @@ async function toggleVoiceInput(): Promise<void> {
|
|||
return
|
||||
}
|
||||
|
||||
// Pre-flight: check microphone permission on macOS
|
||||
if (process.platform === 'darwin') {
|
||||
const micStatus = systemPreferences.getMediaAccessStatus('microphone')
|
||||
if (micStatus !== 'granted') {
|
||||
const granted = await systemPreferences.askForMediaAccess('microphone')
|
||||
if (!granted) {
|
||||
log.warn('Voice input: microphone permission denied')
|
||||
new Notification({
|
||||
title: 'Voice Input',
|
||||
body: 'Microphone access denied. Enable it in System Settings → Privacy & Security → Microphone, then restart the app.'
|
||||
}).show()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-flight: check a connection is configured
|
||||
try {
|
||||
const config = await getConfig()
|
||||
if (!config.defaultConnectionId || config.connections.length === 0) {
|
||||
log.warn('Voice input: no connection configured')
|
||||
new Notification({
|
||||
title: 'Voice Input',
|
||||
body: 'No connection configured. Set up a connection in Settings before using voice input.'
|
||||
}).show()
|
||||
return
|
||||
}
|
||||
const conn = config.connections.find((c) => c.id === config.defaultConnectionId)
|
||||
if (!conn) {
|
||||
log.warn('Voice input: default connection not found')
|
||||
new Notification({
|
||||
title: 'Voice Input',
|
||||
body: 'Default connection not found. Check your connection settings.'
|
||||
}).show()
|
||||
return
|
||||
}
|
||||
} catch (err: any) {
|
||||
log.warn('Voice input: config check failed:', err)
|
||||
}
|
||||
|
||||
// Start recording — chime plays concurrently (separate audio output path from mic input)
|
||||
voiceInputRecording = true
|
||||
playChime(true)
|
||||
|
|
@ -390,6 +464,49 @@ async function toggleVoiceInput(): Promise<void> {
|
|||
}
|
||||
}
|
||||
|
||||
// ─── Call Shortcut ──────────────────────────────────────
|
||||
|
||||
async function toggleCall(): Promise<void> {
|
||||
// Pre-flight: check a connection is configured
|
||||
try {
|
||||
const config = await getConfig()
|
||||
if (!config.defaultConnectionId || config.connections.length === 0) {
|
||||
log.warn('Call: no connection configured')
|
||||
new Notification({
|
||||
title: 'Call',
|
||||
body: 'No connection configured. Set up a connection in Settings before using the call shortcut.'
|
||||
}).show()
|
||||
return
|
||||
}
|
||||
const conn = config.connections.find((c) => c.id === config.defaultConnectionId)
|
||||
if (!conn) {
|
||||
log.warn('Call: default connection not found')
|
||||
new Notification({
|
||||
title: 'Call',
|
||||
body: 'Default connection not found. Check your connection settings.'
|
||||
}).show()
|
||||
return
|
||||
}
|
||||
|
||||
let url = conn.url
|
||||
if (conn.type === 'local' && SERVER_URL) {
|
||||
url = SERVER_URL
|
||||
}
|
||||
if (url.startsWith('http://0.0.0.0')) {
|
||||
url = url.replace('http://0.0.0.0', 'http://localhost')
|
||||
}
|
||||
|
||||
sendToRenderer('call', { connectionId: conn.id, url })
|
||||
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.show()
|
||||
mainWindow.focus()
|
||||
}
|
||||
} catch (err: any) {
|
||||
log.warn('Call: config check failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Windows ────────────────────────────────────────────
|
||||
|
||||
function createMainWindow(show = true): void {
|
||||
|
|
@ -924,7 +1041,7 @@ if (!gotTheLock) {
|
|||
CONFIG = await getConfig()
|
||||
updateTray()
|
||||
voiceInputRecording = false
|
||||
registerShortcuts(CONFIG.globalShortcut, CONFIG.spotlightShortcut, CONFIG.voiceInputShortcut)
|
||||
registerShortcuts(CONFIG.globalShortcut, CONFIG.spotlightShortcut, CONFIG.voiceInputShortcut, CONFIG.callShortcut)
|
||||
})
|
||||
|
||||
// Python/uv
|
||||
|
|
@ -1236,10 +1353,10 @@ if (!gotTheLock) {
|
|||
try {
|
||||
const config = await getConfig()
|
||||
if (!config.defaultConnectionId || config.connections.length === 0) {
|
||||
throw new Error('No connection configured')
|
||||
throw new Error('No connection configured. Set up a connection in Settings first.')
|
||||
}
|
||||
const conn = config.connections.find((c) => c.id === config.defaultConnectionId)
|
||||
if (!conn) throw new Error('Default connection not found')
|
||||
if (!conn) throw new Error('Default connection not found. Check your connection settings.')
|
||||
|
||||
let url = conn.url
|
||||
if (conn.type === 'local' && SERVER_URL) {
|
||||
|
|
@ -1274,7 +1391,7 @@ if (!gotTheLock) {
|
|||
}
|
||||
|
||||
if (!token) {
|
||||
throw new Error('Not authenticated — open a connection first')
|
||||
throw new Error('Not authenticated. Open a connection and sign in before using voice input.')
|
||||
}
|
||||
|
||||
// Build multipart form data manually using Node.js
|
||||
|
|
@ -1306,13 +1423,17 @@ if (!gotTheLock) {
|
|||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '')
|
||||
throw new Error(`Transcription failed (${response.status}): ${text}`)
|
||||
throw new Error(`Transcription failed (HTTP ${response.status}). ${text || 'Check that your server has Speech-to-Text configured.'}`)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
return result
|
||||
} catch (error: any) {
|
||||
log.error('voiceInput:transcribe failed:', error)
|
||||
new Notification({
|
||||
title: 'Voice Input Failed',
|
||||
body: error?.message || 'Transcription failed. Check logs for details.'
|
||||
}).show()
|
||||
throw error
|
||||
}
|
||||
})
|
||||
|
|
@ -1370,6 +1491,10 @@ if (!gotTheLock) {
|
|||
ipcMain.handle('voiceInput:error', (_event, message: string) => {
|
||||
log.warn('Voice input error:', message)
|
||||
voiceInputRecording = false
|
||||
new Notification({
|
||||
title: 'Voice Input Error',
|
||||
body: message || 'An unknown error occurred with voice input.'
|
||||
}).show()
|
||||
})
|
||||
|
||||
// Open Terminal
|
||||
|
|
@ -1627,7 +1752,7 @@ if (!gotTheLock) {
|
|||
|
||||
|
||||
// Global shortcut
|
||||
registerShortcuts(CONFIG.globalShortcut, CONFIG.spotlightShortcut, CONFIG.voiceInputShortcut)
|
||||
registerShortcuts(CONFIG.globalShortcut, CONFIG.spotlightShortcut, CONFIG.voiceInputShortcut, CONFIG.callShortcut)
|
||||
|
||||
// Enable screen capture
|
||||
session.defaultSession.setDisplayMediaRequestHandler(
|
||||
|
|
|
|||
|
|
@ -10,6 +10,11 @@ const api = {
|
|||
})
|
||||
},
|
||||
|
||||
// Request microphone permission (macOS system-level)
|
||||
checkMicPermission: (): Promise<string> => {
|
||||
return ipcRenderer.invoke('voiceInput:micPermission')
|
||||
},
|
||||
|
||||
// Send recorded audio to main process for transcription
|
||||
transcribe: (audioBuffer: ArrayBuffer, token?: string): Promise<any> => {
|
||||
return ipcRenderer.invoke('voiceInput:transcribe', audioBuffer, token)
|
||||
|
|
|
|||
|
|
@ -72,6 +72,15 @@
|
|||
await new Promise((r) => setTimeout(r, 500))
|
||||
|
||||
try {
|
||||
// Request system-level mic permission (macOS) before activating the mic
|
||||
const permStatus = await api?.checkMicPermission()
|
||||
if (permStatus === 'denied') {
|
||||
const msg = 'Microphone access denied. Enable it in System Settings → Privacy & Security → Microphone, then restart the app.'
|
||||
showError(msg)
|
||||
api?.error(msg)
|
||||
return
|
||||
}
|
||||
|
||||
mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||
audioChunks = []
|
||||
|
||||
|
|
@ -96,7 +105,13 @@
|
|||
mediaRecorder.start(250)
|
||||
timer = setInterval(() => { duration++ }, 1000)
|
||||
} catch (err: any) {
|
||||
showError(err?.message || 'Mic access failed')
|
||||
const msg = err?.name === 'NotAllowedError'
|
||||
? 'Microphone access denied. Check system permissions.'
|
||||
: err?.name === 'NotFoundError'
|
||||
? 'No microphone found. Connect a microphone and try again.'
|
||||
: err?.message || 'Mic access failed'
|
||||
showError(msg)
|
||||
api?.error(msg)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -176,7 +191,9 @@
|
|||
api?.close()
|
||||
}
|
||||
} catch (err: any) {
|
||||
showError(err?.message || 'Transcription failed')
|
||||
const msg = err?.message || 'Transcription failed'
|
||||
showError(msg)
|
||||
api?.error(msg)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue