Compare commits

..

16 Commits

Author SHA1 Message Date
Timothy Jaeryang Baek eb3c569078 chore: bump version to 0.0.10 2026-04-24 22:17:45 +09:00
Timothy Jaeryang Baek 3350da65ec feat: auto-recover from GPU process crashes by disabling GPU sandbox
When the GPU process crashes fatally (common with certain NVIDIA/Intel
driver versions on Windows), automatically write a marker file and
relaunch with --disable-gpu-sandbox so users don't have to manually
edit shortcut targets.

- Detect GPU crashes via child-process-gone event
- Persist .gpu-sandbox-disabled marker across restarts
- Apply --disable-gpu-sandbox preemptively on subsequent launches
- Call disableDomainBlockingFor3DAPIs() to prevent WebGL blacklisting
- Clean up marker on app reset so users can re-test after driver updates
- Expose gpuSandboxDisabled in app:info for diagnostics

Fixes #110
2026-04-24 22:15:21 +09:00
Timothy Jaeryang Baek 953327b9ef refac: styling 2026-04-20 16:21:59 +09:00
Timothy Jaeryang Baek 1a56df0c6e refac 2026-04-20 16:14:03 +09:00
Timothy Jaeryang Baek 44c40eabd6 fix: persist Open Terminal API key across restarts
The API key is now saved in config.json and reused on subsequent
startups instead of being regenerated every time, which was breaking
existing integrations that relied on a stable key.

