mirror of
https://github.com/open-webui/desktop.git
synced 2026-07-15 04:35:40 -04:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eb3c569078 | |||
| 3350da65ec | |||
| 953327b9ef | |||
| 1a56df0c6e | |||
| 44c40eabd6 | |||
| d475bde04a | |||
| 5cbe7553d3 | |||
| 7160392959 | |||
| a889d0e607 | |||
| ef66b1b21a | |||
| 6c669f1389 | |||
| fe398bc65d | |||
| f38c95befe | |||
| 4db0faff97 | |||
| 13dfb0f779 | |||
| 201b08826e |
@@ -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
|
||||
|
||||
@@ -1,87 +1,77 @@
|
||||
# Open WebUI Desktop
|
||||
|
||||
[](https://github.com/open-webui/desktop/releases)
|
||||
[](https://github.com/open-webui/desktop/releases)
|
||||
[](https://discord.gg/open-webui)
|
||||
[](LICENSE)
|
||||
|
||||

|
||||
|
||||
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>
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
})
|
||||
|
||||
@@ -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
@@ -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> => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user