diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d83b82..4ad719d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.0.8] - 2026-04-11 + +### Added + +- **Voice Input.** System-wide push-to-talk voice transcription. Press the shortcut from any app to record audio, which is automatically transcribed and sent to your active chat. +- **Voice Input Settings.** Configurable global hotkey and enable/disable toggle in Settings, with a default of Shift+Cmd+Space (macOS) or Shift+Ctrl+Space (Windows/Linux). +- **Audio Feedback.** Bundled start and stop chime sounds play when recording begins and ends. + +### Fixed + +- **Shortcut Recorder on macOS.** Shortcut inputs now use physical key codes instead of character values, fixing Alt key combinations producing unicode characters like √ instead of V. + ## [0.0.7] - 2026-04-11 ### Fixed diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 2c2b32e..41d7925 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -15,7 +15,8 @@ export default defineConfig({ input: { index: resolve(__dirname, 'src/preload/index.ts'), 'content-preload': resolve(__dirname, 'src/preload/content-preload.ts'), - 'spotlight-preload': resolve(__dirname, 'src/preload/spotlight-preload.ts') + 'spotlight-preload': resolve(__dirname, 'src/preload/spotlight-preload.ts'), + 'voice-input-preload': resolve(__dirname, 'src/preload/voice-input-preload.ts') } } } @@ -25,7 +26,8 @@ export default defineConfig({ rollupOptions: { input: { index: resolve(__dirname, 'src/renderer/index.html'), - spotlight: resolve(__dirname, 'src/renderer/spotlight.html') + spotlight: resolve(__dirname, 'src/renderer/spotlight.html'), + 'voice-input': resolve(__dirname, 'src/renderer/voice-input.html') } } }, diff --git a/package.json b/package.json index 238f2f6..f16de65 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "open-webui", - "version": "0.0.7", + "version": "0.0.8", "license": "AGPL-3.0", "description": "Open WebUI Desktop", "main": "./out/main/index.js", diff --git a/resources/sounds/chime-start.wav b/resources/sounds/chime-start.wav new file mode 100644 index 0000000..33d0342 Binary files /dev/null and b/resources/sounds/chime-start.wav differ diff --git a/resources/sounds/chime-stop.wav b/resources/sounds/chime-stop.wav new file mode 100644 index 0000000..0cb930f Binary files /dev/null and b/resources/sounds/chime-stop.wav differ diff --git a/src/main/index.ts b/src/main/index.ts index 6a62e23..2694cf1 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -98,6 +98,7 @@ if (process.platform === 'linux') { let mainWindow: BrowserWindow | null = null let contentWindow: BrowserWindow | null = null let spotlightWindow: BrowserWindow | null = null +let voiceInputWindow: BrowserWindow | null = null let tray: Tray | null = null let isQuiting = false @@ -106,10 +107,12 @@ let SERVER_URL: string | null = null let SERVER_STATUS: string | null = null let SERVER_REACHABLE = false let SERVER_PID: number | null = null +let AUTH_TOKEN: string | null = null +let voiceInputRecording = false // ─── Global Shortcuts ─────────────────────────────────── -const registerShortcuts = (globalAccel?: string, spotlightAccel?: string): void => { +const registerShortcuts = (globalAccel?: string, spotlightAccel?: string, voiceInputAccel?: string): void => { globalShortcut.unregisterAll() // Global shortcut – bring main window to foreground @@ -139,6 +142,20 @@ const registerShortcuts = (globalAccel?: string, spotlightAccel?: string): void log.warn('Failed to register spotlight shortcut:', spotlightAccel, error) } } + + // Voice input shortcut – toggle microphone recording + if (voiceInputAccel && CONFIG?.voiceInputEnabled !== false) { + try { + const ok = globalShortcut.register(voiceInputAccel, () => { + toggleVoiceInput() + }) + log.info(`Voice input shortcut "${voiceInputAccel}" registered: ${ok}`) + } catch (error) { + log.warn('Failed to register voice input shortcut:', voiceInputAccel, error) + } + } else { + log.info(`Voice input shortcut skipped — accel="${voiceInputAccel}", enabled=${CONFIG?.voiceInputEnabled}`) + } } // ─── Spotlight Window ─────────────────────────────────── @@ -257,6 +274,122 @@ function toggleSpotlight(selectedText?: string): void { } } +// ─── Voice Input Window ───────────────────────────────── + +function createVoiceInputWindow(): BrowserWindow { + const { screen } = require('electron') + const cursorPoint = screen.getCursorScreenPoint() + const activeDisplay = screen.getDisplayNearestPoint(cursorPoint) + const { x: sx, y: sy, width: sw } = activeDisplay.bounds + + const winW = 340 + const winH = 72 + + voiceInputWindow = new BrowserWindow({ + x: sx + Math.round((sw - winW) / 2), + y: sy + 120, + width: winW, + height: winH, + frame: false, + transparent: true, + alwaysOnTop: true, + skipTaskbar: true, + resizable: false, + hasShadow: false, + show: false, + focusable: true, + icon: path.join(__dirname, 'assets/icon.png'), + webPreferences: { + preload: join(__dirname, '../preload/voice-input-preload.js'), + sandbox: false, + webviewTag: false, + autoplayPolicy: 'no-user-gesture-required' + } + }) + + // Grant microphone permission for the voice input window + voiceInputWindow.webContents.session.setPermissionRequestHandler( + (_webContents, permission, callback) => { + callback(permission === 'media') + } + ) + + if (is.dev && process.env['ELECTRON_RENDERER_URL']) { + voiceInputWindow.loadURL(`${process.env['ELECTRON_RENDERER_URL']}/voice-input.html`) + } else { + voiceInputWindow.loadFile(join(__dirname, '../renderer/voice-input.html')) + } + + voiceInputWindow.on('closed', () => { + voiceInputWindow = null + voiceInputRecording = false + }) + + return voiceInputWindow +} + +function playChime(ascending: boolean): Promise { + return new Promise((resolve) => { + const { execFile } = require('child_process') + const fs = require('fs') + const file = ascending ? 'chime-start.wav' : 'chime-stop.wav' + const soundPath = app.isPackaged + ? join(process.resourcesPath, 'app.asar.unpacked', 'resources', 'sounds', file) + : join(app.getAppPath(), 'resources', 'sounds', file) + + const exists = fs.existsSync(soundPath) + log.info(`playChime: ${ascending ? 'start' : 'stop'}, path=${soundPath}, exists=${exists}`) + + if (!exists) { resolve(); return } + + if (process.platform === 'darwin') { + execFile('afplay', [soundPath], (err, stdout, stderr) => { + if (err) log.warn('afplay error:', err.message, stderr) + resolve() + }) + } else if (process.platform === 'win32') { + execFile('powershell', ['-NoProfile', '-Command', + `(New-Object Media.SoundPlayer '${soundPath}').PlaySync()` + ], () => resolve()) + } else { + execFile('paplay', [soundPath], (err) => { + if (err) execFile('aplay', [soundPath], () => resolve()) + else resolve() + }) + } + }) +} + +async function toggleVoiceInput(): Promise { + if (voiceInputRecording) { + // Stop recording — chime plays in done/close handler after mic is released + voiceInputRecording = false + if (voiceInputWindow && !voiceInputWindow.isDestroyed()) { + voiceInputWindow.webContents.send('voiceInput:state', { recording: false }) + } + return + } + + // Start recording — chime plays concurrently (separate audio output path from mic input) + voiceInputRecording = true + playChime(true) + + if (voiceInputWindow && !voiceInputWindow.isDestroyed()) { + voiceInputWindow.show() + voiceInputWindow.focus() + voiceInputWindow.webContents.send('voiceInput:state', { recording: true }) + } else { + const win = createVoiceInputWindow() + win.once('ready-to-show', () => { + win.show() + win.focus() + setTimeout(() => { + win.webContents.send('voiceInput:state', { recording: true }) + }, 100) + }) + } +} + // ─── Windows ──────────────────────────────────────────── function createMainWindow(show = true): void { @@ -790,7 +923,8 @@ if (!gotTheLock) { await setConfig(config) CONFIG = await getConfig() updateTray() - registerShortcuts(CONFIG.globalShortcut, CONFIG.spotlightShortcut) + voiceInputRecording = false + registerShortcuts(CONFIG.globalShortcut, CONFIG.spotlightShortcut, CONFIG.voiceInputShortcut) }) // Python/uv @@ -941,6 +1075,12 @@ if (!gotTheLock) { } }) + // Auth token relay from webview + ipcMain.handle('app:setAuthToken', (_event, token: string) => { + AUTH_TOKEN = token || null + log.info('Auth token updated from webview') + }) + // Misc ipcMain.handle('app:reset', () => resetAppHandler()) @@ -1076,6 +1216,162 @@ if (!gotTheLock) { } ) + // ── Voice Input ───────────────────────────────────── + + // Check microphone permission (macOS) + ipcMain.handle('voiceInput:micPermission', async () => { + if (process.platform === 'darwin') { + const status = systemPreferences.getMediaAccessStatus('microphone') + if (status !== 'granted') { + const granted = await systemPreferences.askForMediaAccess('microphone') + return granted ? 'granted' : 'denied' + } + return 'granted' + } + return 'granted' // Windows/Linux don't need explicit permission + }) + + // Transcribe audio via the connected server's STT endpoint + ipcMain.handle('voiceInput:transcribe', async (_event, audioBuffer: ArrayBuffer, rendererToken?: string) => { + try { + const config = await getConfig() + if (!config.defaultConnectionId || config.connections.length === 0) { + throw new Error('No connection configured') + } + const conn = config.connections.find((c) => c.id === config.defaultConnectionId) + if (!conn) throw new Error('Default connection not found') + + 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') + } + + // Use stored auth token (relayed from webview), fall back to renderer-provided or contentWindow + let token = AUTH_TOKEN || rendererToken || '' + if (!token) { + // Scan all webContents to find the Open WebUI webview and read its token + try { + const { webContents: wc } = require('electron') + const allContents = wc.getAllWebContents() + for (const contents of allContents) { + try { + if (contents.getType() === 'webview' && !contents.isDestroyed()) { + const t = await contents.executeJavaScript( + `localStorage.getItem('token') || ''` + ) + if (t) { token = t; break } + } + } catch { + // Skip inaccessible webContents + } + } + } catch { + log.warn('voiceInput:transcribe — could not extract token from webviews') + } + } + + if (!token) { + throw new Error('Not authenticated — open a connection first') + } + + // Build multipart form data manually using Node.js + const boundary = '----VoiceInput' + Date.now() + const buffer = Buffer.from(audioBuffer) + const filename = `recording-${Date.now()}.wav` + + const header = [ + `--${boundary}`, + `Content-Disposition: form-data; name="file"; filename="${filename}"`, + `Content-Type: audio/wav`, + '', + '' + ].join('\r\n') + + const footer = `\r\n--${boundary}--\r\n` + const headerBuf = Buffer.from(header, 'utf-8') + const footerBuf = Buffer.from(footer, 'utf-8') + const body = Buffer.concat([headerBuf, buffer, footerBuf]) + + const response = await fetch(`${url}/api/v1/audio/transcriptions`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': `multipart/form-data; boundary=${boundary}` + }, + body + }) + + if (!response.ok) { + const text = await response.text().catch(() => '') + throw new Error(`Transcription failed (${response.status}): ${text}`) + } + + const result = await response.json() + return result + } catch (error: any) { + log.error('voiceInput:transcribe failed:', error) + throw error + } + }) + + // Voice input completed — deliver text to chat + ipcMain.handle('voiceInput:done', async (_event, text: string) => { + voiceInputRecording = false + playChime(false) + if (voiceInputWindow && !voiceInputWindow.isDestroyed()) { + voiceInputWindow.hide() + } + + if (!text?.trim()) return + + // Deliver text through the same path as Spotlight + const config = await getConfig() + if (!config.defaultConnectionId || config.connections.length === 0) { + mainWindow?.show() + mainWindow?.focus() + return + } + const conn = config.connections.find((c) => c.id === config.defaultConnectionId) + if (!conn) { + mainWindow?.show() + mainWindow?.focus() + 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('query', { query: text.trim(), connectionId: conn.id, url }) + + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.show() + mainWindow.focus() + } + }) + + // Voice input window requests close + ipcMain.handle('voiceInput:close', () => { + voiceInputRecording = false + playChime(false) + if (voiceInputWindow && !voiceInputWindow.isDestroyed()) { + voiceInputWindow.hide() + } + }) + + // Voice input error + ipcMain.handle('voiceInput:error', (_event, message: string) => { + log.warn('Voice input error:', message) + voiceInputRecording = false + }) + // Open Terminal ipcMain.handle('open-terminal:start', async () => { try { @@ -1331,7 +1627,7 @@ if (!gotTheLock) { // Global shortcut - registerShortcuts(CONFIG.globalShortcut, CONFIG.spotlightShortcut) + registerShortcuts(CONFIG.globalShortcut, CONFIG.spotlightShortcut, CONFIG.voiceInputShortcut) // Enable screen capture session.defaultSession.setDisplayMediaRequestHandler( @@ -1423,6 +1719,10 @@ if (!gotTheLock) { spotlightWindow.destroy() } spotlightWindow = null + if (voiceInputWindow && !voiceInputWindow.isDestroyed()) { + voiceInputWindow.destroy() + } + voiceInputWindow = null tray?.destroy() tray = null }) diff --git a/src/main/utils/index.ts b/src/main/utils/index.ts index ba43a37..7ea8d01 100644 --- a/src/main/utils/index.ts +++ b/src/main/utils/index.ts @@ -828,6 +828,8 @@ export interface AppConfig { envVars: Record showSidebar: boolean spotlightPosition: { x: number; y: number } | null + voiceInputShortcut: string + voiceInputEnabled: boolean } const DEFAULT_CONFIG: AppConfig = { @@ -856,7 +858,9 @@ const DEFAULT_CONFIG: AppConfig = { }, envVars: {}, showSidebar: false, - spotlightPosition: null + spotlightPosition: null, + voiceInputShortcut: 'Shift+CommandOrControl+Space', + voiceInputEnabled: true } export const getConfig = async (): Promise => { diff --git a/src/preload/index.ts b/src/preload/index.ts index 1804ebc..eb33442 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -181,7 +181,10 @@ const api = { installUpdate: () => ipcRenderer.invoke('updater:install'), // Changelog - getChangelog: () => ipcRenderer.invoke('app:changelog') + getChangelog: () => ipcRenderer.invoke('app:changelog'), + + // Auth token relay from webview + setAuthToken: (token: string) => ipcRenderer.invoke('app:setAuthToken', token) } if (process.contextIsolated) { diff --git a/src/preload/voice-input-preload.ts b/src/preload/voice-input-preload.ts new file mode 100644 index 0000000..a39fc3b --- /dev/null +++ b/src/preload/voice-input-preload.ts @@ -0,0 +1,43 @@ +import { ipcRenderer, contextBridge } from 'electron' + +const api = { + // Main process tells us to start/stop recording + onRecordingState: ( + callback: (data: { recording: boolean }) => void + ): void => { + ipcRenderer.on('voiceInput:state', (_event, data) => { + callback(data) + }) + }, + + // Send recorded audio to main process for transcription + transcribe: (audioBuffer: ArrayBuffer, token?: string): Promise => { + return ipcRenderer.invoke('voiceInput:transcribe', audioBuffer, token) + }, + + // Notify main process that transcription completed + done: (text: string): void => { + ipcRenderer.invoke('voiceInput:done', text) + }, + + // Close/hide the voice input window + close: (): void => { + ipcRenderer.invoke('voiceInput:close') + }, + + // Report an error + error: (message: string): void => { + ipcRenderer.invoke('voiceInput:error', message) + } +} + +if (process.contextIsolated) { + try { + contextBridge.exposeInMainWorld('voiceInputAPI', api) + } catch (error) { + console.error(error) + } +} else { + // @ts-ignore + window.voiceInputAPI = api +} diff --git a/src/renderer/src/components/VoiceInput.svelte b/src/renderer/src/components/VoiceInput.svelte new file mode 100644 index 0000000..7e44e95 --- /dev/null +++ b/src/renderer/src/components/VoiceInput.svelte @@ -0,0 +1,320 @@ + + + { if (e.key === 'Escape') cancelRecording() }} + onmousemove={onMouseMove} + onmouseup={onMouseUp} +/> + + +
+ {#if recording} +
+ {#each levels as level} +
+ {/each} +
+ {formatDuration(duration)} + {:else if transcribing} +
+ {:else if errorMsg} + {errorMsg} + {/if} +
+ + diff --git a/src/renderer/src/lib/components/Main/Connections/Content.svelte b/src/renderer/src/lib/components/Main/Connections/Content.svelte index e7cf524..e7434b9 100644 --- a/src/renderer/src/lib/components/Main/Connections/Content.svelte +++ b/src/renderer/src/lib/components/Main/Connections/Content.svelte @@ -120,6 +120,13 @@ if (event.channel === 'webview:send') { const requestData = event.args?.[0] if (!requestData) return + + // Handle auth token relay from webview + if (requestData.type === 'token:update' && requestData.token) { + window.electronAPI.setAuthToken?.(requestData.token) + return + } + try { const response = await window.electronAPI[requestData.type]?.(requestData) if (requestData._requestId) { diff --git a/src/renderer/src/lib/components/Main/Settings/General.svelte b/src/renderer/src/lib/components/Main/Settings/General.svelte index 2e99e05..1927632 100644 --- a/src/renderer/src/lib/components/Main/Settings/General.svelte +++ b/src/renderer/src/lib/components/Main/Settings/General.svelte @@ -99,6 +99,12 @@ let spotlightRecording = $state(false) let spotlightShortcutInputEl = $state(null) + // Voice input shortcut recorder + let voiceInputShortcutValue = $state('') + let voiceInputRecording = $state(false) + let voiceInputShortcutInputEl = $state(null) + let voiceInputEnabled = $state(true) + // Keep shortcut value in sync with config store $effect(() => { if ($config?.globalShortcut !== undefined) { @@ -112,6 +118,15 @@ } }) + $effect(() => { + if ($config?.voiceInputShortcut !== undefined) { + voiceInputShortcutValue = $config.voiceInputShortcut ?? '' + } + if ($config?.voiceInputEnabled !== undefined) { + voiceInputEnabled = $config.voiceInputEnabled ?? true + } + }) + const keyToElectron = (e: KeyboardEvent): string | null => { const parts: string[] = [] if (e.metaKey || e.ctrlKey) parts.push('CommandOrControl') @@ -122,16 +137,40 @@ const ignore = ['Control', 'Meta', 'Alt', 'Shift'] if (ignore.includes(e.key)) return null - // Map special keys - const keyMap: Record = { - ' ': 'Space', + // Use e.code to get the physical key (avoids macOS Alt producing unicode like √ for V) + const codeMap: Record = { + Space: 'Space', ArrowUp: 'Up', ArrowDown: 'Down', ArrowLeft: 'Left', ArrowRight: 'Right', - Enter: 'Return' + Enter: 'Return', + Backquote: '`', + Minus: '-', + Equal: '=', + BracketLeft: '[', + BracketRight: ']', + Backslash: '\\', + Semicolon: ';', + Quote: "'", + Comma: ',', + Period: '.', + Slash: '/' } - const key = keyMap[e.key] ?? (e.key.length === 1 ? e.key.toUpperCase() : e.key) + + let key: string + if (codeMap[e.code]) { + key = codeMap[e.code] + } else if (e.code.startsWith('Key')) { + key = e.code.slice(3) // KeyA → A + } else if (e.code.startsWith('Digit')) { + key = e.code.slice(5) // Digit1 → 1 + } else if (e.code.startsWith('F') && /^F\d+$/.test(e.code)) { + key = e.code // F1, F2, etc. + } else { + key = e.key.length === 1 ? e.key.toUpperCase() : e.key + } + parts.push(key) return parts.join('+') } @@ -197,6 +236,32 @@ config.set(await window.electronAPI.getConfig()) } } + + const handleVoiceInputShortcutKeydown = async (e: KeyboardEvent) => { + e.preventDefault() + e.stopPropagation() + + if (e.key === 'Escape') { + voiceInputRecording = false + return + } + + if (e.key === 'Backspace' || e.key === 'Delete') { + voiceInputShortcutValue = '' + voiceInputRecording = false + await window.electronAPI.setConfig({ voiceInputShortcut: '' }) + config.set(await window.electronAPI.getConfig()) + return + } + + const accel = keyToElectron(e) + if (accel) { + voiceInputShortcutValue = accel + voiceInputRecording = false + await window.electronAPI.setConfig({ voiceInputShortcut: accel }) + config.set(await window.electronAPI.getConfig()) + } + }
@@ -412,6 +477,78 @@
+
+
+
Voice Input
+
Enable global push-to-talk voice transcription
+
+ { + voiceInputEnabled = value + await window.electronAPI.setConfig({ voiceInputEnabled: value }) + config.set(await window.electronAPI.getConfig()) + }} + /> +
+ + {#if voiceInputEnabled} +
+
+
Voice Input Shortcut
+
+ {#if voiceInputRecording} + Press a key combination… + {:else} + Toggle microphone recording from anywhere + {/if} +
+
+
+ + {#if voiceInputShortcutValue && !voiceInputRecording} + + {/if} +
+
+ {/if} +