Closes #102
2026-04-20 15:57:42 +09:00
Tim Baek d475bde04a Merge pull request #95 from NN708/main
feat: add AppStream MetaInfo file
2026-04-12 11:21:42 -06:00
NN708 5cbe7553d3 feat: add appstream metainfo file 2026-04-12 08:52:17 +08:00
Timothy Jaeryang Baek 7160392959 refac 2026-04-11 16:44:45 -06:00
Timothy Jaeryang Baek a889d0e607 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
2026-04-11 16:30:10 -06:00
Timothy Jaeryang Baek ef66b1b21a refac 2026-04-11 15:47:39 -06:00
Timothy Jaeryang Baek 6c669f1389 doc: readme 2026-04-11 15:44:59 -06:00
Timothy Jaeryang Baek fe398bc65d doc: readme 2026-04-11 15:42:03 -06:00
Timothy Jaeryang Baek f38c95befe refac 2026-04-11 15:21:11 -06:00
Timothy Jaeryang Baek 4db0faff97 feat: add global voice input with push-to-talk transcription (0.0.8) 2026-04-11 15:17:36 -06:00
Timothy Jaeryang Baek 13dfb0f779 fix: update window title from Electron to Open WebUI 2026-04-11 08:17:14 -07:00
Timothy Jaeryang Baek 201b08826e fix: macOS auto-update zip artifact naming (0.0.7) 2026-04-11 08:06:00 -07:00
23 changed files with 1488 additions and 156 deletions
+38
View File
@@ -5,6 +5,44 @@ 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.10] - 2026-04-24
### Added
- **Concurrent Model Downloads.** Multiple Hugging Face models can now be downloaded simultaneously, each with independent progress tracking and per-file cancel buttons.
### Changed
- **Models Settings UI.** Cleaner layout with inline progress bars, hover-reveal download buttons, and breadcrumb-style repo navigation.
### Fixed
- **GPU Process Crash Recovery.** The app now automatically detects GPU process crashes (common with certain NVIDIA/Intel drivers on Windows) and relaunches with the GPU sandbox disabled, instead of closing immediately. No manual shortcut edits required.
## [0.0.9] - 2026-04-20
### Fixed
- **Open Terminal API Key Persistence.** The Open Terminal API key is now saved in config.json and reused across restarts instead of being regenerated on every startup, which was breaking existing integrations.
## [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
- **macOS Auto-Update.** Auto-update now works correctly on macOS. Previously, the updater tried to download a zip file with a versioned filename that did not exist in the release.
## [0.0.6] - 2026-04-10
### Added
+52 -62
View File
@@ -1,87 +1,77 @@
# Open WebUI Desktop
[![Version](https://img.shields.io/github/v/release/open-webui/desktop?label=version&color=%2331c48d)](https://github.com/open-webui/desktop/releases)
[![Downloads](https://img.shields.io/github/downloads/open-webui/desktop/total?color=%23764abc)](https://github.com/open-webui/desktop/releases)
[![Discord](https://img.shields.io/discord/1170866489302188073?label=discord&color=%235865F2)](https://discord.gg/open-webui)
[![License](https://img.shields.io/badge/license-AGPL--3.0-blue)](LICENSE)
![Open WebUI Desktop](./demo.png)
Your AI, right on your desktop. **Open WebUI Desktop** wraps the full [Open WebUI](https://github.com/open-webui/open-webui) experience into a native app you can install in seconds, with no Docker, terminal, or manual setup required. Just download, launch, and start chatting with local or remote models.
Your AI, right on your desktop. [Open WebUI](https://github.com/open-webui/open-webui) as a native app. Run models locally or connect to any server. No Docker, no terminal, no setup. Download, launch, chat.
> [!NOTE]
> Open WebUI Desktop is in **early alpha**. Things are moving fast and we'd love your feedback! Drop into the [Discord](https://discord.gg/5rJgQTnV4s) or open an issue if you hit a snag.
> [!WARNING]
> **Early Alpha.** Things move fast and stuff might break. [Report bugs](https://github.com/open-webui/desktop/issues) or [come hang out on Discord](https://discord.gg/open-webui).
## Download
Grab the installer for your platform and you're good to go. An internet connection is needed the first time you launch; after that the app works fully offline.
| Platform | Installer |
|----------|-----------|
| macOS (Apple Silicon) | [**Download .dmg**](https://github.com/open-webui/desktop/releases/latest/download/open-webui-arm64.dmg) |
| macOS (Intel) | [**Download .dmg**](https://github.com/open-webui/desktop/releases/latest/download/open-webui-x64.dmg) |
| Windows x64 | [**Download .exe**](https://github.com/open-webui/desktop/releases/latest/download/open-webui-setup.exe) |
| Linux (AppImage) | [**Download .AppImage**](https://github.com/open-webui/desktop/releases/latest/download/open-webui.AppImage) |
| Linux (Debian/Ubuntu) | [**Download .deb**](https://github.com/open-webui/desktop/releases/latest/download/open-webui_amd64.deb) |
| Linux (Snap) | [**Download .snap**](https://github.com/open-webui/desktop/releases/latest/download/open-webui_amd64.snap) |
| Linux (Flatpak) | [**Download .flatpak**](https://github.com/open-webui/desktop/releases/latest/download/open-webui.flatpak) |
### macOS
Internet required on first launch. After that, everything works offline. [All releases →](https://github.com/open-webui/desktop/releases)
| Chip | Installer |
|------|-----------|
| Apple Silicon | [**Download .dmg**](https://github.com/open-webui/desktop/releases/latest/download/open-webui-arm64.dmg) |
| Intel | [**Download .dmg**](https://github.com/open-webui/desktop/releases/latest/download/open-webui-x64.dmg) |
## How It Works
### Windows
🖥️ **Run locally.** The app sets up Open WebUI and llama.cpp on your machine. Download models, chat offline, keep everything private. Nothing leaves your computer.
| Architecture | Installer |
|--------------|-----------|
| x64 | [**Download .exe**](https://github.com/open-webui/desktop/releases/latest/download/open-webui-setup.exe) |
☁️ **Connect remotely.** Point the app at any Open WebUI server. Switch between multiple connections from the sidebar.
### Linux
| Format | Installer |
|--------|-----------|
| AppImage | [**Download .AppImage**](https://github.com/open-webui/desktop/releases/latest/download/open-webui.AppImage) |
| Debian / Ubuntu | [**Download .deb**](https://github.com/open-webui/desktop/releases/latest/download/open-webui_amd64.deb) |
| Snap | [**Download .snap**](https://github.com/open-webui/desktop/releases/latest/download/open-webui_amd64.snap) |
| Flatpak | [**Download .flatpak**](https://github.com/open-webui/desktop/releases/latest/download/open-webui.flatpak) |
> Looking for an older version? Check the [releases page](https://github.com/open-webui/desktop/releases).
Use both at the same time.
## Highlights
- **One-click setup.** Open WebUI and all its dependencies install automatically, no terminal required.
- **Spotlight quick-chat.** Press `⇧⌘I` to pop up a lightweight input bar and fire off a prompt from anywhere on your desktop.
- **Local inference.** The built-in llama.cpp integration lets you download and run models directly on your machine.
- **Connect anywhere.** Point the app at any remote Open WebUI server, or run one locally and switch between connections in the sidebar.
- **Offline-ready.** After the first launch, everything runs without an internet connection.
- **Cross-platform.** Available for macOS (Apple Silicon + Intel), Windows, and Linux.
- **Auto-updates.** New releases are detected and installed automatically.
- **Spotlight.** Hit `Shift+Cmd+I` (macOS) or `Shift+Ctrl+I` (Windows/Linux) to summon a floating chat bar over whatever you're doing. Drag to screenshot anything on screen.
- 🎙️ **Voice input.** System-wide push-to-talk. Press the shortcut from any app to record, and your speech is transcribed and sent to your chat automatically.
- 🧠 **Local inference.** Download and run models entirely on your hardware. Your data never leaves your machine.
- 🎯 **One-click setup.** Everything installs itself. Just click "Get Started."
- 🔌 **Multiple connections.** Juggle servers and switch between them instantly.
- 🔄 **Auto-updates.** New releases land in the background.
- 📡 **Offline-ready.** No internet needed after initial setup.
- 💻 **Cross-platform.** macOS, Windows, and Linux.
## System Requirements
| | Minimum |
|--|---------|
| **Disk** | 5 GB free space |
| **OS** | macOS 12+, Windows 10+, or a modern Linux distro |
| **RAM** | 8 GB recommended (more is better for local models) |
| | Local Models | Remote Only |
|--|-------------|-------------|
| **Disk** | 5 GB+ | ~500 MB |
| **RAM** | 16 GB+ | 4 GB |
| **OS** | macOS 12+, Windows 10+, modern Linux | Same |
## Contributing
> [!NOTE]
> Local models need serious RAM (7B ≈ 8 GB, 13B ≈ 16 GB). Lighter machine? Connect to a remote server instead.
Want to hack on the desktop app? Here's how to get a dev build running:
## Privacy
```bash
# Install dependencies
npm install
# Start the dev server with hot-reload
npm run dev
```
### Building for production
```bash
# macOS
npm run build:mac
# Windows
npm run build:win
# Linux
npm run build:linux
```
## License
This project is licensed under **AGPL-3.0**. See [LICENSE](LICENSE) for details.
No telemetry. No tracking. No phone-home. Your conversations stay on your machine. Period.
## Community
We'd love to have you around! Star the repo, join the [Discord](https://discord.gg/5rJgQTnV4s), or follow [Open WebUI on GitHub](https://github.com/open-webui/open-webui) for the latest updates.
- 💬 [Discord](https://discord.gg/open-webui) - Come hang out
- 🐛 [Issues](https://github.com/open-webui/desktop/issues) - Report bugs or request features
- 🌐 [Open WebUI](https://github.com/open-webui/open-webui) - The main project
- 📖 [Docs](https://docs.openwebui.com) - Full documentation
## Contributing
```bash
npm install
npm run dev
```
See [CHANGELOG.md](CHANGELOG.md) for release history. Licensed under [AGPL-3.0](LICENSE).
@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="UTF-8"?>
<component type="desktop-application">
<id>com.openwebui.open-webui</id>
<metadata_license>CC0-1.0</metadata_license>
<project_license>AGPL-3.0-or-later</project_license>
<name>Open WebUI</name>
<summary>The freedom AI stack</summary>
<developer id="com.timbaek">
<name>Timothy J. Baek</name>
</developer>
<description>
<p>
Open WebUI is an extensible, feature-rich, and user-friendly self-hosted AI platform designed to operate entirely offline.
</p>
</description>
<launchable type="desktop-id">com.openwebui.open-webui.desktop</launchable>
<content_rating type="oars-1.1">
<content_attribute id="social-chat">intense</content_attribute>
</content_rating>
<url type="bugtracker">https://github.com/open-webui/desktop/issues</url>
<url type="homepage">https://openwebui.com</url>
<url type="donation">https://github.com/sponsors/tjbck</url>
<url type="vcs-browser">https://github.com/open-webui/desktop</url>
<screenshots>
<screenshot type="default">
<image>https://raw.githubusercontent.com/open-webui/desktop/61f55e6fd6814b959b16a4704b03262e02186f48/demo.png</image>
</screenshot>
</screenshots>
<releases>
<release version="0.0.8" date="2026-04-11">
<url type="details">https://github.com/open-webui/desktop/releases/tag/v0.0.8</url>
</release>
<release version="0.0.6" date="2026-04-10">
<url type="details">https://github.com/open-webui/desktop/releases/tag/v0.0.6</url>
</release>
</releases>
</component>
+6 -2
View File
@@ -24,8 +24,12 @@ nsis:
createDesktopShortcut: always
mac:
target:
- dmg
- zip
- target: dmg
- target: zip
arch:
- x64
- arm64
artifactName: ${name}-${arch}-mac.${ext}
entitlements: build/entitlements.mac.plist
entitlementsInherit: build/entitlements.mac.plist
extendInfo:
+4 -2
View File
@@ -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')
}
}
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "open-webui",
"version": "0.0.6",
"version": "0.0.10",
"license": "AGPL-3.0",
"description": "Open WebUI Desktop",
"main": "./out/main/index.js",
Binary file not shown.
Binary file not shown.
+499 -6
View File
@@ -89,15 +89,37 @@ log.transports.file.resolvePathFn = () => getLogFilePath('main')
import icon from '../../resources/icon.png?asset'
import { existsSync, writeFileSync, unlinkSync } from 'fs'
if (process.platform === 'linux') {
app.commandLine.appendSwitch('no-sandbox')
}
// ─── GPU Crash Recovery ─────────────────────────────────
// When the GPU process crashes fatally (common on certain NVIDIA/Intel
// driver + Windows combos), we write a marker file and relaunch with
// --disable-gpu-sandbox so the user doesn't have to manually edit
// shortcut properties. On the next launch the marker is detected and
// the switch is applied preemptively.
const gpuCrashMarkerPath = join(app.getPath('userData'), '.gpu-sandbox-disabled')
const gpuSandboxDisabled = existsSync(gpuCrashMarkerPath)
if (gpuSandboxDisabled) {
log.info('GPU sandbox disabled due to previous GPU process crash')
app.commandLine.appendSwitch('disable-gpu-sandbox')
}
// Prevent Chromium from permanently blocking WebGL / 3-D APIs after
// repeated GPU process crashes within the same session.
app.disableDomainBlockingFor3DAPIs()
// ─── State ──────────────────────────────────────────────
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 +128,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, callAccel?: string): void => {
globalShortcut.unregisterAll()
// Global shortcut bring main window to foreground
@@ -139,6 +163,54 @@ 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}`)
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 ───────────────────────────────────
@@ -257,6 +329,205 @@ 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<void> {
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<void> {
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
}
// 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)
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)
})
}
}
// ─── 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 {
@@ -698,6 +969,15 @@ const resetAppHandler = async () => {
} catch (e) {
log.warn('Failed to uninstall llama.cpp during reset:', e)
}
// Remove GPU crash marker so sandbox is re-tested on next launch
try {
if (existsSync(gpuCrashMarkerPath)) {
unlinkSync(gpuCrashMarkerPath)
log.info('GPU crash marker removed during reset')
}
} catch (e) {
log.warn('Failed to remove GPU crash marker during reset:', e)
}
await new Promise((resolve) => setTimeout(resolve, 1000))
await resetApp()
CONFIG = await getConfig() // reload from defaults since config.json was deleted
@@ -748,6 +1028,43 @@ if (!gotTheLock) {
}
electronApp.setAppUserModelId('com.openwebui.desktop')
// ─── GPU Process Crash Recovery ──────────────────
// If the GPU process exits fatally (e.g. sandbox init failure on
// certain NVIDIA/Intel drivers), write a marker and relaunch with
// --disable-gpu-sandbox so the user doesn't have to manually edit
// shortcut targets (see issue #110).
app.on('child-process-gone', (_event, details) => {
if (details.type === 'GPU') {
log.error(
`GPU process gone: reason=${details.reason}, exitCode=${details.exitCode}`
)
// Only auto-recover from fatal crashes, not normal/clean exits
if (
details.reason === 'crashed' ||
details.reason === 'launch-failed' ||
details.reason === 'abnormal-exit'
) {
if (!gpuSandboxDisabled) {
log.info('Writing GPU crash marker and relaunching with --disable-gpu-sandbox')
try {
writeFileSync(gpuCrashMarkerPath, new Date().toISOString(), 'utf-8')
} catch (e) {
log.warn('Failed to write GPU crash marker:', e)
}
app.relaunch({ args: [...process.argv.slice(1), '--disable-gpu-sandbox'] })
app.exit(0)
}
}
}
})
// If we previously set the GPU sandbox marker and this session
// started successfully, log it so it's visible in diagnostics.
if (gpuSandboxDisabled) {
log.info('Running with GPU sandbox disabled (marker file present)')
}
app.on('browser-window-created', (_, window) => {
optimizer.watchWindowShortcuts(window)
})
@@ -760,7 +1077,8 @@ if (!gotTheLock) {
version: app.getVersion(),
platform: process.platform,
arch: process.arch,
username: require('os').userInfo().username
username: require('os').userInfo().username,
gpuSandboxDisabled
}))
ipcMain.handle('app:contentPreloadPath', () => {
@@ -790,7 +1108,8 @@ if (!gotTheLock) {
await setConfig(config)
CONFIG = await getConfig()
updateTray()
registerShortcuts(CONFIG.globalShortcut, CONFIG.spotlightShortcut)
voiceInputRecording = false
registerShortcuts(CONFIG.globalShortcut, CONFIG.spotlightShortcut, CONFIG.voiceInputShortcut, CONFIG.callShortcut)
})
// Python/uv
@@ -941,6 +1260,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 +1401,170 @@ 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. 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. Check your connection settings.')
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 and sign in before using voice input.')
}
// 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 (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
}
})
// 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
new Notification({
title: 'Voice Input Error',
body: message || 'An unknown error occurred with voice input.'
}).show()
})
// Open Terminal
ipcMain.handle('open-terminal:start', async () => {
try {
@@ -1226,8 +1715,8 @@ if (!gotTheLock) {
ipcMain.handle('huggingface:models:delete', (_event, repo: string, filename: string) => {
return deleteModel(repo, filename)
})
ipcMain.handle('huggingface:models:cancel', () => {
cancelDownload()
ipcMain.handle('huggingface:models:cancel', (_event, repo?: string, filename?: string) => {
cancelDownload(repo, filename)
return true
})
ipcMain.handle('huggingface:search', async (_event, query: string, token?: string) => {
@@ -1331,7 +1820,7 @@ if (!gotTheLock) {
// Global shortcut
registerShortcuts(CONFIG.globalShortcut, CONFIG.spotlightShortcut)
registerShortcuts(CONFIG.globalShortcut, CONFIG.spotlightShortcut, CONFIG.voiceInputShortcut, CONFIG.callShortcut)
// Enable screen capture
session.defaultSession.setDisplayMediaRequestHandler(
@@ -1423,6 +1912,10 @@ if (!gotTheLock) {
spotlightWindow.destroy()
}
spotlightWindow = null
if (voiceInputWindow && !voiceInputWindow.isDestroyed()) {
voiceInputWindow.destroy()
}
voiceInputWindow = null
tray?.destroy()
tray = null
})
+28 -10
View File
@@ -62,15 +62,28 @@ const writeManifest = (models: HfModel[]): void => {
// ─── Public API ─────────────────────────────────────────
let activeDownloadAbort: AbortController | null = null
const activeDownloads = new Map<string, AbortController>()
const downloadKey = (repo: string, filename: string): string => `${repo}/${filename}`
/**
* Cancel the current download in progress.
* Cancel a specific download in progress.
* If no repo/filename given, cancels ALL active downloads.
*/
export const cancelDownload = (): void => {
if (activeDownloadAbort) {
activeDownloadAbort.abort()
activeDownloadAbort = null
export const cancelDownload = (repo?: string, filename?: string): void => {
if (repo && filename) {
const key = downloadKey(repo, filename)
const ctrl = activeDownloads.get(key)
if (ctrl) {
ctrl.abort()
activeDownloads.delete(key)
}
} else {
// Cancel all
for (const ctrl of activeDownloads.values()) {
ctrl.abort()
}
activeDownloads.clear()
}
}
@@ -136,8 +149,13 @@ export const downloadModel = async (
headers['Authorization'] = `Bearer ${token}`
}
activeDownloadAbort = new AbortController()
const { signal } = activeDownloadAbort
const key = downloadKey(repo, filename)
// Cancel any existing download for the same file
activeDownloads.get(key)?.abort()
const abortController = new AbortController()
activeDownloads.set(key, abortController)
const { signal } = abortController
// Use fetch for streaming download with progress
const response = await fetch(downloadUrl, {
@@ -183,7 +201,7 @@ export const downloadModel = async (
writeStream.end()
// Clean up partial download
try { fs.unlinkSync(tmpPath) } catch {}
activeDownloadAbort = null
activeDownloads.delete(downloadKey(repo, filename))
throw err
} finally {
writeStream.end()
@@ -192,7 +210,7 @@ export const downloadModel = async (
// Rename tmp to final
fs.renameSync(tmpPath, destPath)
activeDownloadAbort = null
activeDownloads.delete(downloadKey(repo, filename))
// Update manifest
const manifest = readManifest()
+12 -2
View File
@@ -817,6 +817,7 @@ export interface AppConfig {
enabled: boolean
port: number
cwd: string
apiKey: string
}
llamaCpp: {
enabled: boolean
@@ -828,6 +829,10 @@ export interface AppConfig {
envVars: Record<string, string>
showSidebar: boolean
spotlightPosition: { x: number; y: number } | null
voiceInputShortcut: string
voiceInputEnabled: boolean
callShortcut: string
callEnabled: boolean
}
const DEFAULT_CONFIG: AppConfig = {
@@ -846,7 +851,8 @@ const DEFAULT_CONFIG: AppConfig = {
},
openTerminal: {
enabled: false,
cwd: ''
cwd: '',
apiKey: ''
},
llamaCpp: {
enabled: false,
@@ -856,7 +862,11 @@ const DEFAULT_CONFIG: AppConfig = {
},
envVars: {},
showSidebar: false,
spotlightPosition: null
spotlightPosition: null,
voiceInputShortcut: 'Shift+CommandOrControl+Space',
voiceInputEnabled: true,
callShortcut: 'Shift+CommandOrControl+C',
callEnabled: true
}
export const getConfig = async (): Promise<AppConfig> => {
+9 -2
View File
@@ -6,6 +6,7 @@ import * as pty from 'node-pty'
import {
getPythonPath,
getConfig,
setConfig,
installPackage,
isPackageInstalled,
isPythonInstalled,
@@ -63,8 +64,14 @@ export const startOpenTerminal = async (
const config = await getConfig()
const configEnvVars = config.envVars ?? {}
// Auto-generate API key
const generatedKey = crypto.randomBytes(24).toString('base64url')
// Use persisted API key or generate and save a new one
let generatedKey = config.openTerminal?.apiKey
if (!generatedKey) {
generatedKey = crypto.randomBytes(24).toString('base64url')
await setConfig({
openTerminal: { ...config.openTerminal, apiKey: generatedKey }
})
}
// Find available port
let desiredPort = port || 39284
+6 -2
View File
@@ -155,7 +155,8 @@ const api = {
ipcRenderer.invoke('huggingface:models:download', repo, filename, token, expectedSize),
deleteHfModel: (repo: string, filename: string) =>
ipcRenderer.invoke('huggingface:models:delete', repo, filename),
cancelHfDownload: () => ipcRenderer.invoke('huggingface:models:cancel'),
cancelHfDownload: (repo?: string, filename?: string) =>
ipcRenderer.invoke('huggingface:models:cancel', repo, filename),
searchHfModels: (query: string, token?: string) =>
ipcRenderer.invoke('huggingface:search', query, token),
getHfRepoFiles: (repo: string, token?: string) =>
@@ -181,7 +182,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) {
+48
View File
@@ -0,0 +1,48 @@
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)
})
},
// 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)
},
// 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
}
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Electron</title>
<title>Open WebUI</title>
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
<meta
http-equiv="Content-Security-Policy"
@@ -0,0 +1,337 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte'
const api = window.voiceInputAPI
let recording = $state(false)
let transcribing = $state(false)
let duration = $state(0)
let errorMsg = $state('')
// Waveform
let levels: number[] = $state(Array(5).fill(0.15))
let animFrame: number | null = null
let timer: ReturnType<typeof setInterval> | null = null
let errorTimer: ReturnType<typeof setTimeout> | null = null
// Audio
let mediaRecorder: MediaRecorder | null = null
let audioChunks: Blob[] = []
let mediaStream: MediaStream | null = null
let analyser: AnalyserNode | null = null
let audioCtx: AudioContext | null = null
let dataArray: Uint8Array | null = null
// Dragging
let dragging = false
let dragStart = { mx: 0, my: 0, wx: 0, wy: 0 }
const formatDuration = (s: number): string => {
const m = Math.floor(s / 60)
return `${m}:${(s % 60).toString().padStart(2, '0')}`
}
const animateLevel = () => {
if (analyser && dataArray) {
analyser.getByteFrequencyData(dataArray)
// Sample 5 frequency bands
const bands = 5
const step = Math.floor(dataArray.length / bands)
levels = Array.from({ length: bands }, (_, i) => {
const val = dataArray![i * step] / 255
return Math.max(0.15, val)
})
} else {
levels = levels.map(() => 0.15 + Math.random() * 0.6)
}
animFrame = requestAnimationFrame(animateLevel)
}
const showError = (msg: string) => {
errorMsg = msg
if (errorTimer) clearTimeout(errorTimer)
errorTimer = setTimeout(() => {
errorMsg = ''
api?.close()
}, 3000)
}
const startRecording = async () => {
// Reset all state from any previous session
cleanup()
errorMsg = ''
transcribing = false
recording = true
duration = 0
audioChunks = []
animateLevel() // show placeholder bars immediately
// Wait for the start chime (played from main process) to finish
// before activating mic — macOS ducks audio when mic activates
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 = []
// Set up analyser for real audio levels
audioCtx = new AudioContext()
analyser = audioCtx.createAnalyser()
analyser.fftSize = 64
dataArray = new Uint8Array(analyser.frequencyBinCount)
const source = audioCtx.createMediaStreamSource(mediaStream)
source.connect(analyser)
mediaRecorder = new MediaRecorder(mediaStream, {
mimeType: MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
? 'audio/webm;codecs=opus'
: 'audio/webm'
})
mediaRecorder.ondataavailable = (e) => {
if (e.data.size > 0) audioChunks.push(e.data)
}
mediaRecorder.start(250)
timer = setInterval(() => { duration++ }, 1000)
} catch (err: any) {
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)
}
}
const cleanup = () => {
recording = false
transcribing = false
if (timer) { clearInterval(timer); timer = null }
if (animFrame) { cancelAnimationFrame(animFrame); animFrame = null }
levels = Array(5).fill(0.15)
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
mediaRecorder.stop()
}
if (mediaStream) {
mediaStream.getTracks().forEach((t) => t.stop())
mediaStream = null
}
if (audioCtx) {
audioCtx.close()
audioCtx = null
analyser = null
}
mediaRecorder = null
}
const cancelRecording = () => {
cleanup()
api?.close()
}
const stopRecording = async () => {
if (!mediaRecorder || mediaRecorder.state === 'inactive') {
cancelRecording()
return
}
// Too short — treat as cancel (less than 0.8 seconds)
if (duration < 1) {
cancelRecording()
return
}
recording = false
if (timer) { clearInterval(timer); timer = null }
if (animFrame) { cancelAnimationFrame(animFrame); animFrame = null }
levels = Array(5).fill(0.15)
const audioBlob = await new Promise<Blob>((resolve) => {
mediaRecorder!.onstop = () => {
resolve(new Blob(audioChunks, { type: mediaRecorder!.mimeType }))
}
mediaRecorder!.stop()
})
if (mediaStream) {
mediaStream.getTracks().forEach((t) => t.stop())
mediaStream = null
}
if (audioCtx) {
audioCtx.close()
audioCtx = null
analyser = null
}
if (audioBlob.size < 4096) {
api?.close()
return
}
transcribing = true
try {
const buffer = await audioBlob.arrayBuffer()
const result = await api?.transcribe(buffer)
const text = result?.text?.trim()
if (text) {
api?.done(text)
} else {
api?.close()
}
} catch (err: any) {
const msg = err?.message || 'Transcription failed'
showError(msg)
api?.error(msg)
}
}
const onMouseDown = (e: MouseEvent) => {
dragging = true
dragStart = { mx: e.screenX, my: e.screenY, wx: window.screenX, wy: window.screenY }
}
const onMouseMove = (e: MouseEvent) => {
if (!dragging) return
window.moveTo(
dragStart.wx + (e.screenX - dragStart.mx),
dragStart.wy + (e.screenY - dragStart.my)
)
}
const onMouseUp = () => { dragging = false }
onMount(() => {
api?.onRecordingState((data) => {
if (data.recording && !recording) startRecording()
else if (!data.recording && recording) stopRecording()
})
})
onDestroy(() => {
cleanup()
if (errorTimer) clearTimeout(errorTimer)
})
</script>
<svelte:window
onkeydown={(e) => { if (e.key === 'Escape') cancelRecording() }}
onmousemove={onMouseMove}
onmouseup={onMouseUp}
/>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="pill" onmousedown={onMouseDown}>
{#if recording}
<div class="bars">
{#each levels as level}
<div class="bar" style="height: {6 + level * 22}px"></div>
{/each}
</div>
<span class="time">{formatDuration(duration)}</span>
{:else if transcribing}
<div class="loader"></div>
{:else if errorMsg}
<span class="err">{errorMsg}</span>
{/if}
</div>
<style>
@font-face {
font-family: 'Archivo';
src: url('../lib/assets/fonts/Archivo-Variable.ttf');
font-display: swap;
}
:global(*) { margin: 0; padding: 0; box-sizing: border-box; }
:global(html), :global(body), :global(#app) {
height: 100%; width: 100%;
background: transparent;
overflow: hidden;
user-select: none;
-webkit-font-smoothing: antialiased;
}
.pill {
position: absolute;
top: 50%; left: 50%;
transform: translate(-50%, -50%);
display: inline-flex;
align-items: center;
gap: 12px;
padding: 0 20px;
height: 44px;
border-radius: 22px;
cursor: grab;
font-family: 'Archivo', -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
animation: appear 0.15s ease-out;
background: rgba(30, 30, 30, 0.78);
backdrop-filter: blur(40px) saturate(1.8);
-webkit-backdrop-filter: blur(40px) saturate(1.8);
border: 0.5px solid rgba(255, 255, 255, 0.12);
box-shadow:
0 2px 12px rgba(0, 0, 0, 0.35),
inset 0 0.5px 0 rgba(255, 255, 255, 0.06);
}
.pill:active { cursor: grabbing; }
@keyframes appear {
from { opacity: 0; transform: translate(-50%, -50%) scale(0.92); }
to { opacity: 1; transform: translate(-50%, -50%) scale(1); }
}
.bars {
display: flex;
align-items: center;
gap: 3px;
height: 28px;
}
.bar {
width: 4px;
border-radius: 99px;
background: #fff;
opacity: 0.9;
transition: height 60ms ease-out;
min-height: 6px;
}
.time {
font-size: 14px;
font-weight: 600;
font-variant-numeric: tabular-nums;
color: rgba(255, 255, 255, 0.85);
letter-spacing: 0.01em;
}
.loader {
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.15);
border-top-color: rgba(255, 255, 255, 0.8);
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.err {
font-size: 12px;
font-weight: 500;
color: #ff6b6b;
}
</style>
@@ -408,6 +408,28 @@
return
}
// ── Call shortcut ─────────────────────────────────
if (data.type === 'call' && data.data?.connectionId) {
const connId = data.data.connectionId ?? ''
const baseUrl = data.data.url ?? ''
if (!openConnections.has(connId)) {
openConnections.set(connId, baseUrl)
openConnections = new Map(openConnections)
connectedUrl = baseUrl
} else {
connectedUrl = openConnections.get(connId)!
}
activeConnectionId = connId
if (installPhase !== 'working') view = 'connected'
// Targeted delivery — wait a frame for the webview DOM to exist
requestAnimationFrame(() => {
sendToWebview({ type: 'call' }, connId)
})
return
}
// ── Desktop-only state (not forwarded to webviews) ─
if (data.type === 'status:open-terminal') { openTerminalStatus = data.data; return }
if (data.type === 'open-terminal:ready') { openTerminalInfo = data.data; openTerminalStatus = 'started'; return }
@@ -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) {
@@ -30,7 +30,7 @@
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3"
d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5a17.92 17.92 0 01-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418"
/>
{/if}
</svg>
@@ -99,6 +99,18 @@
let spotlightRecording = $state(false)
let spotlightShortcutInputEl = $state<HTMLButtonElement | null>(null)
// Voice input shortcut recorder
let voiceInputShortcutValue = $state('')
let voiceInputRecording = $state(false)
let voiceInputShortcutInputEl = $state<HTMLButtonElement | null>(null)
let voiceInputEnabled = $state(true)
// Call shortcut recorder
let callShortcutValue = $state('')
let callRecording = $state(false)
let callShortcutInputEl = $state<HTMLButtonElement | null>(null)
let callEnabled = $state(true)
// Keep shortcut value in sync with config store
$effect(() => {
if ($config?.globalShortcut !== undefined) {
@@ -112,6 +124,24 @@
}
})
$effect(() => {
if ($config?.voiceInputShortcut !== undefined) {
voiceInputShortcutValue = $config.voiceInputShortcut ?? ''
}
if ($config?.voiceInputEnabled !== undefined) {
voiceInputEnabled = $config.voiceInputEnabled ?? true
}
})
$effect(() => {
if ($config?.callShortcut !== undefined) {
callShortcutValue = $config.callShortcut ?? ''
}
if ($config?.callEnabled !== undefined) {
callEnabled = $config.callEnabled ?? true
}
})
const keyToElectron = (e: KeyboardEvent): string | null => {
const parts: string[] = []
if (e.metaKey || e.ctrlKey) parts.push('CommandOrControl')
@@ -122,16 +152,40 @@
const ignore = ['Control', 'Meta', 'Alt', 'Shift']
if (ignore.includes(e.key)) return null
// Map special keys
const keyMap: Record<string, string> = {
' ': 'Space',
// Use e.code to get the physical key (avoids macOS Alt producing unicode like √ for V)
const codeMap: Record<string, string> = {
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 +251,58 @@
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())
}
}
const handleCallShortcutKeydown = async (e: KeyboardEvent) => {
e.preventDefault()
e.stopPropagation()
if (e.key === 'Escape') {
callRecording = false
return
}
if (e.key === 'Backspace' || e.key === 'Delete') {
callShortcutValue = ''
callRecording = false
await window.electronAPI.setConfig({ callShortcut: '' })
config.set(await window.electronAPI.getConfig())
return
}
const accel = keyToElectron(e)
if (accel) {
callShortcutValue = accel
callRecording = false
await window.electronAPI.setConfig({ callShortcut: accel })
config.set(await window.electronAPI.getConfig())
}
}
</script>
<div class="flex flex-col divide-y divide-white/[0.04]">
@@ -412,6 +518,150 @@
</div>
</div>
<div class="py-4 flex items-center justify-between">
<div>
<div class="text-[13px] opacity-70">Voice Input</div>
<div class="text-[11px] opacity-25 mt-0.5">Enable global push-to-talk voice transcription</div>
</div>
<Switch
checked={voiceInputEnabled}
label="Toggle voice input"
onchange={async (value) => {
voiceInputEnabled = value
await window.electronAPI.setConfig({ voiceInputEnabled: value })
config.set(await window.electronAPI.getConfig())
}}
/>
</div>
{#if voiceInputEnabled}
<div class="py-4 flex items-center justify-between">
<div>
<div class="text-[13px] opacity-70">Voice Input Shortcut</div>
<div class="text-[11px] opacity-25 mt-0.5">
{#if voiceInputRecording}
Press a key combination…
{:else}
Toggle microphone recording from anywhere
{/if}
</div>
</div>
<div class="flex items-center gap-1.5">
<button
bind:this={voiceInputShortcutInputEl}
class="text-[12px] px-3 py-1.5 border-none outline-none rounded-xl transition min-w-[80px] text-center
{voiceInputRecording
? 'bg-black/[0.08] dark:bg-white/[0.10] text-[#1d1d1f] dark:text-[#fafafa] opacity-80 animate-pulse'
: 'bg-black/[0.04] dark:bg-white/[0.06] text-[#1d1d1f] dark:text-[#fafafa] opacity-60 hover:opacity-80'}"
onclick={() => {
voiceInputRecording = true
voiceInputShortcutInputEl?.focus()
}}
onkeydown={(e) => {
if (voiceInputRecording) handleVoiceInputShortcutKeydown(e)
}}
onblur={() => {
voiceInputRecording = false
}}
>
{#if voiceInputRecording}
<span class="text-[11px]">Press keys…</span>
{:else if voiceInputShortcutValue}
{displayShortcut(voiceInputShortcutValue)}
{:else}
<span class="opacity-40">Disabled</span>
{/if}
</button>
{#if voiceInputShortcutValue && !voiceInputRecording}
<button
class="opacity-20 hover:opacity-50 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] p-0.5 shrink-0"
onclick={async () => {
voiceInputShortcutValue = ''
await window.electronAPI.setConfig({ voiceInputShortcut: '' })
config.set(await window.electronAPI.getConfig())
}}
>
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
{/if}
</div>
</div>
{/if}
<div class="py-4 flex items-center justify-between">
<div>
<div class="text-[13px] opacity-70">Call</div>
<div class="text-[11px] opacity-25 mt-0.5">Enable global shortcut to start a voice/video call</div>
</div>
<Switch
checked={callEnabled}
label="Toggle call shortcut"
onchange={async (value) => {
callEnabled = value
await window.electronAPI.setConfig({ callEnabled: value })
config.set(await window.electronAPI.getConfig())
}}
/>
</div>
{#if callEnabled}
<div class="py-4 flex items-center justify-between">
<div>
<div class="text-[13px] opacity-70">Call Shortcut</div>
<div class="text-[11px] opacity-25 mt-0.5">
{#if callRecording}
Press a key combination…
{:else}
Start a call from anywhere
{/if}
</div>
</div>
<div class="flex items-center gap-1.5">
<button
bind:this={callShortcutInputEl}
class="text-[12px] px-3 py-1.5 border-none outline-none rounded-xl transition min-w-[80px] text-center
{callRecording
? 'bg-black/[0.08] dark:bg-white/[0.10] text-[#1d1d1f] dark:text-[#fafafa] opacity-80 animate-pulse'
: 'bg-black/[0.04] dark:bg-white/[0.06] text-[#1d1d1f] dark:text-[#fafafa] opacity-60 hover:opacity-80'}"
onclick={() => {
callRecording = true
callShortcutInputEl?.focus()
}}
onkeydown={(e) => {
if (callRecording) handleCallShortcutKeydown(e)
}}
onblur={() => {
callRecording = false
}}
>
{#if callRecording}
<span class="text-[11px]">Press keys…</span>
{:else if callShortcutValue}
{displayShortcut(callShortcutValue)}
{:else}
<span class="opacity-40">Disabled</span>
{/if}
</button>
{#if callShortcutValue && !callRecording}
<button
class="opacity-20 hover:opacity-50 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] p-0.5 shrink-0"
onclick={async () => {
callShortcutValue = ''
await window.electronAPI.setConfig({ callShortcut: '' })
config.set(await window.electronAPI.getConfig())
}}
>
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
{/if}
</div>
</div>
{/if}
<!-- Advanced (collapsed by default) -->
<div class="py-4">
<button
@@ -41,8 +41,10 @@
let repoFiles = $state<HfFileInfo[]>([])
let loadingFiles = $state(false)
// Download state — track active download in the "Downloaded" section
let activeDownload = $state<{ repo: string; filename: string; percent: number } | null>(null)
// Download state — track active downloads in the "Downloaded" section
let activeDownloads = $state<Map<string, { repo: string; filename: string; percent: number }>>(new Map())
const dlKey = (repo: string, filename: string): string => `${repo}/${filename}`
onMount(async () => {
models = await window.electronAPI.listHfModels()
@@ -52,15 +54,22 @@
window.electronAPI.onData((data: any) => {
if (data.type === 'status:huggingface-download') {
const d = data.data
const key = dlKey(d.repo, d.filename)
if (d?.status === 'downloading') {
activeDownload = { repo: d.repo, filename: d.filename, percent: d.percent ?? 0 }
const updated = new Map(activeDownloads)
updated.set(key, { repo: d.repo, filename: d.filename, percent: d.percent ?? 0 })
activeDownloads = updated
}
if (d?.status === 'done') {
activeDownload = null
const updated = new Map(activeDownloads)
updated.delete(key)
activeDownloads = updated
window.electronAPI.listHfModels().then((m: HfModel[]) => { models = m })
}
if (d?.status === 'failed') {
activeDownload = null
const updated = new Map(activeDownloads)
updated.delete(key)
activeDownloads = updated
}
}
})
@@ -109,22 +118,29 @@
}
const startDownload = async (repo: string, filename: string, size?: number) => {
activeDownload = { repo, filename, percent: 0 }
const key = dlKey(repo, filename)
const updated = new Map(activeDownloads)
updated.set(key, { repo, filename, percent: 0 })
activeDownloads = updated
try {
await window.electronAPI.downloadHfModel(repo, filename, undefined, size)
} catch (e) {
console.error('Failed to download model:', e)
activeDownload = null
const cleaned = new Map(activeDownloads)
cleaned.delete(key)
activeDownloads = cleaned
}
}
const cancelDownload = async () => {
const cancelDownload = async (repo: string, filename: string) => {
try {
await window.electronAPI.cancelHfDownload()
await window.electronAPI.cancelHfDownload(repo, filename)
} catch (e) {
console.error('Failed to cancel download:', e)
}
activeDownload = null
const updated = new Map(activeDownloads)
updated.delete(dlKey(repo, filename))
activeDownloads = updated
}
const removeModel = async (repo: string, filename: string) => {
@@ -143,9 +159,15 @@
}
const isDownloading = (repo: string, filename: string): boolean => {
return activeDownload?.repo === repo && activeDownload?.filename === filename
return activeDownloads.has(dlKey(repo, filename))
}
const getDownloadPercent = (repo: string, filename: string): number => {
return activeDownloads.get(dlKey(repo, filename))?.percent ?? 0
}
const hasActiveDownloads = $derived(activeDownloads.size > 0)
const formatSize = (bytes: number): string => {
if (!bytes) return ''
if (bytes < 1024) return `${bytes} B`
@@ -180,50 +202,50 @@
</button>
</div>
<!-- Downloaded models + active download -->
<!-- Downloaded models + active downloads -->
<div class="py-4">
<div class="text-[12px] opacity-50 mb-2">{$i18n.t('settings.models.downloadedModels')}</div>
{#if models.length > 0 || activeDownload}
<div class="flex flex-col gap-1.5">
{#if models.length > 0 || hasActiveDownloads}
<div class="flex flex-col">
<!-- Active download in progress -->
{#if activeDownload}
<div class="px-2.5 py-2 bg-black/[0.03] dark:bg-white/[0.04] rounded-xl">
<div class="flex items-center justify-between gap-2 mb-1.5">
<div class="min-w-0 flex-1">
<div class="text-[12px] opacity-60 truncate font-mono">{activeDownload.filename}</div>
<div class="text-[10px] opacity-25 truncate">{activeDownload.repo} · {$i18n.t('common.downloading')}</div>
<!-- Active downloads -->
{#each [...activeDownloads.values()] as dl (dlKey(dl.repo, dl.filename))}
<div class="flex items-center gap-3 py-2 group">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="text-[12px] opacity-60 truncate font-mono">{dl.filename}</span>
<span class="text-[10px] opacity-30 font-mono shrink-0">{dl.percent.toFixed(1)}%</span>
</div>
<button
class="opacity-30 hover:opacity-70 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] p-1 shrink-0"
onclick={cancelDownload}
title={$i18n.t('settings.models.cancelDownload')}
>
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<div class="mt-1.5 w-full h-[3px] bg-black/[0.06] dark:bg-white/[0.06] rounded-full overflow-hidden">
<div
class="h-full bg-emerald-400/70 rounded-full transition-[width] duration-300"
style="width: {dl.percent}%"
></div>
</div>
<div class="text-[10px] opacity-20 mt-1 truncate">{dl.repo}</div>
</div>
<div class="w-full h-1 bg-black/[0.06] dark:bg-white/[0.06] rounded-full overflow-hidden">
<div
class="h-full bg-emerald-400/80 rounded-full"
style="width: {activeDownload.percent}%"
></div>
</div>
<div class="text-[10px] opacity-25 mt-1 text-right font-mono">{activeDownload.percent.toFixed(1)}%</div>
<button
class="opacity-0 group-hover:opacity-40 hover:!opacity-70 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] p-1 shrink-0"
onclick={() => cancelDownload(dl.repo, dl.filename)}
title={$i18n.t('settings.models.cancelDownload')}
>
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/if}
{/each}
<!-- Completed downloads -->
{#each models as model}
<div class="flex items-center justify-between gap-2 px-2.5 py-2 bg-black/[0.03] dark:bg-white/[0.04] rounded-xl">
<div class="flex items-center gap-3 py-2 group">
<div class="min-w-0 flex-1">
<div class="text-[12px] opacity-60 truncate font-mono">{model.filename}</div>
<div class="text-[10px] opacity-25 truncate">{model.repo} · {formatSize(model.size)}</div>
<div class="text-[10px] opacity-20 truncate mt-0.5">{model.repo} · {formatSize(model.size)}</div>
</div>
<button
class="opacity-20 hover:opacity-60 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] p-1 shrink-0 {deleting === `${model.repo}/${model.filename}` ? 'pointer-events-none' : ''}"
class="opacity-0 group-hover:opacity-30 hover:!opacity-60 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] p-1 shrink-0 {deleting === `${model.repo}/${model.filename}` ? '!opacity-30 pointer-events-none' : ''}"
onclick={() => removeModel(model.repo, model.filename)}
title={$i18n.t('settings.models.deleteModel')}
>
@@ -239,7 +261,7 @@
{/each}
</div>
{:else}
<div class="text-[11px] opacity-40 py-3">{$i18n.t('settings.models.noModels')}</div>
<div class="text-[11px] opacity-20 py-3">{$i18n.t('settings.models.noModels')}</div>
{/if}
</div>
@@ -248,13 +270,13 @@
<div class="text-[12px] opacity-50 mb-2">
{#if selectedRepo}
<button
class="opacity-50 hover:opacity-80 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] p-0 text-[12px] flex items-center gap-1"
class="opacity-70 hover:opacity-100 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] p-0 text-[12px] flex items-center gap-1 font-mono truncate"
onclick={backToSearch}
>
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<svg class="w-3 h-3 shrink-0 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
{$i18n.t('common.back')}
<span class="truncate">{selectedRepo}</span>
</button>
{:else}
{$i18n.t('settings.models.downloadFromHF')}
@@ -263,10 +285,6 @@
{#if selectedRepo}
<!-- Repo file browser -->
<div class="mb-2">
<div class="text-[12px] opacity-60 font-mono truncate mb-2">{selectedRepo}</div>
</div>
{#if loadingFiles}
<div class="flex items-center gap-2 py-3 justify-center">
<div class="w-3 h-3 rounded-full border-[1.5px] border-black/20 dark:border-white/30 border-t-transparent animate-spin"></div>
@@ -275,27 +293,42 @@
{:else if repoFiles.length === 0}
<div class="text-[11px] opacity-20 text-center py-3">{$i18n.t('settings.models.noGgufFiles')}</div>
{:else}
<div class="flex flex-col gap-1">
<div class="flex flex-col">
{#each repoFiles as file}
{@const downloaded = isDownloaded(selectedRepo, file.filename)}
{@const dlActive = isDownloading(selectedRepo, file.filename)}
<div class="flex items-center justify-between gap-2 px-2.5 py-2 bg-black/[0.03] dark:bg-white/[0.04] rounded-xl">
<div class="flex items-center gap-3 py-2 group">
<div class="min-w-0 flex-1">
<div class="text-[12px] opacity-50 truncate font-mono">{file.filename}</div>
<div class="text-[10px] opacity-25">{formatSize(file.size)}</div>
<div class="text-[10px] opacity-20 mt-0.5">{formatSize(file.size)}</div>
{#if dlActive}
<div class="mt-1.5 w-full h-[3px] bg-black/[0.06] dark:bg-white/[0.06] rounded-full overflow-hidden">
<div
class="h-full bg-emerald-400/70 rounded-full transition-[width] duration-300"
style="width: {getDownloadPercent(selectedRepo, file.filename)}%"
></div>
</div>
{/if}
</div>
{#if downloaded}
<span class="text-[10px] opacity-30 shrink-0 px-2">{$i18n.t('settings.models.downloaded')}</span>
<span class="text-[10px] opacity-25 shrink-0">{$i18n.t('settings.models.downloaded')}</span>
{:else if dlActive}
<div class="flex items-center gap-1.5 shrink-0">
<div class="w-2.5 h-2.5 rounded-full border-[1.5px] border-black/20 dark:border-white/30 border-t-transparent animate-spin"></div>
<span class="text-[10px] opacity-40 font-mono">{activeDownload?.percent?.toFixed(0) ?? 0}%</span>
<span class="text-[10px] opacity-40 font-mono">{getDownloadPercent(selectedRepo, file.filename).toFixed(0)}%</span>
<button
class="opacity-30 hover:opacity-70 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] p-0.5"
onclick={() => cancelDownload(selectedRepo, file.filename)}
title={$i18n.t('settings.models.cancelDownload')}
>
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{:else}
<button
class="opacity-30 hover:opacity-70 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] p-1 shrink-0 {activeDownload ? 'pointer-events-none opacity-10' : ''}"
class="opacity-0 group-hover:opacity-40 hover:!opacity-70 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] p-1 shrink-0"
onclick={() => startDownload(selectedRepo, file.filename, file.size)}
disabled={!!activeDownload}
title={$i18n.t('common.download')}
>
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
@@ -329,10 +362,10 @@
{#if searchError}
<div class="text-[11px] text-red-400/70 text-center py-2">{searchError}</div>
{:else if searchResults.length > 0}
<div class="flex flex-col gap-1 max-h-[300px] overflow-y-auto">
<div class="flex flex-col max-h-[300px] overflow-y-auto">
{#each searchResults as repo}
<button
class="flex items-center justify-between gap-2 px-2.5 py-2 bg-black/[0.03] dark:bg-white/[0.04] hover:bg-black/[0.06] dark:hover:bg-white/[0.08] rounded-xl transition border-none text-left w-full text-[#1d1d1f] dark:text-[#fafafa]"
class="flex items-center justify-between gap-2 py-2 hover:bg-black/[0.03] dark:hover:bg-white/[0.04] rounded-lg transition border-none text-left w-full text-[#1d1d1f] dark:text-[#fafafa] bg-transparent px-1"
onclick={() => selectRepo(repo.id)}
>
<div class="min-w-0 flex-1">
@@ -352,7 +385,7 @@
</span>
</div>
</div>
<svg class="w-3 h-3 opacity-20 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<svg class="w-3 h-3 opacity-15 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
</button>
+8
View File
@@ -0,0 +1,8 @@
import { mount } from 'svelte'
import VoiceInput from './components/VoiceInput.svelte'
const app = mount(VoiceInput, {
target: document.getElementById('app')!
})
export default app
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Open WebUI Voice Input</title>
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'"
/>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/voice-input-main.ts"></script>
</body>
</html>