Compare commits

...

75 Commits

Author SHA1 Message Date
Timothy Jaeryang Baek 0931b46d66 refac 2026-05-06 22:57:36 +09:00
Timothy Jaeryang Baek 601137c5de fix: replace --in-process-gpu with SwiftShader to fix blank webviews on Linux
The --in-process-gpu flag broke <webview> guest compositing entirely,
leaving connection views blank on all Linux configurations. SwiftShader
(--use-gl=angle --use-angle=swiftshader) keeps the GPU process
out-of-process (required for webview compositing) while using software
rendering to avoid the driver-level /dev/shm crashes that originally
motivated the in-process flag.
2026-05-06 22:41:00 +09:00
Timothy Jaeryang Baek a097a83c46 refac 2026-05-06 22:33:38 +09:00
Timothy Jaeryang Baek f8c20275cd refactor: decouple local server from connections array
The local Open WebUI server is a singleton — it's either installed or not.
Previously it was modeled as a type:'local' entry in the connections[]
config array, which broke when installing from Settings (the entry was
only created during the Get Started flow).

Now:
- connections[] is remote-only
- Local server is implicit when open-webui package is installed
- 'local' is a virtual connection ID, synthesized on the fly
- Main process: add getDefaultConnection/buildLocalConnection/resolveConnectionUrl
  helpers, replacing ~70 lines of duplicated config lookup across 5 handlers
- Renderer: localConn derived from localInstalled state, not connections store
- Push-based reactivity: main process emits connections:changed events
- Tray: virtual local entry when package is installed
- Migration: strip legacy type:'local' entries from config on startup
2026-05-06 22:09:20 +09:00
Timothy Jaeryang Baek fa64bc02b5 chore: complete v0.0.19 changelog 2026-05-06 21:18:27 +09:00
Timothy Jaeryang Baek 20f0aaf40c fix: prevent Spotlight from pulling to first desktop on macOS Spaces (#179)
On macOS, app.focus({ steal: true }) activates the entire application,
causing the window manager to switch back to whichever Space the app was
originally launched on. Replace it with targeted window-level focus and
add visibleOnAllWorkspaces to ensure Spotlight appears on the current
Space without triggering a desktop switch.
2026-05-06 21:17:15 +09:00
Timothy Jaeryang Baek c64e946b38 Update release.yml 2026-05-06 21:05:52 +09:00
Timothy Jaeryang Baek 1902f791cb fix: auto-install Python when Open Terminal starts without it
Previously, startOpenTerminal threw 'Python is not installed' which was
caught by the IPC handler but only produced a generic error toast with no
actionable guidance. On a fresh install where the user hasn't started a
Local Server first, this made Open Terminal appear completely broken.

Now startOpenTerminal automatically triggers installPython() when Python
is missing, reusing the same download/extract/uv-install flow that the
Local Server setup uses. Install progress (download %, extraction) is
forwarded to the renderer via a new status:open-terminal-setup event and
displayed in the log panel status bar.

Fixes #168
2026-05-05 01:35:52 +09:00
Timothy Jaeryang Baek c2f128aec0 fix: replace --disable-gpu with --in-process-gpu on Linux (#178)
Both --disable-gpu and --disable-gpu-compositing kill the display
compositor, preventing <webview> guest surfaces from painting (gray
rectangle).  --in-process-gpu moves the GPU thread into the browser
process, sidestepping the cross-process shared-memory IPC crashes
while keeping the compositor alive so webview content renders.
2026-05-05 00:36:27 +09:00
Timothy Jaeryang Baek 64c399738e fix: store HF models directly under models/ for llama-server discovery
llama-server's --models-dir scanner only checks one level of
subdirectories. Downloaded models were stored two levels deep at
models/huggingface/<repo-slug>/<filename>, making them invisible.

Changed the cache directory from models/huggingface/ to models/ so
models land at models/<repo-slug>/<filename> — exactly one level deep.

Includes automatic migration of existing models from the legacy
huggingface/ subdirectory on first run.

Fixes #177
2026-05-05 00:21:59 +09:00
Timothy Jaeryang Baek 842c1481f6 fix: allow webview popups so links open in default browser on Windows (#165, #170) 2026-05-03 00:42:27 +09:00
Timothy Jaeryang Baek e7eef2da86 chore: bump version to 0.0.17 2026-05-03 00:09:33 +09:00
Timothy Jaeryang Baek aab2a687cc refac 2026-05-03 00:06:03 +09:00
Timothy Jaeryang Baek 9e204e78b7 doc 2026-05-02 22:37:04 +09:00
Timothy Jaeryang Baek ef53d6fb21 fix: open links in default browser instead of within the app (#165) 2026-05-02 19:04:15 +09:00
Timothy Jaeryang Baek 21e8a36e0d chore: bump version to 0.0.15 2026-04-28 14:32:36 +09:00
Timothy Jaeryang Baek fcb32f93ab feat: add ARM64 support for Linux and Windows
- Add ubuntu-24.04-arm and windows-11-arm to CI matrix
- Linux ARM64 builds AppImage + deb (Raspberry Pi, DGX Spark)
- Windows ARM64 builds NSIS installer (Snapdragon devices)
- Add arch to NSIS and AppImage artifact names to prevent collisions
- Add latest-linux.yml and latest.yml merge steps for auto-updater
- Update README with ARM64 download links

Closes #140
2026-04-28 14:31:23 +09:00
Timothy Jaeryang Baek 48be8d0386 fix(linux): disable GPU entirely to prevent shared memory crash and grey screen
Escalate from --disable-gpu-compositing to --disable-gpu on Linux.
The GPU process crashes during shared memory allocation in /tmp even
with --disable-dev-shm-usage, and disabling compositing alone still
leaves the GPU process running. --disable-gpu prevents the process
from spawning entirely, resolving both the crash and grey screen.

Closes #157, Closes #119
2026-04-28 14:23:36 +09:00
Timothy Jaeryang Baek 4cab91de4e fix: spotlight dismiss no longer brings main window to foreground (#158)
Escape, click-away, hotkey toggle, and blur now hide the spotlight
overlay silently.  Only spotlight:submit restores the main window
so the user sees their query delivered.
2026-04-28 14:08:58 +09:00
Timothy Jaeryang Baek 564a89baa8 chore: bump version to 0.0.14 and update changelog 2026-04-28 13:20:20 +09:00
Timothy Jaeryang Baek 25b9e195c2 fix(linux): disable GPU compositing to prevent grey/blank webview screen
On many Linux setups (Intel iGPU, mixed NVIDIA/Intel, Wayland
compositors), the GPU compositor fails silently without crashing
the GPU process — producing a grey rectangle where the webview
content should be.  The existing GPU crash recovery marker never
fires because the process stays alive.

Adding --disable-gpu-compositing on Linux is the standard Electron
workaround (used by VS Code, Brave, etc.) for this class of issue.
It disables only the compositor, not all hardware acceleration,
so video decode and basic GPU ops still work.

Also adds render-process-gone listeners to auto-reload the main
window on renderer crashes and log webview guest crashes for
diagnostics.

Closes #119
2026-04-27 15:15:44 +09:00
Timothy Jaeryang Baek 61db9dc10f fix: grant clipboard-write permission to webview sessions (fixes #154)
The Copy button in the Open WebUI interface silently failed on
GNOME/Wayland/Flatpak because the webview session permission handlers
did not include 'clipboard-sanitized-write'. Electron denied the
navigator.clipboard.writeText() call, but the web app saw no error
and briefly showed 'Copied' without actually writing to the clipboard.

Added 'clipboard-sanitized-write' to both permission handler whitelists:
- Per-connection content window handler
- session-created handler for webview partition sessions
2026-04-27 14:32:35 +09:00
Timothy Jaeryang Baek 27a3075c3a fix(linux): use /tmp for shared memory to prevent AppImage /dev/shm crashes
Add --disable-dev-shm-usage flag on Linux so Chromium writes shared
memory to /tmp instead of /dev/shm.  AppImage's FUSE mount restricts
child-process access to /dev/shm, causing FATAL zygote/renderer crashes
with 'Unable to access(W_OK|X_OK) /dev/shm' — resulting in a blank/grey
screen.  Also affects .deb and Snap packages on some distros.

Fixes #136
2026-04-25 00:32:27 +09:00
Timothy Jaeryang Baek 8c990befbe chore: bump version to 0.0.12 and update changelog 2026-04-25 00:29:27 +09:00
Timothy Jaeryang Baek da84e49970 fix: enable npmRebuild and unpack node-pty to fix Linux deb crash (#125)
- Set npmRebuild: true so native modules are compiled against
  Electron's Node ABI on each platform during packaging
- Add node_modules/node-pty/** to asarUnpack so the native pty.node
  binary is extracted to the real filesystem instead of being
  trapped inside app.asar where require() cannot load it
2026-04-25 00:27:10 +09:00
Timothy Jaeryang Baek e0af7f3d32 fix: show error overlay instead of grey screen on webview load failure
When connecting to remote or auth-enabled instances, the webview could
fail to load silently — leaving users with a blank grey screen and no
feedback (#119, #124).

Changes:
- Add did-fail-load, crashed, and did-navigate event listeners to
  webviews so load failures are captured per-connection
- Surface the existing webviewLoading state in the loading overlay
  (was tracked but never rendered)
- Show an error overlay with the failure description, Retry button,
  and Open in Browser fallback when the webview fails to load
- Fix preload race condition: webviews created before the async
  contentPreloadPath resolves now get the correct preload reapplied
- Add console-message listener (warnings/errors) for diagnosing
  blank-page issues where the page loads but JS fails silently
2026-04-25 00:23:33 +09:00
Timothy Jaeryang Baek 4f653f5fcd fix: support global shortcuts on Wayland/Flatpak via xdg-desktop-portal
- Enable native Wayland backend with ozone-platform-hint=auto on Linux,
  allowing Chromium's built-in GlobalShortcutsPortal (default since Cr134)
  to route globalShortcut.register() through xdg-desktop-portal
- Add org.freedesktop.portal.Desktop talk-name to Flatpak finishArgs so
  the sandbox permits portal D-Bus calls
- Refactor shortcut registration into tryRegisterShortcut() helper with
  consistent error handling and user-facing notifications for all four
  shortcuts (global, spotlight, voice input, call)

Fixes #126
2026-04-25 00:22:02 +09:00
Timothy Jaeryang Baek 3a76f985ab feat: persist window size and position across restarts
- Save window bounds (size + position) and maximized state to config on
  resize, move, maximize, and unmaximize with debounced writes
- Restore saved geometry on launch with display visibility validation
  to handle disconnected monitors gracefully
- Lower minimum window size from 1280x800 to 480x360 to support
  smaller screens and compact layouts
- Extract magic numbers into named constants and consolidate event
  handlers for cleaner maintainability

Closes #145, Closes #109
2026-04-25 00:10:25 +09:00
Timothy Jaeryang Baek 06808cb284 feat: add toggleable clipboard auto-paste for Spotlight
Add a 'spotlightClipboardPaste' setting (default: true) that controls
whether clipboard contents are automatically pasted into the Spotlight
prompt window. Users can disable this in Settings > General to prevent
sensitive data (e.g. passwords) from being exposed.

Closes #123
2026-04-25 00:06:05 +09:00
Timothy Jaeryang Baek ed26423f90 chore: bump version to 0.0.11 2026-04-25 00:00:54 +09:00
Timothy Jaeryang Baek 5e95e918c7 refac 2026-04-25 00:00:16 +09:00
Timothy Jaeryang Baek 84c93aaeb6 fix: add disable-library-validation entitlement to fix macOS launch crash
The build/entitlements.mac.plist was missing the critical
com.apple.security.cs.disable-library-validation entitlement, causing
macOS to reject loading the Electron Framework due to Team ID mismatch.

Also syncs all other entitlements (network, camera, microphone, etc.)
from the root entitlements.plist to ensure consistency.
2026-04-24 23:59:57 +09:00
Timothy Jaeryang Baek 37f0891840 fix(#108): trust all SSL certificates for HTTPS connections
Bypass certificate verification globally so the desktop app can connect
to Open WebUI instances using self-signed, private-CA, ZeroSSL, or any
other non-publicly-trusted certificates.

Three layers:
- session.defaultSession.setCertificateVerifyProc: covers net.fetch()
  used by validateRemoteUrl / checkUrlAndOpen
- app.on('session-created'): covers partitioned webview sessions
  (persist:connection-*) including in-page API calls
- app.on('certificate-error'): fallback for BrowserWindow navigations

Also switches validateRemoteUrl and checkUrlAndOpen from Node fetch()
to Electron net.fetch() so they route through Chromium's network stack
and respect the session certificate overrides.
2026-04-24 23:59:25 +09:00
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
Timothy Jaeryang Baek e12bc93d71 refac 2026-04-10 10:27:30 -07:00
Timothy Jaeryang Baek be5661116f refac 2026-04-10 10:06:17 -07:00
Timothy Jaeryang Baek 0680b56e1c refac 2026-04-10 09:58:59 -07:00
Timothy Jaeryang Baek 20d7f145c7 refac 2026-04-09 11:02:55 -07:00
Timothy Jaeryang Baek 0edc4d7532 refac 2026-04-09 09:17:43 -07:00
Timothy Jaeryang Baek 36eacde7e9 refac 2026-04-08 14:30:14 -07:00
Timothy Jaeryang Baek 03f6abae75 refac 2026-04-08 13:49:32 -07:00
Timothy Jaeryang Baek 08616e701d refac 2026-04-07 10:03:11 -05:00
Timothy Jaeryang Baek 34ce8c9d4f doc: readme 2026-04-07 09:58:53 -05:00
Tim Baek c551bf0cb6 refac 2026-04-06 16:10:35 -05:00
Tim Baek 335f72ae4c refac 2026-04-06 13:34:08 -05:00
Tim Baek 563d87349c refac 2026-04-06 13:07:41 -05:00
Timothy Jaeryang Baek b9ae57d008 refac 2026-04-06 12:56:25 -05:00
Timothy Jaeryang Baek 8585558492 refac 2026-04-06 12:31:17 -05:00
Timothy Jaeryang Baek 08356674e1 fix: release 2026-04-06 12:15:30 -05:00
Timothy Jaeryang Baek b4ae19abf0 fix: release 2026-04-06 11:59:26 -05:00
Timothy Jaeryang Baek 5aa13c813e v0.0.3: fix spotlight focus after page interaction, fix ?q= query passthrough 2026-04-06 11:40:25 -05:00
Timothy Jaeryang Baek 14258c4b36 v0.0.2: spotlight input bar, system theme sync, persistent spotlight position 2026-04-06 11:32:15 -05:00
Timothy Jaeryang Baek a2f6de45f5 refac 2026-04-02 20:36:40 -05:00
Timothy Jaeryang Baek 3b3349d3b5 refac 2026-04-02 20:31:48 -05:00
Timothy Jaeryang Baek b099a4a6fa refac 2026-04-02 20:28:21 -05:00
Timothy Jaeryang Baek 4920e90bef refac 2026-04-02 20:24:01 -05:00
Timothy Jaeryang Baek 6852b4f83e refac 2026-04-02 19:24:34 -05:00
Timothy Jaeryang Baek a29f1fe1f1 Update .npmrc 2026-04-02 18:53:57 -05:00
Timothy Jaeryang Baek 4076e12976 refac 2026-03-31 17:48:05 -05:00
Timothy Jaeryang Baek e9f8c89cc9 refac 2026-03-22 22:38:10 -05:00
45 changed files with 4196 additions and 530 deletions
+270 -49
View File
@@ -6,8 +6,35 @@ on:
- release
jobs:
build:
name: Build and Package
compile:
name: Compile (Typecheck + Vite Build)
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: 'npm'
- name: Install Dependencies
run: npm ci
- name: Typecheck and Build
run: npm run build
- name: Upload Compiled Output
uses: actions/upload-artifact@v4
with:
name: compiled-output
path: out/
retention-days: 1
package:
name: Package (${{ matrix.os }}-${{ matrix.arch }})
needs: compile
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
@@ -15,8 +42,12 @@ jobs:
include:
- os: ubuntu-latest
arch: x64
- os: ubuntu-24.04-arm
arch: arm64
- os: windows-latest
arch: x64
- os: windows-11-arm
arch: arm64
- os: macos-latest
arch: x64
- os: macos-latest
@@ -29,14 +60,28 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: 22
cache: "npm"
cache: 'npm'
- name: Install Dependencies
run: npm ci
- name: Download Compiled Output
uses: actions/download-artifact@v4
with:
name: compiled-output
path: out/
# ── Flatpak setup (Linux x64 only) ──
- name: Cache Flatpak SDKs
if: runner.os == 'Linux' && matrix.arch == 'x64'
uses: actions/cache@v4
with:
path: ~/.local/share/flatpak
key: flatpak-sdk-23.08-electron2-${{ runner.os }}
- name: Install Flatpak build tools
id: flatpak
if: ${{ matrix.os == 'ubuntu-latest' }}
if: runner.os == 'Linux' && matrix.arch == 'x64'
continue-on-error: true
run: |
sudo apt-get update
@@ -45,9 +90,10 @@ jobs:
flatpak install --user -y flathub org.freedesktop.Platform//23.08 org.freedesktop.Sdk//23.08
flatpak install --user -y flathub org.electronjs.Electron2.BaseApp//23.08
# ── Apple codesigning (macOS only) ──
- name: Install Apple codesigning certificate
id: apple_cert
if: ${{ matrix.os == 'macos-latest' }}
if: matrix.os == 'macos-latest'
continue-on-error: true
env:
BUILD_CERTIFICATE_BASE64: ${{ secrets.BUILD_CERTIFICATE_BASE64 }}
@@ -65,41 +111,61 @@ jobs:
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security import $CERTIFICATE_PATH -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security list-keychain -d user -s $KEYCHAIN_PATH
# Build commands — use electron-builder directly to pass arch flags
- name: Create Windows Builds
if: ${{ matrix.os == 'windows-latest' }}
run: npm run build && npx electron-builder --win --${{ matrix.arch }}
# ── Platform packaging ──
- name: Package for Windows
id: win_build
if: runner.os == 'Windows'
continue-on-error: true
run: npx electron-builder --win --${{ matrix.arch }} --publish never
- name: Create macOS Builds (signed)
if: ${{ matrix.os == 'macos-latest' && steps.apple_cert.outcome == 'success' }}
- name: Package for Windows (unsigned fallback)
if: runner.os == 'Windows' && steps.win_build.outcome == 'failure'
env:
WIN_CSC_LINK: ''
CSC_IDENTITY_AUTO_DISCOVERY: 'false'
run: |
rm -rf dist/
npx electron-builder --win --${{ matrix.arch }} --publish never
- name: Package for macOS (signed + notarized)
id: mac_build
if: matrix.os == 'macos-latest' && steps.apple_cert.outcome == 'success'
continue-on-error: true
env:
CSC_LINK: ${{ secrets.BUILD_CERTIFICATE_BASE64 }}
CSC_KEY_PASSWORD: ${{ secrets.P12_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: npm run build && npx electron-builder --mac --${{ matrix.arch }}
run: npx electron-builder --mac --${{ matrix.arch }} -c.mac.notarize=true --publish never
- name: Create macOS Builds (unsigned fallback)
if: ${{ matrix.os == 'macos-latest' && steps.apple_cert.outcome != 'success' }}
- name: Package for macOS (unsigned fallback)
if: matrix.os == 'macos-latest' && (steps.apple_cert.outcome != 'success' || steps.mac_build.outcome == 'failure')
env:
CSC_IDENTITY_AUTO_DISCOVERY: 'false'
run: npm run build && npx electron-builder --mac --${{ matrix.arch }}
- name: Create Linux Builds
if: ${{ matrix.os == 'ubuntu-latest' }}
run: |
npm run build
if [ "${{ steps.flatpak.outcome }}" == "success" ]; then
npx electron-builder --linux --${{ matrix.arch }}
rm -rf dist/
npx electron-builder --mac --${{ matrix.arch }} --publish never
- name: Package for Linux
if: runner.os == 'Linux'
run: |
if [ "${{ matrix.arch }}" == "arm64" ]; then
# ARM64: deb + AppImage only (flatpak BaseApp not available for arm64)
npx electron-builder --linux AppImage deb --${{ matrix.arch }} --publish never
elif [ "${{ steps.flatpak.outcome }}" == "success" ]; then
npx electron-builder --linux --${{ matrix.arch }} --publish never
else
echo "Flatpak not available, building without flatpak"
npx electron-builder --linux AppImage deb snap --${{ matrix.arch }}
npx electron-builder --linux AppImage deb snap --${{ matrix.arch }} --publish never
fi
# Sign Windows executable after build
# ── Windows code signing ──
- name: Azure Trusted Signing (Windows Only)
if: ${{ matrix.os == 'windows-latest' }}
if: runner.os == 'Windows'
continue-on-error: true
uses: azure/trusted-signing-action@v0.5.1
with:
@@ -112,12 +178,7 @@ jobs:
files-folder: dist
files-folder-filter: exe
- name: List files for debugging
shell: bash
run: |
echo "Files in dist directory:"
ls -la dist/ || echo "dist directory not found"
# ── Upload release artifacts ──
- name: Upload Artifacts
uses: actions/upload-artifact@v4
with:
@@ -138,14 +199,24 @@ jobs:
if-no-files-found: warn
release:
needs: build
if: always() && github.event_name == 'push' && github.ref == 'refs/heads/release'
name: Create GitHub Release
needs: package
if: >-
github.event_name == 'push' &&
github.ref == 'refs/heads/release' &&
!cancelled() &&
needs.package.result != 'failure'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout Repository
uses: actions/checkout@v4
with:
sparse-checkout: |
package.json
CHANGELOG.md
sparse-checkout-cone-mode: false
- name: Get version from package.json
id: version
@@ -171,16 +242,162 @@ jobs:
- name: Download Artifacts
uses: actions/download-artifact@v4
with:
pattern: '*-*'
merge-multiple: false
- name: List downloaded artifacts
- name: Install js-yaml for manifest merging
run: npm install --no-save js-yaml
- name: Merge macOS latest-mac.yml (x64 + arm64)
run: |
echo "Downloaded artifacts:"
find . -type f | grep -E "\.(exe|zip|dmg|pkg|deb|rpm|AppImage|flatpak|tar\.gz|yml|blockmap)$" || echo "No package files found"
ls -laR
# Each macOS arch build produces its own latest-mac.yml with only
# that arch's entry. Merge them so electron-updater works for both.
X64_YML="macos-latest-x64/latest-mac.yml"
ARM_YML="macos-latest-arm64/latest-mac.yml"
if [ -f "$X64_YML" ] && [ -f "$ARM_YML" ]; then
echo "Merging latest-mac.yml from both architectures"
node -e "
const fs = require('fs');
const yaml = require('js-yaml');
const x64 = yaml.load(fs.readFileSync('$X64_YML', 'utf8'));
const arm = yaml.load(fs.readFileSync('$ARM_YML', 'utf8'));
const all = [...(x64.files || []), ...(arm.files || [])];
const map = new Map();
for (const f of all) map.set(f.url, f);
x64.files = [...map.values()];
const out = yaml.dump(x64, { lineWidth: -1 });
fs.writeFileSync('$X64_YML', out);
console.log(out);
"
rm -f "$ARM_YML"
else
echo "Skipping merge — need both $X64_YML and $ARM_YML"
ls -la macos-latest-*/latest-mac.yml 2>/dev/null || true
fi
- name: Merge Linux latest-linux.yml (x64 + arm64)
run: |
X64_YML="ubuntu-latest-x64/latest-linux.yml"
# electron-builder names the arm64 Linux manifest "latest-linux-arm64.yml"
ARM_YML="ubuntu-24.04-arm-arm64/latest-linux-arm64.yml"
if [ -f "$X64_YML" ] && [ -f "$ARM_YML" ]; then
echo "Merging latest-linux.yml from both architectures"
node -e "
const fs = require('fs');
const yaml = require('js-yaml');
const x64 = yaml.load(fs.readFileSync('$X64_YML', 'utf8'));
const arm = yaml.load(fs.readFileSync('$ARM_YML', 'utf8'));
const all = [...(x64.files || []), ...(arm.files || [])];
const map = new Map();
for (const f of all) map.set(f.url, f);
x64.files = [...map.values()];
const out = yaml.dump(x64, { lineWidth: -1 });
fs.writeFileSync('$X64_YML', out);
console.log(out);
"
rm -f "$ARM_YML"
else
echo "Skipping merge — need both $X64_YML and $ARM_YML"
ls -la ubuntu-*-*/latest-linux*.yml 2>/dev/null || true
fi
- name: Merge Windows latest.yml (x64 + arm64)
run: |
X64_YML="windows-latest-x64/latest.yml"
ARM_YML="windows-11-arm-arm64/latest.yml"
if [ -f "$X64_YML" ] && [ -f "$ARM_YML" ]; then
echo "Merging latest.yml from both architectures"
node -e "
const fs = require('fs');
const yaml = require('js-yaml');
const x64 = yaml.load(fs.readFileSync('$X64_YML', 'utf8'));
const arm = yaml.load(fs.readFileSync('$ARM_YML', 'utf8'));
const all = [...(x64.files || []), ...(arm.files || [])];
const map = new Map();
for (const f of all) map.set(f.url, f);
x64.files = [...map.values()];
const out = yaml.dump(x64, { lineWidth: -1 });
fs.writeFileSync('$X64_YML', out);
console.log(out);
"
rm -f "$ARM_YML"
else
echo "Skipping merge — need both $X64_YML and $ARM_YML"
ls -la windows-*-*/latest.yml 2>/dev/null || true
fi
- name: Reconcile update manifest hashes
run: |
# Recompute SHA512 from actual artifact files to ensure manifests
# match the real binaries (fixes signed/unsigned hash mismatches)
node -e "
const fs = require('fs');
const crypto = require('crypto');
const yaml = require('js-yaml');
const path = require('path');
const manifests = [
{ prefix: 'macos-latest-', file: 'latest-mac.yml' },
{ prefix: 'ubuntu-', file: 'latest-linux.yml' },
{ prefix: 'windows-', file: 'latest.yml' },
];
const allDirs = fs.readdirSync('.').filter(d => {
try { return fs.statSync(d).isDirectory(); } catch { return false; }
});
for (const { prefix, file } of manifests) {
const dirs = allDirs.filter(d => d.startsWith(prefix));
let ymlPath = null;
for (const dir of dirs) {
const p = path.join(dir, file);
if (fs.existsSync(p)) { ymlPath = p; break; }
}
if (!ymlPath) { console.log('No ' + file + ' found, skipping'); continue; }
const manifest = yaml.load(fs.readFileSync(ymlPath, 'utf8'));
let fixed = 0;
for (const entry of manifest.files) {
for (const dir of dirs) {
const filePath = path.join(dir, entry.url);
if (fs.existsSync(filePath)) {
const buf = fs.readFileSync(filePath);
const hash = crypto.createHash('sha512').update(buf).digest('base64');
if (hash !== entry.sha512) {
console.log('[' + file + '] Fix: ' + entry.url);
entry.sha512 = hash;
entry.size = buf.length;
fixed++;
}
break;
}
}
}
if (manifest.path) {
for (const dir of dirs) {
const filePath = path.join(dir, manifest.path);
if (fs.existsSync(filePath)) {
const buf = fs.readFileSync(filePath);
manifest.sha512 = crypto.createHash('sha512').update(buf).digest('base64');
break;
}
}
}
const out = yaml.dump(manifest, { lineWidth: -1 });
fs.writeFileSync(ymlPath, out);
console.log('[' + file + '] ' + (fixed ? 'Fixed ' + fixed + ' entries' : 'All hashes OK'));
}
"
- name: Create Release
id: create_release
uses: softprops/action-gh-release@v1
uses: softprops/action-gh-release@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
@@ -190,14 +407,18 @@ jobs:
draft: false
prerelease: false
files: |
**/*.zip
**/*.exe
**/*.dmg
**/*.pkg
**/*.deb
**/*.rpm
**/*.AppImage
**/*.flatpak
**/*.tar.gz
**/*.blockmap
**/latest*.yml
windows-*-*/*.exe
windows-*-*/*.blockmap
windows-*-*/latest*.yml
macos-latest-*/*.dmg
macos-latest-*/*.zip
macos-latest-*/*.pkg
macos-latest-*/*.blockmap
macos-latest-*/latest*.yml
ubuntu-*-*/*.deb
ubuntu-*-*/*.rpm
ubuntu-*-*/*.AppImage
ubuntu-*-*/*.snap
ubuntu-*-*/*.flatpak
ubuntu-*-*/*.tar.gz
ubuntu-*-*/latest*.yml
+1
View File
@@ -6,3 +6,4 @@ out
.DS_Store
.eslintcache
*.log*
*.tsbuildinfo
+1 -2
View File
@@ -1,2 +1 @@
electron_mirror=https://npmmirror.com/mirrors/electron/
electron_builder_binaries_mirror=https://npmmirror.com/mirrors/electron-builder-binaries/
+200 -16
View File
@@ -5,23 +5,207 @@ 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.20] - 2026-05-07
### Fixed
- **Blank Webview on Linux.** Replaced the `--in-process-gpu` Chromium flag with SwiftShader software rendering (`--use-gl=angle --use-angle=swiftshader`). The in-process GPU flag broke `<webview>` guest compositing entirely, leaving connection views blank on all Linux configurations. SwiftShader keeps the GPU process out-of-process (required for webview compositing) while avoiding driver-level crashes (#178).
## [0.0.19] - 2026-05-06
### Fixed
- **Spotlight Pulls to First Desktop on macOS.** Spotlight no longer switches Spaces when triggered from a non-primary desktop. The window is now visible on all workspaces, and `app.focus({ steal: true })` — which activated the entire app and caused the Space switch — has been replaced with targeted window-level focus (#179).
- **Gray Screen When Connecting to Server on Linux.** Replaced the `--disable-gpu` Chromium flag with `--in-process-gpu`, which keeps the display compositor alive so `<webview>` guest surfaces actually paint instead of showing a gray rectangle. The previous flag fixed GPU process crashes but broke webview rendering on Debian and Ubuntu (#178).
- **Open Terminal Fails Silently Without Python.** Open Terminal now automatically installs Python when it's missing instead of throwing an opaque error. Progress status is surfaced in the UI during installation.
- **Corrupt Auto-Update Manifests.** Fixed the release workflow to deduplicate artifact entries during manifest merging, preventing SHA512 checksum mismatches that caused updates to fail silently.
## [0.0.18] - 2026-05-05
### Fixed
- **Downloaded Models Not Recognized by llama.cpp.** Models downloaded from Hugging Face are now stored directly under the `models/` directory instead of a nested `models/huggingface/` subdirectory, so llama-server's model scanner discovers them without manual symlinks. Existing models in the old location are automatically migrated on startup (#177).
## [0.0.17] - 2026-05-03
### Added
- **Webview Context Menu.** Right-clicking inside the webview now shows a native context menu with Cut, Copy, Paste, Undo/Redo, spell-check suggestions, and "Open Link in Browser" — enabling system autofill and password manager integration on login pages (#161).
### Changed
- **Windows OpenSSL Compatibility.** The bundled Python's directory is now prepended to `PATH` on Windows so its own OpenSSL DLLs are loaded before any conflicting system-wide installations (Git for Windows, Anaconda, Strawberry Perl, etc.), preventing the `OPENSSL_Uplink: no OPENSSL_Applink` crash on startup (#167).
- **Links Open in Default Browser on Windows.** Added `allowpopups` to the webview so that `target="_blank"` link clicks correctly propagate to the main process handler and open in the default browser instead of being silently blocked (#165, #170).
- **Linux System Requirements.** Documentation now specifies glibc 2.28+ as a minimum requirement for Linux installations.
## [0.0.16] - 2026-05-02
### Fixed
- **Links Open in Default Browser.** Clicking links in chat responses now opens them in the user's default browser instead of navigating within the app or spawning a new Electron window (#165).
## [0.0.15] - 2026-04-28
### Added
- **ARM64 Support for Linux and Windows.** Native ARM64 builds are now produced for Linux (.deb, AppImage) and Windows (NSIS installer), enabling support for Raspberry Pi, NVIDIA DGX Spark, Snapdragon laptops, and other ARM64 devices (#140).
### Fixed
- **Grey/Blank Screen on Linux.** Disabled GPU compositing entirely on Linux to prevent shared memory allocation crashes that caused a grey or blank screen on systems with restricted `/dev/shm` or `/tmp` permissions.
- **Spotlight Dismiss Behavior.** Pressing Escape or the toggle shortcut to dismiss Spotlight no longer erroneously brings the main application window to the foreground (#158).
## [0.0.14] - 2026-04-28
### Fixed
- **Grey/Blank Webview on Linux.** Disabled GPU compositing on Linux to prevent silent compositor failures that produce a grey rectangle instead of rendered content on systems with problematic Intel/NVIDIA drivers or certain Wayland compositors (#119).
- **Renderer Crash Recovery.** The main window now automatically reloads when the renderer process dies unexpectedly, preventing a permanent blank/grey screen.
- **Webview Crash Diagnostics.** Added logging for guest webview renderer crashes to aid debugging connectivity and rendering issues.
- **macOS Notarization.** Resolved Apple notarization failure caused by an expired Developer Program agreement, restoring signed and notarized macOS builds.
## [0.0.13] - 2026-04-27
### Fixed
- **Copy Button on Linux (GNOME/Wayland/Flatpak).** Fixed the "Copy" button in the Open WebUI interface not actually writing to the system clipboard on Linux. The webview session was missing the `clipboard-sanitized-write` permission required by Electron for `navigator.clipboard.writeText()` to work.
## [0.0.12] - 2026-04-25
### Added
- **Toggleable Clipboard Auto-Paste for Spotlight.** Spotlight's automatic clipboard pasting is now optional and can be toggled in Settings, so the input bar starts empty when preferred.
- **Persistent Window Size and Position.** The app now remembers your window dimensions, position, and maximized state across restarts, with safe fallback when a saved display is disconnected.
### Fixed
- **Linux .deb Crash.** Fixed app failing to launch on Linux with `Failed to load native module: pty.node` by enabling native module rebuilds and unpacking node-pty from the asar archive during packaging.
- **Grey Screen on Connection Failure.** The webview now shows an error overlay with retry and open-in-browser options instead of a blank grey screen when a connection fails to load or crashes.
- **Global Shortcuts on Wayland/Flatpak.** Global shortcuts now work on Wayland desktops via `xdg-desktop-portal`, with clear user-facing notifications when a shortcut cannot be registered.
## [0.0.11] - 2026-04-24
### Fixed
- **macOS Launch Crash.** Fixed app failing to launch with "different Team IDs" error by adding the missing `disable-library-validation` entitlement to the build signing configuration.
- **Self-Signed SSL Connections.** The app now trusts all SSL certificates, allowing connections to Open WebUI instances behind self-signed or untrusted certificates without errors.
## [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
- **Spotlight Screenshot Capture.** Drag anywhere on the Spotlight overlay to select a region of your screen. Screenshots appear as inline thumbnails and are sent alongside your message.
- **Multiple Screenshots.** Attach several screenshots in a single Spotlight query. Each one can be individually removed before sending.
- **Click-to-Dismiss Spotlight.** Clicking the background outside the input bar dismisses Spotlight, in addition to pressing Escape.
- **Screen Recording Permission Prompt (macOS).** If screen capture permission hasn't been granted, a notification guides you to the correct System Settings page.
- **Screenshot Hint.** A "Drag anywhere to capture a screenshot" hint appears when Spotlight opens.
- **Offline Mode for llama.cpp.** Previously downloaded llama.cpp binaries are automatically detected on startup, so local models work without an internet connection.
- **Auto-Connect on Startup.** The app pre-connects to your default connection when launched, so Spotlight queries work immediately.
### Changed
- **Fullscreen Spotlight Overlay.** Spotlight now opens as a fullscreen transparent overlay on your active display rather than a small floating window, enabling screenshot capture and multi-display support.
- **Faster Remote Connections.** Switching to a remote server is now instant with no loading delay.
- **Smarter Loading Indicator.** The loading spinner only appears when the local server is actually starting, instead of showing on every connection switch.
- **Clearer Sidebar Selection.** Active connections are more visually distinct with bolder text and stronger highlights. Inactive connections are subtler for better contrast.
- **Safer llama.cpp Updates.** The app verifies internet connectivity before removing the current installation, preventing accidental data loss when updating offline.
### Fixed
- **Tray Menu Connections.** Clicking a connection from the system tray menu now correctly opens it in the app.
- **Dark Mode Context Menus.** Sidebar right-click menus no longer appear incorrectly highlighted in dark mode.
- **Local Server Always Accessible.** The local connection in the sidebar is no longer grayed out when the server isn't running. Clicking it will start the server.
- **Open Terminal Install Errors.** If automatic installation of Open Terminal fails, you now see a clear error message instead of a silent failure.
- **Network Timeout Handling.** Requests for llama.cpp releases now time out after 10 seconds instead of hanging indefinitely on slow networks.
## [0.0.5] - 2026-04-07
### Added
- **Two-Way Theme Sync.** Theme changes in Open WebUI are now mirrored to the desktop app and vice versa, so your light/dark preference stays consistent everywhere.
- **Seamless Spotlight Queries.** Spotlight prompts now appear directly in your already-open chat without triggering a full page reload.
### Fixed
- **Auto-Default Connection.** Selecting a connection now automatically saves it as your default for Spotlight and app startup.
- **Smooth Connection Switching.** Switching between already-open connections no longer causes unnecessary page reloads.
- **Connection Switch Race Condition.** Clicking a remote connection while the local server is still starting no longer gets overridden when the local server finishes loading.
## [0.0.3] - 2026-04-06
### Fixed
- **Spotlight Focus.** Spotlight now reliably appears after interacting with the main window. Previously could fail to show on macOS.
- **Spotlight Search Passthrough.** Searches submitted from Spotlight now correctly load in already-open connections instead of being silently ignored.
## [0.0.2] - 2026-04-06
### Added
- **Spotlight Input Bar.** Lightweight quick-chat bar (⇧⌘I) for submitting queries without opening the full app.
- **Spotlight Shortcut.** Dedicated configurable shortcut for Spotlight, independent from the global app shortcut.
- **Draggable Spotlight.** Spotlight bar can be dragged to any position on screen.
- **Persistent Spotlight Position.** Spotlight position is saved and restored across app restarts.
- **Spotlight Settings.** Shortcut recorder in Settings → General for the Spotlight shortcut.
### Fixed
- **System Theme Sync.** The app now responds to OS dark/light mode changes in real-time when set to "Auto". Previously only checked once at startup.
## [0.0.1] - 2026-03-20
### Added
- **Local Server Management** Install, start, stop, and restart Open WebUI directly from the desktop app
- **Connection Manager** Connect to multiple Open WebUI servers with sidebar quick-switch
- **Status Bar** Real-time status indicators for Open WebUI, Open Terminal, and llama.cpp services
- **Log Panel** — Live PTY-backed terminal log viewer for all services with copy, refresh, and resize
- **Open Terminal Integration** Built-in terminal server for AI-powered shell access
- **llama.cpp Integration** Local inference runtime with model management and Hugging Face downloads
- **Settings** General, Open WebUI, Terminal, Inference, Models, Connections, and About panels
- **Global Shortcut** Configurable system-wide hotkey to bring the app to the foreground
- **Auto-Update** Built-in software update checker with download and install
- **Tray Support** System tray icon with quick actions and optional background execution
- **Factory Reset** One-click removal of all installed components, data, and connections
- **Disk Space Check** Pre-install validation requiring at least 5 GB of free storage
- **Internationalization** — i18n support with English, Japanese, Chinese (Simplified & Traditional) translations
- **Changelog Viewer** — In-app changelog accessible from the About settings page
- **Singleton Enforcement** — Prevents multiple instances of managed services from running simultaneously
- **Cross-Platform** — macOS, Windows, and Linux support
- **Local Server Management.** Install, start, stop, and restart Open WebUI directly from the desktop app.
- **Connection Manager.** Connect to multiple Open WebUI servers with sidebar quick-switch.
- **Status Bar.** Real-time status indicators for Open WebUI, Open Terminal, and llama.cpp services.
- **Log Viewer.** Live terminal log viewer for all services with copy, refresh, and resize.
- **Open Terminal Integration.** Built-in terminal server for AI-powered shell access.
- **llama.cpp Integration.** Local inference engine with model management and Hugging Face downloads.
- **Settings.** General, Open WebUI, Terminal, Inference, Models, Connections, and About panels.
- **Global Shortcut.** Configurable system-wide hotkey to bring the app to the foreground.
- **Auto-Update.** Built-in update checker with one-click download and install.
- **Tray Support.** System tray icon with quick actions and optional background mode.
- **Factory Reset.** One-click removal of all installed components, data, and connections.
- **Disk Space Check.** Pre-install check requiring at least 5 GB of free storage.
- **Internationalization.** English, Japanese, Chinese (Simplified & Traditional) translations.
- **In-App Changelog.** Accessible from the About settings page.
- **Cross-Platform.** macOS, Windows, and Linux support.
+62 -36
View File
@@ -1,54 +1,80 @@
# Open WebUI Desktop 🌐
# Open WebUI Desktop
![App Demo](./demo.png)
[![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** is a cross-platform desktop application for [Open WebUI](https://github.com/open-webui/open-webui). It brings the _full-featured Open WebUI experience_ directly to your device, effectively transforming it into a powerful server—without the complexities of manual setup.
![Open WebUI Desktop](./demo.png)
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.
> [!WARNING]
> This project is currently in **alpha** and under active development. 🛠️ Expect frequent updates and potential changes as we refine the application.
> **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 📥
## Download
| 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-x64-setup.exe) |
| Windows ARM64 | [**Download .exe**](https://github.com/open-webui/desktop/releases/latest/download/open-webui-arm64-setup.exe) |
| Linux x64 (AppImage) | [**Download .AppImage**](https://github.com/open-webui/desktop/releases/latest/download/open-webui_x64.AppImage) |
| Linux x64 (Debian/Ubuntu) | [**Download .deb**](https://github.com/open-webui/desktop/releases/latest/download/open-webui_amd64.deb) |
| Linux x64 (Snap) | [**Download .snap**](https://github.com/open-webui/desktop/releases/latest/download/open-webui_amd64.snap) |
| Linux x64 (Flatpak) | [**Download .flatpak**](https://github.com/open-webui/desktop/releases/latest/download/open-webui.flatpak) |
| Linux ARM64 (AppImage) | [**Download .AppImage**](https://github.com/open-webui/desktop/releases/latest/download/open-webui_arm64.AppImage) |
| Linux ARM64 (Debian/Ubuntu) | [**Download .deb**](https://github.com/open-webui/desktop/releases/latest/download/open-webui_arm64.deb) |
Internet required on first launch. After that, everything works offline. [All releases →](https://github.com/open-webui/desktop/releases)
## How It Works
🖥️ **Run locally.** The app runs Open WebUI on your machine. You can optionally enable the built-in llama.cpp engine to download and run models offline. Nothing leaves your computer.
☁️ **Connect remotely.** Point the app at any Open WebUI server. Switch between multiple connections from the sidebar.
Use both at the same time.
## Highlights
-**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.** Optionally run models entirely on your hardware via the built-in llama.cpp engine. Your data never leaves your machine.
- 🎯 **One-click setup.** Launch and connect to a server in seconds. Local models can be enabled from the settings.
- 🔌 **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
| | Local Models | Remote Only |
|--|-------------|-------------|
| **Disk** | 5 GB+ | ~500 MB |
| **RAM** | 16 GB+ | 4 GB |
| **OS** | macOS 12+, Windows 10+, modern Linux (glibc 2.28+) | Same |
> [!NOTE]
> An internet connection is required for initial setup, but afterwards the application can be used completely offline.
> Local models need serious RAM (7B ≈ 8 GB, 13B ≈ 16 GB). Lighter machine? Connect to a remote server instead.
Get the latest alpha release from our [releases page](https://github.com/open-webui/desktop/releases).
## Privacy
## Features
No telemetry. No tracking. No phone-home. Your conversations stay on your machine. Period.
- **One-Click Installation**: Quickly and effortlessly install and set up Open WebUI with all its dependencies. This feature is fully functional and ready to make your setup a breeze.
- **Cross-Platform Support**: Compatible with Windows, macOS, and Linux to ensure broad accessibility.
- **Offline Capability**: After initial setup, use the application completely offline for enhanced privacy and reliability.
## Community
## Project Setup
- 💬 [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
### Install
## Contributing
```bash
npm install
```
### Development
```bash
npm run dev
```
### Build
```bash
# For windows
npm run build:win
# For macOS
npm run build:mac
# For Linux
npm run build:linux
```
## License 📜
This project is licensed under the **AGPL-3.0**. For details, see [LICENSE](LICENSE).
## Stay Tuned! 🌟
We're actively developing Open WebUI. Follow [Open WebUI](https://github.com/open-webui/open-webui) for updates, and join the [community on Discord](https://discord.gg/5rJgQTnV4s) to stay involved.
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>
+28
View File
@@ -8,5 +8,33 @@
<true/>
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<key>com.apple.security.cs.debugger</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.network.server</key>
<true/>
<key>com.apple.security.files.user-selected.read-only</key>
<true/>
<key>com.apple.security.inherit</key>
<true/>
<key>com.apple.security.automation.apple-events</key>
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.security.device.bluetooth</key>
<true/>
<key>com.apple.security.device.camera</key>
<true/>
<key>com.apple.security.device.print</key>
<true/>
<key>com.apple.security.device.microphone</key>
<true/>
<key>com.apple.security.device.usb</key>
<true/>
<key>com.apple.security.personal-information.location</key>
<true/>
</dict>
</plist>
+18 -8
View File
@@ -15,27 +15,32 @@ extraResources:
to: CHANGELOG.md
asarUnpack:
- resources/**
- node_modules/node-pty/**
win:
executableName: open-webui
nsis:
artifactName: ${name}-${version}-setup.${ext}
artifactName: ${name}-${arch}-setup.${ext}
shortcutName: ${productName}
uninstallDisplayName: ${productName}
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:
- NSCameraUsageDescription: Application requests access to the device's camera.
- NSMicrophoneUsageDescription: Application requests access to the device's microphone.
- NSDocumentsFolderUsageDescription: Application requests access to the user's Documents folder.
- NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder.
notarize: true
dmg:
background: build/dmg-background.png
artifactName: ${name}-${version}.${ext}
artifactName: ${name}-${arch}.${ext}
title: ${productName}
contents:
- x: 225
@@ -53,15 +58,19 @@ linux:
- flatpak
maintainer: openwebui.com
category: Utility
deb:
artifactName: ${name}_${arch}.${ext}
snap:
artifactName: ${name}_${arch}.${ext}
appImage:
artifactName: ${name}-${version}.${ext}
artifactName: ${name}_${arch}.${ext}
flatpak:
base: org.electronjs.Electron2.BaseApp
baseVersion: '23.08'
runtime: org.freedesktop.Platform
runtimeVersion: '23.08'
sdk: org.freedesktop.Sdk
artifactName: ${name}-${version}.flatpak
artifactName: ${name}.flatpak
finishArgs:
- --share=ipc
- --socket=x11
@@ -71,7 +80,8 @@ flatpak:
- --device=dri
- --filesystem=home
- --talk-name=org.freedesktop.Notifications
npmRebuild: false
- --talk-name=org.freedesktop.portal.Desktop
npmRebuild: true
publish:
provider: github
owner: open-webui
+12 -1
View File
@@ -14,12 +14,23 @@ export default defineConfig({
rollupOptions: {
input: {
index: resolve(__dirname, 'src/preload/index.ts'),
'content-preload': resolve(__dirname, 'src/preload/content-preload.ts')
'content-preload': resolve(__dirname, 'src/preload/content-preload.ts'),
'spotlight-preload': resolve(__dirname, 'src/preload/spotlight-preload.ts'),
'voice-input-preload': resolve(__dirname, 'src/preload/voice-input-preload.ts')
}
}
}
},
renderer: {
build: {
rollupOptions: {
input: {
index: resolve(__dirname, 'src/renderer/index.html'),
spotlight: resolve(__dirname, 'src/renderer/spotlight.html'),
'voice-input': resolve(__dirname, 'src/renderer/voice-input.html')
}
}
},
plugins: [tailwindcss(), svelte()]
}
})
+4 -4
View File
@@ -1,12 +1,12 @@
{
"name": "open-webui-desktop",
"version": "0.1.0",
"name": "open-webui",
"version": "0.0.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "open-webui-desktop",
"version": "0.1.0",
"name": "open-webui",
"version": "0.0.3",
"hasInstallScript": true,
"license": "AGPL-3.0",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "open-webui",
"version": "0.0.1",
"version": "0.0.20",
"license": "AGPL-3.0",
"description": "Open WebUI Desktop",
"main": "./out/main/index.js",
Binary file not shown.
Binary file not shown.
+1143 -58
View File
File diff suppressed because it is too large Load Diff
+59 -14
View File
@@ -5,14 +5,14 @@
* Downloads files from HF repos, manages a local model cache,
* and provides listing/deletion of cached models.
*
* Cache dir: <userData>/models/huggingface/<repo-slug>/<filename>
* Cache dir: <userData>/models/<repo-slug>/<filename>
*/
import * as fs from 'fs'
import * as path from 'path'
import log from 'electron-log'
import { getUserDataPath, downloadFileWithProgress } from './index'
import { getInstallDir, downloadFileWithProgress } from './index'
// ─── Types ──────────────────────────────────────────────
@@ -33,10 +33,37 @@ export interface HfDownloadProgress {
// ─── Paths ──────────────────────────────────────────────
const getHfCacheDir = (): string => {
const dir = path.join(getUserDataPath(), 'models', 'huggingface')
const dir = path.join(getInstallDir(), 'models')
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true })
}
// Migrate models from legacy models/huggingface/<slug>/ to models/<slug>/
const legacyDir = path.join(dir, 'huggingface')
if (fs.existsSync(legacyDir)) {
try {
const entries = fs.readdirSync(legacyDir, { withFileTypes: true })
for (const entry of entries) {
if (entry.isDirectory()) {
const src = path.join(legacyDir, entry.name)
const dest = path.join(dir, entry.name)
if (!fs.existsSync(dest)) {
fs.renameSync(src, dest)
log.info(`[huggingface] Migrated ${entry.name} from legacy cache`)
}
}
}
// Remove legacy dir if empty (manifest.json may remain)
const remaining = fs.readdirSync(legacyDir)
if (remaining.length === 0) {
fs.rmdirSync(legacyDir)
log.info('[huggingface] Removed empty legacy huggingface/ directory')
}
} catch (e) {
log.warn('[huggingface] Failed to migrate legacy cache:', e)
}
}
return dir
}
@@ -62,15 +89,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()
}
}
@@ -87,7 +127,7 @@ export const listModels = (): HfModel[] => {
* Get the cache directory path (so runtimes can reference it).
*/
export const getModelsDir = (): string => {
const dir = path.join(getUserDataPath(), 'models')
const dir = path.join(getInstallDir(), 'models')
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true })
}
@@ -136,8 +176,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 +228,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 +237,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()
+105 -47
View File
@@ -8,7 +8,7 @@ import crypto from 'crypto'
import * as tar from 'tar'
import { app, shell, Notification } from 'electron'
import { app, shell, Notification, net as electronNet } from 'electron'
import { execFileSync, exec, spawn, execSync, execFile } from 'child_process'
import log from 'electron-log'
@@ -51,6 +51,31 @@ export const getUserDataPath = (): string => {
return path.normalize(userDataDir)
}
/**
* Root directory for heavyweight data (Python, models, llama.cpp).
* Reads `installDir` from config.json synchronously so it's available
* before any async init. Falls back to `getUserDataPath()`.
*/
export const getInstallDir = (): string => {
const configPath = path.join(getUserDataPath(), 'config.json')
let customDir = ''
try {
if (fs.existsSync(configPath)) {
const data = JSON.parse(fs.readFileSync(configPath, 'utf8'))
customDir = data.installDir || ''
}
} catch {}
const installDir = customDir || getUserDataPath()
if (!fs.existsSync(installDir)) {
try {
fs.mkdirSync(installDir, { recursive: true })
} catch (error) {
log.error(error)
}
}
return path.normalize(installDir)
}
export const getOpenWebUIDataPath = (): string => {
// Check config for custom data directory
const configPath = path.join(getUserDataPath(), 'config.json')
@@ -61,7 +86,7 @@ export const getOpenWebUIDataPath = (): string => {
customDir = data.dataDir || ''
}
} catch {}
const openWebUIDataDir = customDir || path.join(getUserDataPath(), 'data')
const openWebUIDataDir = customDir || path.join(getInstallDir(), 'data')
if (!fs.existsSync(openWebUIDataDir)) {
try {
fs.mkdirSync(openWebUIDataDir, { recursive: true })
@@ -194,15 +219,15 @@ export const getPythonDownloadPath = (): string => {
}
export const getPythonInstallationDir = (): string => {
const installDir = path.join(app.getPath('userData'), 'python')
if (!fs.existsSync(installDir)) {
const pythonDir = path.join(getInstallDir(), 'python')
if (!fs.existsSync(pythonDir)) {
try {
fs.mkdirSync(installDir, { recursive: true })
fs.mkdirSync(pythonDir, { recursive: true })
} catch (error) {
log.error(error)
}
}
return path.normalize(installDir)
return path.normalize(pythonDir)
}
const downloadPython = async (onProgress = null) => {
@@ -266,8 +291,8 @@ export const installPython = async (installationDir?: string, onStatus?: (status
try {
onStatus?.('Extracting Python…')
const userDataPath = getUserDataPath()
await tar.x({ cwd: userDataPath, file: pythonDownloadPath })
const installBase = getInstallDir()
await tar.x({ cwd: installBase, file: pythonDownloadPath })
} catch (error) {
log.error(error)
// Remove possibly-corrupted download so next retry re-downloads
@@ -293,10 +318,7 @@ export const installPython = async (installationDir?: string, onStatus?: (status
['-m', 'pip', 'install', 'uv'],
{
encoding: 'utf-8',
env: {
...process.env,
...(process.platform === 'win32' ? { PYTHONIOENCODING: 'utf-8' } : {})
}
env: pythonEnv()
},
(error) => {
if (error) reject(error)
@@ -325,6 +347,38 @@ export const getPythonPath = (installationDir?: string) => {
return path.normalize(getPythonExecutablePath(installationDir || getPythonInstallationDir()))
}
/**
* Build a process environment suitable for running the bundled Python.
*
* On Windows the standalone Python distribution ships its own OpenSSL DLLs
* (`libssl-3-x64.dll`, `libcrypto-3-x64.dll`) next to `python.exe`. If a
* different OpenSSL installation (Git for Windows, Anaconda, Strawberry Perl,
* etc.) appears earlier on the system `PATH`, Python picks up those mismatched
* DLLs at load-time, which causes the fatal error:
*
* OPENSSL_Uplink(..., 08): no OPENSSL_Applink
*
* To prevent this we prepend the Python installation directory to `PATH` so
* Windows finds the correct DLLs first. On non-Windows platforms this is a
* harmless no-op.
*
* Any additional env overrides (e.g. `configEnvVars`) can be spread after
* calling this helper.
*/
const pythonEnv = (extra: Record<string, string> = {}): Record<string, string> => {
const base: Record<string, string> = { ...process.env }
if (process.platform === 'win32') {
// python.exe lives at the root of the installation directory on Windows
const pythonDir = getPythonInstallationDir()
const currentPath = process.env['PATH'] || process.env['Path'] || ''
base['PATH'] = `${pythonDir};${currentPath}`
base['PYTHONIOENCODING'] = 'utf-8'
}
return { ...base, ...extra }
}
export const isPythonInstalled = (installationDir?: string) => {
const pythonPath = getPythonPath(installationDir)
if (!fs.existsSync(pythonPath)) {
@@ -333,10 +387,7 @@ export const isPythonInstalled = (installationDir?: string) => {
try {
const pythonVersion = execFileSync(pythonPath, ['--version'], {
encoding: 'utf-8',
env: {
...process.env,
...(process.platform === 'win32' ? { PYTHONIOENCODING: 'utf-8' } : {})
}
env: pythonEnv()
})
log.info('Installed Python Version:', pythonVersion.trim())
return true
@@ -350,10 +401,7 @@ export const isUvInstalled = (installationDir?: string) => {
try {
const result = execFileSync(pythonPath, ['-m', 'uv', '--version'], {
encoding: 'utf-8',
env: {
...process.env,
...(process.platform === 'win32' ? { PYTHONIOENCODING: 'utf-8' } : {})
}
env: pythonEnv()
})
log.info('Installed uv Version:', result.trim())
return true
@@ -403,10 +451,7 @@ export const installPackage = (packageName: string, version?: string, onStatus?:
...(version ? [`${packageName}==${version}`] : [packageName, '-U'])
],
{
env: {
...process.env,
...(process.platform === 'win32' ? { PYTHONIOENCODING: 'utf-8' } : {})
}
env: pythonEnv()
}
)
@@ -461,10 +506,7 @@ export const isPackageInstalled = (packageName: string): boolean => {
try {
const info = execFileSync(pythonPath, ['-m', 'uv', 'pip', 'show', packageName], {
encoding: 'utf-8',
env: {
...process.env,
...(process.platform === 'win32' ? { PYTHONIOENCODING: 'utf-8' } : {})
}
env: pythonEnv()
})
return info.includes(`Name: ${packageName}`)
} catch {
@@ -478,10 +520,7 @@ export const getPackageVersion = (packageName: string): string | null => {
try {
const info = execFileSync(pythonPath, ['-m', 'uv', 'pip', 'show', packageName], {
encoding: 'utf-8',
env: {
...process.env,
...(process.platform === 'win32' ? { PYTHONIOENCODING: 'utf-8' } : {})
}
env: pythonEnv()
})
const match = info.match(/^Version:\s*(.+)$/m)
return match ? match[1].trim() : null
@@ -496,10 +535,7 @@ export const uninstallPackage = (packageName: string): boolean => {
try {
execFileSync(pythonPath, ['-m', 'uv', 'pip', 'uninstall', packageName], {
encoding: 'utf-8',
env: {
...process.env,
...(process.platform === 'win32' ? { PYTHONIOENCODING: 'utf-8' } : {})
}
env: pythonEnv()
})
log.info(`Uninstalled package: ${packageName}`)
return true
@@ -563,14 +599,12 @@ export const startServer = async (
name: 'xterm-256color',
cols: 200,
rows: 50,
env: {
...process.env,
env: pythonEnv({
...(configEnvVars ?? {}),
DATA_DIR: dataDir,
WEBUI_SECRET_KEY: secretKey,
PYTHONUNBUFFERED: '1',
...(process.platform === 'win32' ? { PYTHONIOENCODING: 'utf-8' } : {})
}
PYTHONUNBUFFERED: '1'
})
})
} catch (error) {
throw new Error(
@@ -730,7 +764,7 @@ export const checkUrlAndOpen = async (url: string, callback: Function = async ()
const checkUrl = async (): Promise<boolean> => {
try {
const response = await fetch(url, { method: 'HEAD' })
const response = await electronNet.fetch(url, { method: 'HEAD' })
return response.ok
} catch {
return false
@@ -758,7 +792,7 @@ export const checkUrlAndOpen = async (url: string, callback: Function = async ()
export const validateRemoteUrl = async (url: string): Promise<boolean> => {
try {
const response = await fetch(url, { method: 'HEAD', signal: AbortSignal.timeout(5000) })
const response = await electronNet.fetch(url, { method: 'HEAD', signal: AbortSignal.timeout(5000) })
return response.ok
} catch {
return false
@@ -780,6 +814,8 @@ export interface AppConfig {
connections: Connection[]
runInBackground: boolean
globalShortcut: string
spotlightShortcut: string
installDir: string
dataDir: string
localServer: {
port: number
@@ -790,6 +826,7 @@ export interface AppConfig {
enabled: boolean
port: number
cwd: string
apiKey: string
}
llamaCpp: {
enabled: boolean
@@ -799,6 +836,15 @@ export interface AppConfig {
extraArgs: string[]
}
envVars: Record<string, string>
showSidebar: boolean
spotlightPosition: { x: number; y: number } | null
spotlightClipboardPaste: boolean
voiceInputShortcut: string
voiceInputEnabled: boolean
callShortcut: string
callEnabled: boolean
windowBounds: { x: number; y: number; width: number; height: number } | null
windowMaximized: boolean
}
const DEFAULT_CONFIG: AppConfig = {
@@ -807,6 +853,8 @@ const DEFAULT_CONFIG: AppConfig = {
connections: [],
runInBackground: true,
globalShortcut: 'Alt+CommandOrControl+O',
spotlightShortcut: 'Shift+CommandOrControl+I',
installDir: '',
dataDir: '',
localServer: {
port: 8080,
@@ -815,7 +863,8 @@ const DEFAULT_CONFIG: AppConfig = {
},
openTerminal: {
enabled: false,
cwd: ''
cwd: '',
apiKey: ''
},
llamaCpp: {
enabled: false,
@@ -823,7 +872,16 @@ const DEFAULT_CONFIG: AppConfig = {
variant: 'cpu',
extraArgs: []
},
envVars: {}
envVars: {},
showSidebar: false,
spotlightPosition: null,
spotlightClipboardPaste: true,
voiceInputShortcut: 'Shift+CommandOrControl+Space',
voiceInputEnabled: true,
callShortcut: 'Shift+CommandOrControl+C',
callEnabled: true,
windowBounds: null,
windowMaximized: false
}
export const getConfig = async (): Promise<AppConfig> => {
@@ -900,7 +958,7 @@ export const resetApp = async (): Promise<void> => {
}
// Remove llama.cpp binaries
const llamaCppPath = path.join(getUserDataPath(), 'llama.cpp')
const llamaCppPath = path.join(getInstallDir(), 'llama.cpp')
if (fs.existsSync(llamaCppPath)) {
try {
fs.rmSync(llamaCppPath, { recursive: true, force: true })
@@ -911,7 +969,7 @@ export const resetApp = async (): Promise<void> => {
}
// Remove downloaded models (huggingface + any user-added models)
const modelsPath = path.join(getUserDataPath(), 'models')
const modelsPath = path.join(getInstallDir(), 'models')
if (fs.existsSync(modelsPath)) {
try {
fs.rmSync(modelsPath, { recursive: true, force: true })
+103 -12
View File
@@ -10,7 +10,8 @@ import log from 'electron-log'
import {
getConfig,
getUserDataPath,
setConfig,
getInstallDir,
portInUse,
downloadFileWithProgress
} from './index'
@@ -32,11 +33,32 @@ let binaryPath: string | null = null
// ─── Public Getters ─────────────────────────────────────
export const getLlamaCppInfo = () => {
// Lazily discover a cached binary on cold boot so the UI never falsely
// reports "not installed" when the files are actually on disk.
if (!binaryPath) {
const cacheBase = path.join(getInstallDir(), 'llama.cpp')
try {
if (fs.existsSync(cacheBase)) {
const dirs = fs.readdirSync(cacheBase, { withFileTypes: true })
.filter((d) => d.isDirectory())
for (const d of dirs) {
const found = findBinary(path.join(cacheBase, d.name))
if (found) {
binaryPath = found
break
}
}
}
} catch {
// Ignore — best-effort discovery
}
}
// Extract version tag from binaryPath — the tag is the directory name
// directly under the llama.cpp cache dir, e.g. …/llama.cpp/<tag>/bin/llama-server
let version: string | null = null
if (binaryPath) {
const cacheBase = path.join(getUserDataPath(), 'llama.cpp')
const cacheBase = path.join(getInstallDir(), 'llama.cpp')
const relative = path.relative(cacheBase, binaryPath)
const tag = relative.split(path.sep)[0]
if (tag) version = tag
@@ -193,11 +215,45 @@ export const setupLlamaCpp = async (
const version = llamaConfig.version || 'latest'
const variant = resolveVariant(llamaConfig.variant)
const cacheBase = path.join(getUserDataPath(), 'llama.cpp')
const cacheBase = path.join(getInstallDir(), 'llama.cpp')
if (!fs.existsSync(cacheBase)) {
fs.mkdirSync(cacheBase, { recursive: true })
}
// ── Check for existing cached binary before any network request ──
// This allows llama.cpp to start offline when previously installed.
if (version !== 'latest') {
// Pinned version — check its specific directory
const pinnedDir = path.join(cacheBase, version)
const pinnedBinary = fs.existsSync(pinnedDir) ? findBinary(pinnedDir) : null
if (pinnedBinary) {
log.info(`Using cached llama-server binary (pinned ${version}): ${pinnedBinary}`)
binaryPath = pinnedBinary
onStatus?.('Ready')
return pinnedBinary
}
} else {
// 'latest' — scan all cached version directories for a usable binary
try {
const cachedVersions = fs.readdirSync(cacheBase, { withFileTypes: true })
.filter((d) => d.isDirectory())
.map((d) => d.name)
for (const cachedTag of cachedVersions) {
const cachedBinary = findBinary(path.join(cacheBase, cachedTag))
if (cachedBinary) {
log.info(`Found cached llama-server binary (${cachedTag}): ${cachedBinary}`)
// Still try to fetch release info to see if there's a newer version,
// but if the network is unavailable, use the cached binary.
binaryPath = cachedBinary
break
}
}
} catch {
// Cache directory scan failed — proceed to network fetch
}
}
onStatus?.('Fetching release info…')
const apiUrl =
version === 'latest'
@@ -207,14 +263,25 @@ export const setupLlamaCpp = async (
let releaseData: any
try {
const response = await fetch(apiUrl, {
headers: { Accept: 'application/vnd.github.v3+json' }
headers: { Accept: 'application/vnd.github.v3+json' },
signal: AbortSignal.timeout(10000)
})
if (!response.ok) {
throw new Error(`GitHub API returned ${response.status}: ${response.statusText}`)
}
releaseData = await response.json()
} catch (error) {
throw new Error(`Failed to fetch release info: ${error?.message ?? error}`)
// Network unavailable — fall back to cached binary if we found one
if (binaryPath) {
log.info('Network unavailable, using cached llama-server binary:', binaryPath)
onStatus?.('Ready (offline)')
return binaryPath
}
throw new Error(
`Failed to fetch release info (no internet?) and no cached llama.cpp binary found. ` +
`Please connect to the internet for the initial llama.cpp installation. ` +
`Original error: ${error?.message ?? error}`
)
}
const tag = releaseData.tag_name
@@ -334,13 +401,37 @@ export const checkLlamaCppUpdate = async (): Promise<{ currentVersion: string |
export const updateLlamaCpp = async (
onStatus?: (status: string) => void
): Promise<{ url?: string; status?: string; pid?: number; binaryPath?: string; version?: string | null }> => {
// 1. Stop if running
// 1. Verify network is available BEFORE destructive operations —
// don't delete the old binary if we can't download a replacement.
onStatus?.('Checking for updates…')
let releaseTag: string
try {
const response = await fetch(
'https://api.github.com/repos/ggml-org/llama.cpp/releases/latest',
{
headers: { Accept: 'application/vnd.github.v3+json' },
signal: AbortSignal.timeout(10000)
}
)
if (!response.ok) {
throw new Error(`GitHub API returned ${response.status}: ${response.statusText}`)
}
const data = await response.json()
releaseTag = data.tag_name
} catch (error) {
throw new Error(
`Cannot update llama.cpp: unable to reach GitHub. ` +
`Please check your internet connection. (${error?.message ?? error})`
)
}
// 2. Stop if running
await stopLlamaCpp()
// 2. Clear old cache directory
// 3. Clear old cache directory (safe — we verified network above)
const currentInfo = getLlamaCppInfo()
if (currentInfo.version) {
const cacheDir = path.join(getUserDataPath(), 'llama.cpp', currentInfo.version)
const cacheDir = path.join(getInstallDir(), 'llama.cpp', currentInfo.version)
if (fs.existsSync(cacheDir)) {
onStatus?.('Removing old version…')
try {
@@ -351,11 +442,11 @@ export const updateLlamaCpp = async (
}
}
// 3. Temporarily enforce 'latest' in config so it fetches the newest
// 4. Temporarily enforce 'latest' in config so it fetches the newest
const config = await getConfig()
await setConfig({ llamaCpp: { ...config.llamaCpp, version: 'latest' } }) // Assuming setConfig is available, if not we'll modify it
await setConfig({ llamaCpp: { ...config.llamaCpp, version: 'latest' } })
// 4. Download new release
// 5. Download new release
onStatus?.('Downloading update…')
await setupLlamaCpp(onStatus)
@@ -512,7 +603,7 @@ export const validateLlamaCppProcess = (): boolean => {
export const uninstallLlamaCpp = async (): Promise<void> => {
await stopLlamaCpp()
const cacheBase = path.join(getUserDataPath(), 'llama.cpp')
const cacheBase = path.join(getInstallDir(), 'llama.cpp')
if (fs.existsSync(cacheBase)) {
fs.rmSync(cacheBase, { recursive: true, force: true })
log.info('Removed llama.cpp directory:', cacheBase)
+40 -7
View File
@@ -6,9 +6,11 @@ import * as pty from 'node-pty'
import {
getPythonPath,
getConfig,
setConfig,
installPackage,
isPackageInstalled,
isPythonInstalled,
installPython,
portInUse
} from './index'
import { ServiceLock, isProcessAlive } from './service-lock'
@@ -37,7 +39,8 @@ export const getOpenTerminalPty = (): pty.IPty | null => ptyProcess
export const getOpenTerminalLog = (): string[] => logBuffer
export const startOpenTerminal = async (
port: number | null = null
port: number | null = null,
onStatus?: (status: string) => void
): Promise<{ url: string; apiKey: string; pid: number }> => {
if (!lock.acquire()) {
return { url, apiKey, pid }
@@ -45,11 +48,35 @@ export const startOpenTerminal = async (
await stopOpenTerminal()
if (!isPythonInstalled()) throw new Error('Python is not installed')
if (!isPythonInstalled()) {
log.info('Python not installed — installing automatically for Open Terminal…')
onStatus?.('Installing Python…')
try {
const ok = await installPython(undefined, onStatus)
if (!ok) throw new Error('Python installation returned false')
} catch (err) {
throw new Error(
`Python is required for Open Terminal but installation failed: ${err?.message ?? err}`
)
}
if (!isPythonInstalled()) {
throw new Error(
'Python was installed but could not be verified. Please restart the app and try again.'
)
}
}
if (!isPackageInstalled('open-terminal')) {
log.info('open-terminal not installed, installing...')
const ok = await installPackage('open-terminal')
if (!ok) throw new Error('Failed to install open-terminal')
log.info('open-terminal not installed, attempting install...')
onStatus?.('Installing Open Terminal package…')
try {
await installPackage('open-terminal')
} catch (err) {
throw new Error(
`Open Terminal is not installed and auto-install failed. ` +
`Please connect to the internet and try again. (${err?.message ?? err})`
)
}
}
const pythonPath = getPythonPath()
@@ -57,8 +84,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
+8
View File
@@ -15,6 +15,14 @@ ipcRenderer.on('desktop:event', (_event, data) => {
eventCallbacks.forEach((cb) => cb(data))
})
// ─── Theme Sync: Open WebUI → Desktop ───────────────────
// Open WebUI calls window.applyTheme() after every theme change.
// We inject this hook so the desktop shell can mirror the theme.
contextBridge.exposeInMainWorld('applyTheme', () => {
const theme = localStorage.getItem('theme') ?? 'system'
ipcRenderer.sendToHost('webview:event', { type: 'theme:update', data: { theme } })
})
// Expose to the Open WebUI page via contextBridge (secure, unforgeable)
contextBridge.exposeInMainWorld('electronAPI', {
// Push events: desktop → Open WebUI
+15 -41
View File
@@ -1,20 +1,8 @@
import { ipcRenderer, contextBridge } from 'electron'
import { electronAPI } from '@electron-toolkit/preload'
window.addEventListener('DOMContentLoaded', () => {
ipcRenderer.on('main:data', (_event, data) => {
window.postMessage(
{
...data,
type: `electron:${data.type}`
},
window.location.origin
)
})
})
// ─── PTY MessagePort ────────────────────────────────────
// The MessagePort stays in the preload (cannot cross contextBridge).
// MessagePorts stay in the preload (cannot cross contextBridge).
// We expose simple functions so the renderer never touches the port.
let activePtyPort: MessagePort | null = null
let ptyOutputCallback: ((data: string) => void) | null = null
@@ -22,17 +10,10 @@ let ptyOutputCallback: ((data: string) => void) | null = null
ipcRenderer.on('pty:port', (event, _data) => {
const [port] = event.ports
if (!port) return
// Clean up previous port
if (activePtyPort) {
activePtyPort.close()
}
if (activePtyPort) activePtyPort.close()
activePtyPort = port
port.onmessage = (ev: MessageEvent) => {
if (ev.data?.type === 'output' && ptyOutputCallback) {
ptyOutputCallback(ev.data.data)
}
if (ev.data?.type === 'output' && ptyOutputCallback) ptyOutputCallback(ev.data.data)
}
port.start()
})
@@ -44,37 +25,25 @@ let otPtyOutputCallback: ((data: string) => void) | null = null
ipcRenderer.on('open-terminal:pty:port', (event, _data) => {
const [port] = event.ports
if (!port) return
if (activeOtPtyPort) {
activeOtPtyPort.close()
}
if (activeOtPtyPort) activeOtPtyPort.close()
activeOtPtyPort = port
port.onmessage = (ev: MessageEvent) => {
if (ev.data?.type === 'output' && otPtyOutputCallback) {
otPtyOutputCallback(ev.data.data)
}
if (ev.data?.type === 'output' && otPtyOutputCallback) otPtyOutputCallback(ev.data.data)
}
port.start()
})
// ─── llama.cpp PTY MessagePort ───────────────────────
// ─── llama.cpp PTY MessagePort ──────────────────────────
let activeLsCppPtyPort: MessagePort | null = null
let lsCppPtyOutputCallback: ((data: string) => void) | null = null
ipcRenderer.on('llamacpp:pty:port', (event, _data) => {
const [port] = event.ports
if (!port) return
if (activeLsCppPtyPort) {
activeLsCppPtyPort.close()
}
if (activeLsCppPtyPort) activeLsCppPtyPort.close()
activeLsCppPtyPort = port
port.onmessage = (ev: MessageEvent) => {
if (ev.data?.type === 'output' && lsCppPtyOutputCallback) {
lsCppPtyOutputCallback(ev.data.data)
}
if (ev.data?.type === 'output' && lsCppPtyOutputCallback) lsCppPtyOutputCallback(ev.data.data)
}
port.start()
})
@@ -91,6 +60,7 @@ const api = {
getVersion: () => ipcRenderer.invoke('get:version'),
resetApp: () => ipcRenderer.invoke('app:reset'),
getDefaultDataPath: () => ipcRenderer.invoke('app:defaultDataPath'),
getInstallDir: () => ipcRenderer.invoke('app:installDir'),
getContentPreloadPath: () => ipcRenderer.invoke('app:contentPreloadPath'),
getDiskSpace: () => ipcRenderer.invoke('system:diskSpace'),
getLaunchAtLogin: () => ipcRenderer.invoke('app:launchAtLogin:get'),
@@ -185,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) =>
@@ -211,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) {
+43
View File
@@ -0,0 +1,43 @@
import { ipcRenderer, contextBridge } from 'electron'
const api = {
submitQuery: (query: string, images?: string[]): void => {
ipcRenderer.invoke('spotlight:submit', query, images)
},
closeSpotlight: (): void => {
ipcRenderer.invoke('spotlight:close')
},
captureRegion: (rect: {
x: number
y: number
width: number
height: number
}): Promise<string | null> => {
return ipcRenderer.invoke('spotlight:captureRegion', rect)
},
savePosition: (offset: { x: number; y: number }): void => {
ipcRenderer.invoke('spotlight:savePosition', offset)
},
onInit: (
callback: (data: {
barOffset: { x: number; y: number } | null
screenSize: { width: number; height: number }
query: string
}) => void
): void => {
ipcRenderer.on('spotlight:init', (_event, data) => {
callback(data)
})
}
}
if (process.contextIsolated) {
try {
contextBridge.exposeInMainWorld('spotlightAPI', api)
} catch (error) {
console.error(error)
}
} else {
// @ts-ignore
window.spotlightAPI = api
}
+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"
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Open WebUI Spotlight</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/spotlight-main.ts"></script>
</body>
</html>
+32 -26
View File
@@ -1,10 +1,22 @@
<script lang="ts">
import { onMount } from 'svelte'
import { onMount, onDestroy } from 'svelte'
import { fade } from 'svelte/transition'
import { appInfo, config, connections, serverInfo, appState } from './lib/stores'
import Main from './lib/components/Main.svelte'
let themeMediaQuery: MediaQueryList
let themeChangeHandler: ((e: MediaQueryListEvent) => void) | null = null
const applyResolvedTheme = (theme: string) => {
let resolved = theme
if (theme === 'system') {
resolved = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
document.documentElement.classList.remove('light', 'dark')
document.documentElement.classList.add(resolved)
}
onMount(async () => {
const api = window?.electronAPI
if (!api) return
@@ -15,11 +27,17 @@
// Apply saved theme
const savedTheme = (await api.getConfig())?.theme ?? 'system'
let resolved = savedTheme
if (savedTheme === 'system') {
resolved = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
applyResolvedTheme(savedTheme)
// Listen for OS theme changes so "system" mode reacts in real-time
themeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
themeChangeHandler = () => {
const currentTheme = $config?.theme ?? 'system'
if (currentTheme === 'system') {
applyResolvedTheme('system')
}
}
document.documentElement.classList.add(resolved)
themeMediaQuery.addEventListener('change', themeChangeHandler)
api.onData((data: any) => {
if (data.type === 'status:server') {
@@ -30,32 +48,20 @@
}
})
// Install python in the background — don't block UI
const pythonReady = await api.getPythonStatus()
if (!pythonReady) {
// Check disk space before installing
const MINIMUM_DISK_BYTES = 5 * 1024 * 1024 * 1024 // 5 GB
const disk = await api.getDiskSpace()
if (disk?.free >= 0 && disk.free < MINIMUM_DISK_BYTES) {
const availableGB = (disk.free / (1024 * 1024 * 1024)).toFixed(1)
appState.set(`insufficient-storage:${availableGB}`)
return
}
appState.set('initializing')
api.installPython().then(async () => {
appState.set('ready')
}).catch((e: any) => {
appState.set(`install-failed:${e?.message || 'Python installation failed. Please try again.'}`)
})
} else {
appState.set('ready')
}
// Don't auto-install anything — the user must explicitly choose
// "Get Started" (local install) which handles Python/uv as a prerequisite.
appState.set('ready')
setInterval(async () => {
serverInfo.set(await api.getServerInfo())
}, 3000)
})
onDestroy(() => {
if (themeMediaQuery && themeChangeHandler) {
themeMediaQuery.removeEventListener('change', themeChangeHandler)
}
})
</script>
<main class="w-full h-full bg-[#f5f5f7] dark:bg-[#0a0a0a]">
@@ -0,0 +1,526 @@
<script lang="ts">
import { onMount } from 'svelte'
import logoImage from '../lib/assets/images/splash.png'
let inputEl = $state<HTMLInputElement | null>(null)
let query = $state('')
let images = $state<string[]>([])
let errorMsg = $state('')
let showHint = $state(true)
// Bar position within the fullscreen window
let barX = $state(0)
let barY = $state(160)
let screenW = $state(1920)
let screenH = $state(1080)
const BAR_W = 748
const api = window.spotlightAPI
// ─── Error Toast ───
let errorTimer: ReturnType<typeof setTimeout> | null = null
const showError = (msg: string, duration = 4000) => {
errorMsg = msg
if (errorTimer) clearTimeout(errorTimer)
errorTimer = setTimeout(() => { errorMsg = '' }, duration)
}
// ─── Submit ───
const submit = () => {
const q = query.trim()
if (!q && images.length === 0) return
if (!api) return
// Svelte 5 $state creates Proxy objects that Electron IPC can't serialize.
// Spread into a plain array of plain strings for structured clone.
const plainImages = images.length > 0 ? [...images].map((s) => s.slice(0)) : undefined
api.submitQuery(q, plainImages)
query = ''
images = []
}
// ─── Bar Dragging ───
let barDragging = $state(false)
let barDragStart = { mx: 0, my: 0, bx: 0, by: 0 }
const onBarMouseDown = (e: MouseEvent) => {
// Only drag from the bar background, not from input or buttons
const target = e.target as HTMLElement
if (target.tagName === 'INPUT' || target.closest('button') || target.closest('.preview')) return
e.preventDefault()
barDragging = true
barDragStart = { mx: e.clientX, my: e.clientY, bx: barX, by: barY }
}
// ─── Region Selection ───
let selecting = $state(false)
let selStart = { x: 0, y: 0 }
let selRect = $state({ x: 0, y: 0, w: 0, h: 0 })
let didDrag = false
const DRAG_THRESHOLD = 8
const onWrapperMouseDown = (e: MouseEvent) => {
// Ignore if it's on the bar or preview
if ((e.target as HTMLElement).closest('.bar') || (e.target as HTMLElement).closest('.preview')) return
e.preventDefault()
selStart = { x: e.clientX, y: e.clientY }
selecting = true
didDrag = false
selRect = { x: e.clientX, y: e.clientY, w: 0, h: 0 }
}
const onMouseMove = (e: MouseEvent) => {
if (barDragging) {
const dx = e.clientX - barDragStart.mx
const dy = e.clientY - barDragStart.my
barX = Math.max(0, Math.min(screenW - BAR_W, barDragStart.bx + dx))
barY = Math.max(0, Math.min(screenH - 60, barDragStart.by + dy))
return
}
if (selecting) {
const dx = e.clientX - selStart.x
const dy = e.clientY - selStart.y
if (!didDrag && Math.abs(dx) + Math.abs(dy) > DRAG_THRESHOLD) {
didDrag = true
}
selRect = {
x: Math.min(e.clientX, selStart.x),
y: Math.min(e.clientY, selStart.y),
w: Math.abs(dx),
h: Math.abs(dy)
}
}
}
const onMouseUp = async (e: MouseEvent) => {
if (barDragging) {
barDragging = false
api?.savePosition({ x: barX, y: barY })
return
}
if (selecting) {
selecting = false
showHint = false
if (didDrag && selRect.w > 10 && selRect.h > 10) {
// Capture the selected region
const result = await api?.captureRegion({
x: selRect.x,
y: selRect.y,
width: selRect.w,
height: selRect.h
})
if (result === 'no-permission') {
showError('Screen Recording permission required. Opening System Settings…')
} else if (result && typeof result === 'string' && result.startsWith('data:')) {
images = [...images, result]
} else if (!result) {
showError('Screenshot capture failed.')
}
selRect = { x: 0, y: 0, w: 0, h: 0 }
} else {
// Click on background (not a drag) — dismiss
selRect = { x: 0, y: 0, w: 0, h: 0 }
api?.closeSpotlight()
}
}
}
const removeImage = (index: number) => {
images = images.filter((_, i) => i !== index)
}
onMount(() => {
inputEl?.focus()
window.addEventListener('focus', () => {
inputEl?.focus()
})
api?.onInit?.((data) => {
if (data.screenSize) {
screenW = data.screenSize.width
screenH = data.screenSize.height
}
if (data.barOffset) {
barX = data.barOffset.x
barY = data.barOffset.y
} else {
// Default: center horizontally, near top
barX = Math.round((screenW - BAR_W) / 2)
barY = 160
}
if (data.query) {
query = data.query
requestAnimationFrame(() => inputEl?.select())
}
requestAnimationFrame(() => inputEl?.focus())
// Show screenshot hint
showHint = true
})
})
</script>
<svelte:window
onkeydown={(e) => e.key === 'Escape' && api?.closeSpotlight()}
onmousemove={onMouseMove}
onmouseup={onMouseUp}
/>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="wrapper" onmousedown={onWrapperMouseDown}>
<!-- Screen capture overlay -->
{#if selecting && didDrag}
<!-- Selection with shadow-based dimming -->
<div
class="selection"
style="left:{selRect.x}px;top:{selRect.y}px;width:{selRect.w}px;height:{selRect.h}px"
>
<div class="handle tl"></div>
<div class="handle tr"></div>
<div class="handle bl"></div>
<div class="handle br"></div>
</div>
<!-- Dimensions label -->
<div
class="dimensions"
style="left:{selRect.x + selRect.w / 2}px;top:{selRect.y + selRect.h + 12}px"
>
{Math.round(selRect.w)} × {Math.round(selRect.h)}
</div>
{/if}
<!-- Floating bar -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="bar"
style="left:{barX}px;top:{barY}px"
onmousedown={onBarMouseDown}
>
{#if images.length > 0}
<div class="attachments">
{#each images as img, i}
<div class="preview-item">
<img src={img} alt="Screenshot {i + 1}" />
<button class="preview-remove" onclick={() => removeImage(i)} aria-label="Remove">×</button>
</div>
{/each}
</div>
{/if}
<div class="input-row">
<img class="logo" src={logoImage} alt="" />
<input
bind:this={inputEl}
bind:value={query}
type="text"
placeholder="What can I help you with today?"
autocomplete="off"
spellcheck="false"
onkeydown={(e) => {
if (e.key === 'Enter' && !e.isComposing) {
e.preventDefault()
submit()
}
}}
/>
<button class="send" class:active={query.trim().length > 0 || images.length > 0} aria-label="Send" onclick={submit}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 19V5" />
<path d="M5 12l7-7 7 7" />
</svg>
</button>
</div>
</div>
<!-- Screenshot hint -->
{#if showHint && images.length === 0 && !selecting}
<div class="hint">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 8V6a2 2 0 012-2h2" /><path d="M4 16v2a2 2 0 002 2h2" /><path d="M16 4h2a2 2 0 012 2v2" /><path d="M16 20h2a2 2 0 002-2v-2" />
</svg>
Drag anywhere to capture a screenshot
</div>
{/if}
<!-- Error toast -->
{#if errorMsg}
<div class="error-toast">{errorMsg}</div>
{/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;
}
.wrapper {
position: fixed;
inset: 0;
cursor: crosshair;
}
.selection {
position: absolute;
border: 2px solid rgba(255, 255, 255, 0.9);
border-radius: 2px;
pointer-events: none;
z-index: 10;
/* Massive spread shadow dims everything outside the selection */
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.45);
}
.handle {
position: absolute;
width: 6px;
height: 6px;
background: white;
border-radius: 1px;
box-shadow: 0 0 2px rgba(0, 0, 0, 0.4);
}
.handle.tl { top: -3px; left: -3px; }
.handle.tr { top: -3px; right: -3px; }
.handle.bl { bottom: -3px; left: -3px; }
.handle.br { bottom: -3px; right: -3px; }
.dimensions {
position: absolute;
transform: translateX(-50%);
padding: 3px 8px;
border-radius: 4px;
font-family: 'Archivo', -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
font-size: 11px;
font-weight: 500;
letter-spacing: 0.02em;
color: rgba(255, 255, 255, 0.9);
background: rgba(0, 0, 0, 0.65);
backdrop-filter: blur(4px);
pointer-events: none;
z-index: 11;
white-space: nowrap;
}
.bar {
position: absolute;
width: 748px;
display: flex;
flex-direction: column;
border-radius: 14px;
font-family: 'Archivo', -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
cursor: grab;
z-index: 100;
overflow: hidden;
background: #f5f5f7;
color: #1d1d1f;
border: 0.5px solid rgba(0, 0, 0, 0.08);
box-shadow:
0 2px 8px rgba(0, 0, 0, 0.06),
0 8px 24px rgba(0, 0, 0, 0.1);
}
@media (prefers-color-scheme: dark) {
.bar {
background: #1a1a1c;
color: #fafafa;
border-color: rgba(255, 255, 255, 0.08);
box-shadow:
0 2px 8px rgba(0, 0, 0, 0.2),
0 8px 24px rgba(0, 0, 0, 0.35);
}
}
.input-row {
display: flex;
align-items: center;
gap: 10px;
height: 52px;
padding: 0 9px 0 15px;
}
.logo {
width: 25px;
height: 25px;
flex-shrink: 0;
object-fit: contain;
opacity: 0.8;
}
@media (prefers-color-scheme: dark) {
.logo { filter: invert(1); }
}
input {
flex: 1;
min-width: 0;
border: none;
outline: none;
background: transparent;
font-family: inherit;
font-size: 17px;
font-weight: 450;
line-height: 1;
letter-spacing: -0.01em;
color: #1d1d1f;
cursor: text;
}
input::placeholder {
color: rgba(0, 0, 0, 0.22);
}
@media (prefers-color-scheme: dark) {
input { color: #fafafa; }
input::placeholder { color: rgba(255, 255, 255, 0.18); }
}
.send {
width: 30px;
height: 30px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border: none;
outline: none;
border-radius: 10px;
cursor: pointer;
transition: all 0.15s ease;
background: rgba(0, 0, 0, 0.05);
color: rgba(0, 0, 0, 0.18);
}
.send.active {
background: #1d1d1f;
color: #fff;
}
.send.active:hover { opacity: 0.8; }
.send.active:active { transform: scale(0.92); }
@media (prefers-color-scheme: dark) {
.send {
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.15);
}
.send.active {
background: #fafafa;
color: #1d1d1f;
}
}
/* ─── Inline Attachments ─── */
.attachments {
display: flex;
gap: 6px;
padding: 10px 12px 0;
flex-wrap: wrap;
}
.preview-item {
position: relative;
width: 56px;
height: 56px;
border-radius: 8px;
overflow: hidden;
flex-shrink: 0;
}
.preview-item img {
width: 100%;
height: 100%;
object-fit: cover;
}
.preview-remove {
position: absolute;
top: 2px;
right: 2px;
width: 18px;
height: 18px;
border-radius: 50%;
border: none;
background: rgba(0, 0, 0, 0.6);
color: white;
font-size: 12px;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
opacity: 0;
transition: opacity 0.15s;
}
.preview-item:hover .preview-remove {
opacity: 1;
}
/* ─── Error Toast ─── */
.error-toast {
position: fixed;
bottom: 80px;
left: 50%;
transform: translateX(-50%);
padding: 10px 20px;
border-radius: 10px;
font-family: 'Archivo', -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
font-size: 14px;
font-weight: 500;
color: #fff;
background: rgba(220, 38, 38, 0.9);
backdrop-filter: blur(8px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
z-index: 200;
pointer-events: none;
animation: toast-in 0.25s ease-out;
}
@keyframes toast-in {
from { opacity: 0; transform: translateX(-50%) translateY(10px); }
to { opacity: 1; transform: translateX(-50%) translateY(0); }
}
/* ─── Screenshot Hint ─── */
.hint {
position: fixed;
bottom: 48px;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
border-radius: 10px;
font-family: 'Archivo', -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
font-size: 13px;
font-weight: 450;
z-index: 200;
pointer-events: none;
animation: hint-in 0.25s ease-out;
color: rgba(255, 255, 255, 0.8);
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 0.5px solid rgba(255, 255, 255, 0.1);
}
@keyframes hint-in {
from { opacity: 0; transform: translateX(-50%) translateY(6px); }
to { opacity: 1; transform: translateX(-50%) translateY(0); }
}
</style>
@@ -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>
+35 -3
View File
@@ -14,11 +14,18 @@
onMount(async () => {
connections.set(await window.electronAPI.getConnections())
config.set(await window.electronAPI.getConfig())
const cfg = await window.electronAPI.getConfig()
config.set(cfg)
sidebarOpen = cfg?.showSidebar ?? true
setTimeout(() => {
visible = true
}, 50)
})
const toggleSidebar = () => {
sidebarOpen = !sidebarOpen
window.electronAPI.setConfig({ showSidebar: sidebarOpen })
}
</script>
{#if visible}
@@ -33,13 +40,13 @@
: 'h-8'}"
>
<div
class="flex items-center {$appInfo?.platform === 'darwin'
class="flex items-center gap-3 {$appInfo?.platform === 'darwin'
? 'pl-25'
: 'pl-3'} pr-2 shrink-0 translate-y-[0.5px]"
>
<button
class="opacity-70 hover:opacity-100 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] no-drag"
onclick={() => (sidebarOpen = !sidebarOpen)}
onclick={toggleSidebar}
use:tooltip={sidebarOpen ? $i18n.t('sidebar.tooltip.closeSidebar') : $i18n.t('sidebar.tooltip.openSidebar')}
>
<svg
@@ -56,6 +63,31 @@
/>
</svg>
</button>
{#if activeConnectionName}
<button
class="opacity-40 hover:opacity-80 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] no-drag cursor-pointer"
onclick={() => {
const wv = document.querySelector('webview[style*="display: flex"]') as any
if (wv?.goBack) wv.goBack()
}}
>
<svg class="w-[13px] h-[13px]" 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>
</button>
<button
class="opacity-40 hover:opacity-80 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] no-drag cursor-pointer"
onclick={() => {
const wv = document.querySelector('webview[style*="display: flex"]') as any
if (wv?.goForward) wv.goForward()
}}
>
<svg class="w-[13px] h-[13px]" 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>
{/if}
</div>
<div class="flex-1 flex items-center justify-center">
<span class="text-[11px] opacity-80">{activeConnectionName || $i18n.t('app.name')}</span>
@@ -50,9 +50,11 @@
const serverReachable = $derived($serverInfo?.reachable)
const isInitializing = $derived($appState === 'initializing')
const hasLocal = $derived(($connections ?? []).some((c) => c.type === 'local'))
const localConn = $derived(($connections ?? []).find((c) => c.type === 'local'))
const remoteConnections = $derived(($connections ?? []).filter((c) => c.type !== 'local'))
const localConn = $derived(localInstalled
? { id: 'local', name: 'Open WebUI', type: 'local' as const, url: `http://127.0.0.1:${$config?.localServer?.port ?? 8080}` }
: null
)
const remoteConnections = $derived($connections ?? [])
// Open Terminal state
let openTerminalStatus = $state<string | null>(null)
@@ -62,13 +64,22 @@
let llamaCppStatus = $state<string | null>(null)
let llamaCppInfo = $state<{ url?: string; pid?: number } | null>(null)
let llamaCppSetupStatus = $state('')
let openTerminalSetupStatus = $state('')
const startInstall = async (options?: { installOpenTerminal?: boolean; installLlamaCpp?: boolean }) => {
const startInstall = async (options?: { installOpenTerminal?: boolean; installLlamaCpp?: boolean; installDir?: string }) => {
installPhase = 'working'
installError = ''
installStatus = ''
toastVisible = false
try {
// Save custom install directory before anything else
if (options?.installDir) {
const currentDir = await window.electronAPI.getInstallDir()
if (options.installDir !== currentDir) {
await window.electronAPI.setConfig({ installDir: options.installDir })
}
}
// Check disk space before installing (minimum 5 GB)
const MINIMUM_DISK_BYTES = 5 * 1024 * 1024 * 1024
const disk = await window.electronAPI.getDiskSpace()
@@ -101,14 +112,7 @@
const info = await window.electronAPI.getServerInfo()
installStatus = $i18n.t('main.install.settingUpConnection')
await window.electronAPI.addConnection({
id: 'local',
name: 'Local',
type: 'local',
url: info?.url || 'http://127.0.0.1:8080'
})
await window.electronAPI.setDefaultConnection('local')
connections.set(await window.electronAPI.getConnections())
config.set(await window.electronAPI.getConfig())
// Wait for server to actually be reachable before showing connected view
@@ -132,8 +136,7 @@
// Now connect — the server is ready
installStatus = ''
localInstalled = true
await connect('local')
connect('local')
installPhase = 'idle'
} catch (e: any) {
installPhase = 'error'
@@ -169,7 +172,6 @@
type: 'remote',
url: u
})
connections.set(await window.electronAPI.getConnections())
config.set(await window.electronAPI.getConfig())
url = ''
error = ''
@@ -182,33 +184,60 @@
}
}
const connect = async (id: string) => {
const connect = (id: string) => {
showingLogs = false
// Toggle: clicking the active connection unselects it
if (activeConnectionId === id && view === 'connected') {
connectingId = ''
activeConnectionId = ''
connectedUrl = ''
view = 'welcome'
return
}
// Persist as default so spotlight/startup always use the last-selected connection
window.electronAPI.setDefaultConnection(id)
// Already-open connection — just switch to it
if (openConnections.has(id)) {
connectingId = ''
activeConnectionId = id
connectedUrl = openConnections.get(id)!
view = 'connected'
return
}
connectingId = id
try {
const result = await window.electronAPI.connectTo(id)
if (result?.url) {
openConnections.set(result.connectionId, result.url)
openConnections = new Map(openConnections) // trigger reactivity
connectedUrl = result.url
activeConnectionId = result.connectionId
view = 'connected'
}
} finally {
activeConnectionId = id
if (id === 'local') {
// Local needs server start — use IPC (no renderer-side conn needed)
connectingId = id
view = 'welcome'
window.electronAPI.connectTo(id).then((result: any) => {
if (!result?.url) {
if (connectingId === id) connectingId = ''
return
}
if (!openConnections.has(result.connectionId)) {
openConnections.set(result.connectionId, result.url)
openConnections = new Map(openConnections)
}
if (connectingId === id) {
connectedUrl = result.url
activeConnectionId = result.connectionId
connectingId = ''
if (installPhase !== 'working') {
view = 'connected'
}
}
})
} else {
const conn = ($connections ?? []).find((c) => c.id === id)
if (!conn) return
// Remote — open immediately, no IPC needed
connectingId = ''
openConnections.set(id, conn.url)
openConnections = new Map(openConnections)
connectedUrl = conn.url
view = 'connected'
}
}
@@ -220,7 +249,6 @@
const remove = async (id: string) => {
await window.electronAPI.removeConnection(id)
connections.set(await window.electronAPI.getConnections())
config.set(await window.electronAPI.getConfig())
if (activeConnectionId === id) {
disconnect()
@@ -229,11 +257,15 @@
openConnections = new Map(openConnections)
}
// Sync active connection info to parent
$effect(() => {
const conn = ($connections ?? []).find((c) => c.id === activeConnectionId)
activeConnectionName = conn?.name ?? ''
isLocalConnection = conn?.type === 'local'
if (activeConnectionId === 'local') {
activeConnectionName = localConn?.name ?? 'Open WebUI'
isLocalConnection = true
} else {
const conn = ($connections ?? []).find((c) => c.id === activeConnectionId)
activeConnectionName = conn?.name ?? ''
isLocalConnection = false
}
})
// React to showingLogs from parent — open the server log panel
@@ -301,41 +333,125 @@
activeLog = activeLog === log ? null : (log as typeof activeLog)
}
// ── Webview event delivery ─────────────────────────────
// Single path: all events from the main process flow through here.
// Query events target a specific webview; everything else broadcasts.
const sendToWebview = (event: any, connId?: string) => {
const container = document.querySelector('.content-webview-container')
if (!container) return
const webviews = connId
? [container.querySelector(`webview[partition="persist:connection-${connId}"]`) as any].filter(Boolean)
: Array.from(container.querySelectorAll('webview'))
for (const wv of webviews) {
try {
// Attempt to send — throws if webview hasn't fired dom-ready yet
wv.send('desktop:event', event)
} catch {
// Webview not ready — queue delivery until dom-ready
const onReady = () => {
wv.removeEventListener('dom-ready', onReady)
try { wv.send('desktop:event', event) } catch (_) {}
}
wv.addEventListener('dom-ready', onReady)
}
}
}
// Listen for events from main process
onMount(() => {
window.electronAPI.onData((data: any) => {
// ── Connection opened (startup, tray click) ───────
if (data.type === 'connection:open' && data.data?.url) {
const connId = data.data.connectionId ?? ''
openConnections.set(connId, data.data.url)
openConnections = new Map(openConnections)
connectedUrl = data.data.url
activeConnectionId = connId
// Don't switch to connected view during active install — the install
// flow handles its own transition after confirming reachability.
if (installPhase !== 'working') {
view = 'connected'
const incomingUrl = data.data.url
if (!openConnections.has(connId)) {
openConnections.set(connId, incomingUrl)
openConnections = new Map(openConnections)
}
if (view !== 'connected') {
connectedUrl = openConnections.get(connId) ?? incomingUrl
activeConnectionId = connId
if (installPhase !== 'working') view = 'connected'
}
return
}
if (data.type === 'status:open-terminal') {
openTerminalStatus = data.data
// ── Spotlight / desktop query ─────────────────────
if (data.type === 'query' && (data.data?.query || data.data?.files?.length)) {
const connId = data.data.connectionId ?? ''
const query = data.data.query
const files = data.data.files
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: 'query', data: { query, files } }, connId)
})
return
}
if (data.type === 'open-terminal:ready') {
openTerminalInfo = data.data
openTerminalStatus = 'started'
// ── 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
}
if (data.type === 'status:llamacpp') {
llamaCppStatus = data.data
// ── Desktop-only state (not forwarded to webviews) ─
if (data.type === 'status:open-terminal') { openTerminalStatus = data.data; return }
if (data.type === 'status:open-terminal-setup') { openTerminalSetupStatus = data.data ?? ''; return }
if (data.type === 'open-terminal:ready') { openTerminalInfo = data.data; openTerminalStatus = 'started'; openTerminalSetupStatus = ''; return }
if (data.type === 'status:llamacpp') { llamaCppStatus = data.data; return }
if (data.type === 'status:llamacpp-setup') { llamaCppSetupStatus = data.data ?? ''; return }
if (data.type === 'llamacpp:ready') { llamaCppInfo = data.data; llamaCppStatus = 'started'; llamaCppSetupStatus = ''; return }
if (data.type === 'status:install') { installStatus = data.data ?? ''; return }
if (data.type === 'packages:changed') {
localInstalled = !!data.data?.['open-webui']
return
}
if (data.type === 'status:llamacpp-setup') {
llamaCppSetupStatus = data.data ?? ''
if (data.type === 'connections:changed') {
connections.set(data.data ?? [])
return
}
if (data.type === 'llamacpp:ready') {
llamaCppInfo = data.data
llamaCppStatus = 'started'
llamaCppSetupStatus = ''
}
if (data.type === 'status:install') {
installStatus = data.data ?? ''
// ── Everything else → broadcast to all webviews ───
sendToWebview(data)
})
// Auto-connect to the default connection on startup so the webview
// is pre-loaded and ready for spotlight queries.
window.electronAPI.getConfig().then((cfg: any) => {
if (cfg?.defaultConnectionId && !activeConnectionId) {
connect(cfg.defaultConnectionId)
}
})
@@ -375,8 +491,10 @@
await window.electronAPI.stopOpenTerminal()
openTerminalStatus = null
openTerminalInfo = null
openTerminalSetupStatus = ''
} else {
openTerminalStatus = 'starting'
openTerminalSetupStatus = ''
const result = await window.electronAPI.startOpenTerminal()
if (result) {
openTerminalInfo = result
@@ -384,6 +502,7 @@
} else {
openTerminalStatus = 'failed'
}
openTerminalSetupStatus = ''
}
}
@@ -427,7 +546,6 @@
{onOpenSettings}
onRename={async (id, name) => {
await window.electronAPI.updateConnection(id, { name })
connections.set(await window.electronAPI.getConnections())
}}
onRemove={remove}
{openGithub}
@@ -469,7 +587,7 @@
statusText={activeLog === 'server'
? (serverStatus === 'starting' ? 'Starting Open WebUI…' : serverStatus === 'running' && !serverReachable ? 'Waiting for server…' : installStatus || '')
: activeLog === 'open-terminal'
? (openTerminalStatus === 'stopping' ? 'Stopping Open Terminal…' : openTerminalStatus === 'starting' ? 'Starting Open Terminal…' : '')
? (openTerminalStatus === 'stopping' ? 'Stopping Open Terminal…' : openTerminalSetupStatus || (openTerminalStatus === 'starting' ? 'Starting Open Terminal…' : ''))
: (llamaCppStatus === 'stopping' ? 'Stopping llama-server…' : llamaCppSetupStatus || (llamaCppStatus === 'starting' ? 'Starting llama-server…' : llamaCppStatus === 'setting-up' ? 'Setting up llama.cpp…' : ''))}
connectPty={getConnectPty(activeLog)}
disconnectPty={getDisconnectPty(activeLog)}
@@ -37,12 +37,24 @@
<div
class="relative flex h-36 items-center justify-center overflow-hidden bg-gradient-to-br from-gray-900 via-gray-800 to-black dark:from-white dark:via-gray-100 dark:to-gray-200"
>
<div class="absolute inset-0 bg-gradient-to-r from-transparent via-white/5 to-transparent"></div>
<div
class="absolute inset-0 bg-gradient-to-r from-transparent via-white/5 to-transparent"
></div>
<div class="relative z-10 text-center">
<div class="mb-2.5 flex justify-center">
<div class="rounded-full bg-white/10 p-3 dark:bg-black/10">
<svg class="w-6 h-6 text-white dark:text-gray-900" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<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 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" />
<svg
class="w-6 h-6 text-white dark:text-gray-900"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<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 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"
/>
</svg>
</div>
</div>
@@ -57,11 +69,14 @@
<!-- Body -->
<div class="px-6 py-5">
<label class="block text-[11px] text-gray-400 dark:text-gray-500"
>{$i18n.t('setup.connectionManager.serverUrl')}</label
>
<input
type="text"
bind:value={url}
placeholder={$i18n.t('setup.urlPlaceholder')}
class="w-full px-4 py-2.5 rounded-xl bg-black/[0.015] dark:bg-white/[0.02] text-[13px] text-[#1d1d1f] dark:text-[#fafafa] placeholder:opacity-25 outline-none focus:bg-black/[0.03] dark:focus:bg-white/[0.04] transition border border-black/[0.06] dark:border-white/[0.06]"
placeholder="https://"
class="w-full py-2 text-[14px] text-[#1d1d1f] dark:text-[#fafafa] placeholder:opacity-20 outline-none bg-transparent border-none border-b border-black/[0.08] dark:border-white/[0.08]"
onkeydown={(e) => e.key === 'Enter' && onConnect()}
/>
@@ -79,7 +94,9 @@
>
{#if connecting}
<span class="inline-flex items-center gap-2">
<span class="w-3.5 h-3.5 rounded-full border-2 border-white/30 dark:border-black/30 border-t-white dark:border-t-black animate-spin inline-block"></span>
<span
class="w-3.5 h-3.5 rounded-full border-2 border-white/30 dark:border-black/30 border-t-white dark:border-t-black animate-spin inline-block"
></span>
{$i18n.t('common.connecting')}
</span>
{:else}
@@ -1,7 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte'
import { fade, fly } from 'svelte/transition'
import { connections, config, serverInfo, appState } from '../../../stores'
import { config, serverInfo, appState } from '../../../stores'
import i18n from '../../../i18n'
import LocalInstall from '../../Setup/LocalInstall.svelte'
import GetStartedModal from './GetStartedModal.svelte'
@@ -25,7 +25,7 @@
connecting: boolean
error: string
autoInstall: boolean
onStartInstall: (options?: { installOpenTerminal?: boolean; installLlamaCpp?: boolean }) => void
onStartInstall: (options?: { installOpenTerminal?: boolean; installLlamaCpp?: boolean; installDir?: string }) => void
onAddConnection: () => void
onSetView: (v: string) => void
showAddConnectionModal: boolean
@@ -72,42 +72,55 @@
// Track webview loading per connection
let webviewLoading: Map<string, boolean> = $state(new Map())
// Track webview load errors per connection
let webviewErrors: Map<string, { code: number; description: string; url: string }> = $state(new Map())
// Content preload path for webview bridge
let contentPreloadPath: string = $state('')
// Server is starting up (local)
const serverStarting = $derived(
localConn && localInstalled && (
localInstalled && (
$serverInfo?.status === 'starting' ||
($serverInfo?.status === 'running' && !$serverInfo?.reachable)
)
)
const activeWebviewError = $derived(
view === 'connected' && activeConnectionId
? webviewErrors.get(activeConnectionId) ?? null
: null
)
const isLoading = $derived(
connectingId !== '' ||
serverStarting ||
(view === 'connected' && activeConnectionId && (webviewLoading.get(activeConnectionId) ?? true))
(serverStarting && activeConnectionId === 'local') ||
(view === 'connected' && !activeWebviewError && webviewLoading.get(activeConnectionId) === true)
)
const retryActiveWebview = () => {
const wv = document.querySelector(
`webview[partition="persist:connection-${activeConnectionId}"]`
) as any
if (wv?.reload) {
webviewErrors.delete(activeConnectionId)
webviewErrors = new Map(webviewErrors)
wv.reload()
}
}
const openActiveInBrowser = () => {
const connUrl = openConnections.get(activeConnectionId)
if (connUrl) {
window.electronAPI.openInBrowser(connUrl)
}
}
// Attach load event listeners and IPC forwarding to webviews
onMount(async () => {
// Fetch the content preload path once
contentPreloadPath = await window.electronAPI.getContentPreloadPath()
// Forward main:data events from the main process into all active webviews
window.electronAPI.onData((data: any) => {
const container = document.querySelector('.content-webview-container')
if (!container) return
const webviews = container.querySelectorAll('webview')
webviews.forEach((wv: any) => {
try {
wv.send('desktop:event', data)
} catch (_) {
// webview may not be ready yet
}
})
})
const observer = new MutationObserver(() => {
const container = document.querySelector('.content-webview-container')
if (!container) return
@@ -130,11 +143,63 @@
webviewLoading = new Map(webviewLoading)
})
// Track load failures so we can show an error overlay
wv.addEventListener('did-fail-load', (event: any) => {
// Ignore sub-resource failures and aborted navigations (-3)
if (event.errorCode === -3 || event.isMainFrame === false) return
webviewErrors.set(connId, {
code: event.errorCode,
description: event.errorDescription || 'Unknown error',
url: event.validatedURL || ''
})
webviewErrors = new Map(webviewErrors)
})
// Clear error when a navigation succeeds (retry, redirect, etc.)
wv.addEventListener('did-navigate', () => {
if (webviewErrors.has(connId)) {
webviewErrors.delete(connId)
webviewErrors = new Map(webviewErrors)
}
})
// Renderer process crash
wv.addEventListener('crashed', () => {
webviewErrors.set(connId, {
code: -1,
description: 'crashed',
url: ''
})
webviewErrors = new Map(webviewErrors)
})
// Log guest page console messages for debugging blank-page issues (#124)
wv.addEventListener('console-message', (event: any) => {
if (event.level >= 2) { // warnings and errors only
console.warn(`[webview:${connId}]`, event.message)
}
})
// If this webview was created before the preload path resolved
// (race between auto-connect and async IPC), the preload didn't
// attach. Force a reload now so it picks up the correct preload.
if (contentPreloadPath && wv.getAttribute('preload') !== contentPreloadPath) {
wv.setAttribute('preload', contentPreloadPath)
wv.reload()
}
// Handle IPC messages from the webview guest (Open WebUI → desktop)
wv.addEventListener('ipc-message', async (event: any) => {
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) {
@@ -149,6 +214,35 @@
} else if (event.channel === 'webview:load') {
const page = event.args?.[0]
if (page) onSetView(page === 'home' ? 'welcome' : page)
} else if (event.channel === 'webview:event') {
const payload = event.args?.[0]
if (!payload?.type) return
if (payload.type === 'theme:update') {
const webuiTheme = payload.data?.theme ?? 'system'
// Map Open WebUI theme names to desktop-compatible values
let desktopTheme: string
if (webuiTheme === 'system') {
desktopTheme = 'system'
} else if (webuiTheme.includes('dark')) {
desktopTheme = 'dark'
} else {
desktopTheme = 'light'
}
// Resolve and apply CSS class
let resolved = desktopTheme
if (desktopTheme === 'system') {
resolved = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
document.documentElement.classList.remove('light', 'dark')
document.documentElement.classList.add(resolved)
// Persist to desktop config
await window.electronAPI.setConfig({ theme: desktopTheme })
config.set(await window.electronAPI.getConfig())
}
}
})
})
@@ -174,12 +268,54 @@
src={connUrl}
class="flex-1 min-h-0 border-none"
style="display: {view === 'connected' && activeConnectionId === connId ? 'flex' : 'none'}"
allowpopups
partition="persist:connection-{connId}"
preload={contentPreloadPath}
allowpopups
></webview>
{/each}
<!-- Error overlay when webview fails to load -->
{#if activeWebviewError}
<div class="absolute inset-0 z-20 flex items-center justify-center bg-[#eee] dark:bg-[#111]" transition:fade={{ duration: 200 }}>
<div class="text-center max-w-sm px-6">
<div class="mx-auto mb-4 w-10 h-10 rounded-full bg-black/[0.04] dark:bg-white/[0.06] flex items-center justify-center">
{#if activeWebviewError.code === -1}
<svg class="w-5 h-5 opacity-30" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
</svg>
{:else}
<svg class="w-5 h-5 opacity-30" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<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 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" />
</svg>
{/if}
</div>
<div class="text-[14px] font-medium mb-1 opacity-80">
{activeWebviewError.code === -1 ? $i18n.t('setup.pageCrashed') : $i18n.t('setup.couldNotLoadPage')}
</div>
<div class="text-[12px] opacity-30 mb-1">{activeWebviewError.description}</div>
{#if activeWebviewError.url}
<div class="text-[11px] opacity-20 mb-6 break-all font-mono">{activeWebviewError.url}</div>
{:else}
<div class="mb-6"></div>
{/if}
<div class="flex gap-2 justify-center">
<button
class="px-4 py-2 rounded-xl text-[13px] font-medium bg-black dark:bg-white text-white dark:text-black border-none cursor-pointer transition hover:bg-gray-800 dark:hover:bg-gray-100 active:scale-[0.98]"
onclick={retryActiveWebview}
>
{$i18n.t('common.retry')}
</button>
<button
class="px-4 py-2 rounded-xl text-[13px] bg-black/[0.04] dark:bg-white/[0.06] text-[#1d1d1f] dark:text-[#fafafa] border-none cursor-pointer opacity-60 hover:opacity-90 transition active:scale-[0.98]"
onclick={openActiveInBrowser}
>
{$i18n.t('setup.openInBrowser')}
</button>
</div>
</div>
</div>
{/if}
<!-- Loading overlay for webview -->
{#if isLoading}
<div class="absolute inset-0 z-10 flex items-center justify-center bg-[#eee] dark:bg-[#111]" transition:fade={{ duration: 200 }}>
@@ -253,7 +389,7 @@
<div class="flex-1 flex items-center justify-center px-6 relative overflow-hidden">
{#if view === 'welcome'}
{#if remoteConnections.length > 0 || (localConn && localInstalled)}
{#if remoteConnections.length > 0 || localInstalled}
<div class="text-center max-w-[320px]" in:fade={{ duration: 200 }}>
<div class="text-lg opacity-80 mb-1.5">{$i18n.t('app.name')}</div>
<div class="text-[12px] opacity-30 mb-6">
@@ -351,7 +487,6 @@
autoStart={autoInstall}
onBack={() => { autoInstall = false; onSetView('welcome') }}
onComplete={async () => {
connections.set(await window.electronAPI.getConnections())
config.set(await window.electronAPI.getConfig())
onSetView('welcome')
}}
@@ -1,10 +1,11 @@
<script lang="ts">
import { onMount } from 'svelte'
import { fade, scale } from 'svelte/transition'
import i18n from '../../../i18n'
import Switch from '../../common/Switch.svelte'
interface Props {
onContinue: (options: { installOpenTerminal: boolean; installLlamaCpp: boolean }) => void
onContinue: (options: { installOpenTerminal: boolean; installLlamaCpp: boolean; installDir: string }) => void
onCancel: () => void
}
@@ -12,6 +13,21 @@
let installOpenTerminal = $state(true)
let installLlamaCpp = $state(true)
let installDir = $state('')
let defaultInstallDir = $state('')
let advancedOpen = $state(false)
onMount(async () => {
defaultInstallDir = await window.electronAPI.getInstallDir()
installDir = defaultInstallDir
})
const changeInstallDir = async () => {
const folder = await window.electronAPI.selectFolder()
if (folder) {
installDir = folder
}
}
</script>
<!-- svelte-ignore a11y_click_events_have_key_events -->
@@ -24,7 +40,7 @@
<div class="absolute inset-0 bg-black/50 backdrop-blur-sm"></div>
<div
class="relative mx-4 w-full max-w-lg overflow-hidden rounded-3xl bg-white shadow-2xl dark:bg-gray-950"
class="relative mx-4 w-full max-w-xl overflow-hidden rounded-3xl bg-white shadow-2xl dark:bg-gray-950"
transition:scale={{ start: 0.97, duration: 180 }}
onmousedown={(e) => e.stopPropagation()}
>
@@ -51,7 +67,7 @@
</div>
<!-- Options -->
<div class="px-6 py-4 flex flex-col divide-y divide-gray-100 dark:divide-gray-800/60">
<div class="px-6 py-4 flex flex-col divide-y divide-gray-100/30 dark:divide-gray-800/15">
<div class="py-3.5 flex items-center justify-between gap-4">
<div>
<div class="text-[13px] font-medium text-gray-700 dark:text-gray-300">{$i18n.t('main.getStarted.openTerminal')}</div>
@@ -78,11 +94,48 @@
</div>
</div>
<!-- Advanced (collapsed) -->
<div class="px-6 pb-4">
<button
class="flex items-center gap-1.5 bg-transparent border-none p-0 cursor-pointer"
onclick={() => { advancedOpen = !advancedOpen }}
>
<svg
class="w-2.5 h-2.5 text-gray-400 dark:text-gray-500 transition-transform duration-200 {advancedOpen ? 'rotate-90' : ''}"
fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
</svg>
<span class="text-[11px] text-gray-400 dark:text-gray-500">{$i18n.t('common.advanced')}</span>
</button>
{#if advancedOpen}
<div class="mt-3">
<div class="text-[11px] text-gray-400 dark:text-gray-500 mb-1.5">{$i18n.t('setup.install.installLocation')}</div>
<div class="flex items-center gap-2">
<div
class="flex-1 min-w-0 px-3 py-2 bg-gray-50 dark:bg-gray-900 text-[11px] text-gray-500 dark:text-gray-400 font-mono truncate rounded-xl"
title={installDir}
>
{installDir || '…'}
</div>
<button
class="shrink-0 text-[11px] text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 px-3 py-2 bg-gray-50 dark:bg-gray-900 rounded-xl transition border-none cursor-pointer"
onclick={changeInstallDir}
>
{$i18n.t('setup.install.changeLocation')}
</button>
</div>
<div class="text-[10px] text-gray-300 dark:text-gray-600 mt-1">{$i18n.t('setup.install.installLocationDesc')}</div>
</div>
{/if}
</div>
<!-- Footer -->
<div class="px-5 pb-5 flex flex-col gap-2">
<div class="px-5 pb-5 pt-1 flex flex-col gap-2">
<button
class="w-full rounded-xl bg-gray-900 dark:bg-white px-4 py-2.5 text-sm font-medium text-white dark:text-gray-900 transition-all duration-200 hover:bg-gray-800 dark:hover:bg-gray-100 active:scale-[0.98] border-none cursor-pointer"
onclick={() => onContinue({ installOpenTerminal, installLlamaCpp })}
onclick={() => onContinue({ installOpenTerminal, installLlamaCpp, installDir })}
>
{$i18n.t('main.getStarted.continue')}
</button>
@@ -95,3 +148,4 @@
</div>
</div>
</div>
@@ -69,7 +69,9 @@
>
<!-- Connections header -->
<div class="flex items-center justify-between px-4 pt-2 pb-1.5">
<span class="text-[10px] tracking-wider uppercase opacity-60">{$i18n.t('sidebar.connections')}</span>
<span class="text-[10px] tracking-wider uppercase opacity-60"
>{$i18n.t('sidebar.connections')}</span
>
<button
class="opacity-25 hover:opacity-60 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] leading-none"
onclick={() => {
@@ -93,20 +95,18 @@
<div class="flex-1 min-h-0 overflow-y-auto px-2">
<!-- Pinned: Open WebUI (local) -->
{#if localConn && localInstalled}
{@const isServerLoading = connectingId === localConn.id || serverStatus === 'starting' || (serverStatus === 'running' && !serverReachable)}
{@const isLocalDisabled = !serverReachable && !isServerLoading}
{@const isServerLoading =
connectingId === localConn.id ||
serverStatus === 'starting' ||
(serverStatus === 'running' && !serverReachable)}
<div
class="w-full px-2 py-[6px] rounded-xl group flex items-center gap-2 transition-colors {isLocalDisabled
? 'opacity-40 cursor-default'
: 'cursor-pointer'} {activeConnectionId === localConn.id || isServerLoading
? 'bg-black/[0.06] dark:bg-white/[0.08]'
: isLocalDisabled
? ''
: 'hover:bg-black/[0.03] dark:bg-white/[0.05]'}"
class="w-full px-2.5 py-1.5 rounded-xl group flex items-center gap-2 transition-colors cursor-pointer {activeConnectionId === localConn.id
? 'bg-black/[0.08] dark:bg-white/[0.08]'
: 'hover:bg-black/[0.04] dark:hover:bg-white/[0.06]'}"
role="button"
tabindex="0"
onclick={() => !isLocalDisabled && onConnect(localConn.id)}
onkeydown={(e) => e.key === 'Enter' && !isLocalDisabled && onConnect(localConn.id)}
onclick={() => onConnect(localConn.id)}
onkeydown={(e) => e.key === 'Enter' && onConnect(localConn.id)}
>
{#if connectingId === localConn.id || serverStatus === 'starting' || (serverStatus === 'running' && !serverReachable)}
<div class="w-[14px] h-[14px] shrink-0 flex items-center justify-center">
@@ -144,8 +144,8 @@
{:else}
<span
class="text-[12px] {activeConnectionId === localConn.id
? 'opacity-90'
: 'opacity-100'} transition-opacity truncate flex-1 min-w-0"
? 'font-medium opacity-100'
: 'opacity-70'} transition-opacity truncate flex-1 min-w-0"
>{localConn.name ?? 'Open WebUI'}</span
>
{/if}
@@ -180,7 +180,7 @@
>
<div class="py-1 px-1.5">
<button
class="w-full flex items-center gap-2.5 px-3 py-1.5 text-left text-[12px] opacity-50 hover:opacity-90 hover:bg-black/[0.04] dark:bg-white/[0.06] transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-xl"
class="w-full flex items-center gap-2.5 px-3 py-1.5 text-left text-[12px] opacity-50 hover:opacity-90 hover:bg-black/[0.04] dark:hover:bg-white/[0.06] transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-xl"
onclick={(e) => {
e.stopPropagation()
menuOpenId = null
@@ -203,7 +203,7 @@
{$i18n.t('common.rename')}
</button>
<button
class="w-full flex items-center gap-2.5 px-3 py-1.5 text-left text-[12px] opacity-50 hover:opacity-90 hover:bg-black/[0.04] dark:bg-white/[0.06] transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-xl"
class="w-full flex items-center gap-2.5 px-3 py-1.5 text-left text-[12px] opacity-50 hover:opacity-90 hover:bg-black/[0.04] dark:hover:bg-white/[0.06] transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-xl"
onclick={(e) => {
e.stopPropagation()
menuOpenId = null
@@ -238,10 +238,10 @@
{#each remoteConnections as conn (conn.id)}
<div
class="w-full px-2 py-[6px] rounded-xl group flex items-center gap-2 transition-colors cursor-pointer {activeConnectionId ===
class="w-full px-2.5 py-1.5 rounded-xl group flex items-center gap-2 transition-colors cursor-pointer {activeConnectionId ===
conn.id
? 'bg-black/[0.06] dark:bg-white/[0.08]'
: 'hover:bg-black/[0.03] dark:bg-white/[0.05]'}"
? 'bg-black/[0.08] dark:bg-white/[0.08]'
: 'hover:bg-black/[0.04] dark:hover:bg-white/[0.06]'}"
role="button"
tabindex="0"
onclick={() => editingId !== conn.id && onConnect(conn.id)}
@@ -288,8 +288,8 @@
{:else}
<span
class="text-[12px] {activeConnectionId === conn.id
? 'opacity-90'
: 'opacity-100'} transition-opacity truncate flex-1 min-w-0">{conn.name}</span
? 'font-medium opacity-100'
: 'opacity-70'} transition-opacity truncate flex-1 min-w-0">{conn.name}</span
>
{/if}
@@ -324,7 +324,7 @@
>
<div class="py-1 px-1.5">
<button
class="w-full flex items-center gap-2.5 px-3 py-1.5 text-left text-[12px] opacity-50 hover:opacity-90 hover:bg-black/[0.04] dark:bg-white/[0.06] transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-xl"
class="w-full flex items-center gap-2.5 px-3 py-1.5 text-left text-[12px] opacity-50 hover:opacity-90 hover:bg-black/[0.04] dark:hover:bg-white/[0.06] transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-xl"
onclick={(e) => {
e.stopPropagation()
menuOpenId = null
@@ -347,7 +347,7 @@
{$i18n.t('common.rename')}
</button>
<button
class="w-full flex items-center gap-2.5 px-3 py-1.5 text-left text-[12px] opacity-50 hover:opacity-90 hover:bg-black/[0.04] dark:bg-white/[0.06] transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-xl"
class="w-full flex items-center gap-2.5 px-3 py-1.5 text-left text-[12px] opacity-50 hover:opacity-90 hover:bg-black/[0.04] dark:hover:bg-white/[0.06] transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-xl"
onclick={(e) => {
e.stopPropagation()
menuOpenId = null
@@ -4,7 +4,6 @@
const remove = async (id: string) => {
await window.electronAPI.removeConnection(id)
connections.set(await window.electronAPI.getConnections())
config.set(await window.electronAPI.getConfig())
}
</script>
@@ -30,7 +29,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>
@@ -9,6 +9,8 @@
let resetting = $state(false)
let theme = $state<string>('system')
let advancedOpen = $state(false)
let installDirPath = $state('')
let defaultInstallDir = $state('')
// Env vars editor state
let envEntries = $state<{ key: string; value: string }[]>([])
@@ -26,6 +28,10 @@
theme = cfg?.theme ?? 'system'
applyThemeClass(theme)
// Load install dir
defaultInstallDir = await window.electronAPI.getInstallDir()
installDirPath = cfg?.installDir || defaultInstallDir
// Load languages
languages = await getLanguages()
selectedLanguage = cfg?.language ?? localStorage.getItem('locale') ?? 'en-US'
@@ -45,6 +51,18 @@
applyThemeClass(newTheme)
await window.electronAPI.setConfig({ theme: newTheme })
config.set(await window.electronAPI.getConfig())
// Push theme to all active Open WebUI webviews
const container = document.querySelector('.content-webview-container')
if (container) {
container.querySelectorAll('webview').forEach((wv: any) => {
try {
wv.send('desktop:event', { type: 'theme:update', data: { theme: newTheme } })
} catch (_) {
// webview may not be ready yet
}
})
}
}
const setDefault = async (id: string) => {
@@ -76,6 +94,26 @@
let recording = $state(false)
let shortcutInputEl = $state<HTMLButtonElement | null>(null)
// Spotlight shortcut recorder
let spotlightShortcutValue = $state('')
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)
// Spotlight clipboard paste
let spotlightClipboardPaste = $state(true)
// Keep shortcut value in sync with config store
$effect(() => {
if ($config?.globalShortcut !== undefined) {
@@ -83,6 +121,33 @@
}
})
$effect(() => {
if ($config?.spotlightShortcut !== undefined) {
spotlightShortcutValue = $config.spotlightShortcut ?? ''
}
if ($config?.spotlightClipboardPaste !== undefined) {
spotlightClipboardPaste = $config.spotlightClipboardPaste ?? true
}
})
$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')
@@ -93,16 +158,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('+')
}
@@ -142,6 +231,84 @@
config.set(await window.electronAPI.getConfig())
}
}
const handleSpotlightShortcutKeydown = async (e: KeyboardEvent) => {
e.preventDefault()
e.stopPropagation()
if (e.key === 'Escape') {
spotlightRecording = false
return
}
if (e.key === 'Backspace' || e.key === 'Delete') {
spotlightShortcutValue = ''
spotlightRecording = false
await window.electronAPI.setConfig({ spotlightShortcut: '' })
config.set(await window.electronAPI.getConfig())
return
}
const accel = keyToElectron(e)
if (accel) {
spotlightShortcutValue = accel
spotlightRecording = false
await window.electronAPI.setConfig({ spotlightShortcut: accel })
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]">
@@ -303,6 +470,220 @@
</div>
</div>
<div class="py-4 flex items-center justify-between">
<div>
<div class="text-[13px] opacity-70">{$i18n.t('settings.general.spotlightShortcut')}</div>
<div class="text-[11px] opacity-25 mt-0.5">
{#if spotlightRecording}
{$i18n.t('settings.general.globalShortcutRecording')}
{:else}
{$i18n.t('settings.general.spotlightShortcutDesc')}
{/if}
</div>
</div>
<div class="flex items-center gap-1.5">
<button
bind:this={spotlightShortcutInputEl}
class="text-[12px] px-3 py-1.5 border-none outline-none rounded-xl transition min-w-[80px] text-center
{spotlightRecording
? '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={() => {
spotlightRecording = true
spotlightShortcutInputEl?.focus()
}}
onkeydown={(e) => {
if (spotlightRecording) handleSpotlightShortcutKeydown(e)
}}
onblur={() => {
spotlightRecording = false
}}
>
{#if spotlightRecording}
<span class="text-[11px]">{$i18n.t('settings.general.pressShortcut')}</span>
{:else if spotlightShortcutValue}
{displayShortcut(spotlightShortcutValue)}
{:else}
<span class="opacity-40">{$i18n.t('common.disabled')}</span>
{/if}
</button>
{#if spotlightShortcutValue && !spotlightRecording}
<button
class="opacity-20 hover:opacity-50 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] p-0.5 shrink-0"
onclick={async () => {
spotlightShortcutValue = ''
await window.electronAPI.setConfig({ spotlightShortcut: '' })
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>
<div class="py-4 flex items-center justify-between">
<div>
<div class="text-[13px] opacity-70">Clipboard Auto-Paste</div>
<div class="text-[11px] opacity-25 mt-0.5">Automatically paste clipboard contents into Spotlight</div>
</div>
<Switch
checked={spotlightClipboardPaste}
label="Toggle clipboard auto-paste"
onchange={async (value) => {
spotlightClipboardPaste = value
await window.electronAPI.setConfig({ spotlightClipboardPaste: value })
config.set(await window.electronAPI.getConfig())
}}
/>
</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
@@ -319,7 +700,43 @@
</button>
{#if advancedOpen}
<div class="flex flex-col divide-y divide-white/[0.04] mt-1">
<div class="flex flex-col divide-y divide-white/[0.04] mt-1">
<!-- Install location -->
<div class="py-4 flex items-center justify-between gap-4">
<div class="shrink-0">
<div class="text-[13px] opacity-70">{$i18n.t('settings.general.installLocation')}</div>
<div class="text-[11px] opacity-25 mt-0.5">{$i18n.t('settings.general.installLocationDesc')}</div>
<div class="text-[10px] opacity-15 mt-0.5">{$i18n.t('settings.general.installLocationNote')}</div>
</div>
<div class="flex items-center gap-1.5 min-w-0 flex-1 max-w-[280px] justify-end">
<input
type="text"
class="bg-black/[0.04] dark:bg-white/[0.06] text-[12px] text-[#1d1d1f] dark:text-[#fafafa] px-3 py-1.5 border-none outline-none rounded-xl opacity-60 min-w-0 flex-1 text-right font-mono"
placeholder={defaultInstallDir || 'Default'}
value={installDirPath === defaultInstallDir ? '' : installDirPath}
onchange={async (e) => {
const val = (e.target as HTMLInputElement).value.trim()
installDirPath = val || defaultInstallDir
await window.electronAPI.setConfig({ installDir: val })
config.set(await window.electronAPI.getConfig())
}}
/>
<button
class="shrink-0 text-[12px] opacity-40 hover:opacity-70 px-2.5 py-1.5 bg-black/[0.04] dark:bg-white/[0.06] transition border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-xl"
onclick={async () => {
const folder = await window.electronAPI.selectFolder()
if (folder) {
installDirPath = folder
await window.electronAPI.setConfig({ installDir: folder })
config.set(await window.electronAPI.getConfig())
}
}}
>
{$i18n.t('common.browse')}
</button>
</div>
</div>
<!-- Environment variables -->
<div class="py-4">
<div class="flex items-center justify-between mb-3">
@@ -429,7 +429,7 @@
</div>
<Switch
checked={$config?.llamaCpp?.enabled ?? false}
onToggle={() => updateConfig('enabled', !($config?.llamaCpp?.enabled ?? false))}
onchange={(value) => updateConfig('enabled', value)}
/>
</div>
@@ -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>
@@ -34,7 +34,6 @@
type: 'remote',
url: u
})
connections.set(await window.electronAPI.getConnections())
config.set(await window.electronAPI.getConfig())
url = ''; name = ''; view = 'list'
} catch { error = $i18n.t('setup.connectionManager.failed') }
@@ -48,10 +47,8 @@
}
const remove = async (id: string) => {
await window.electronAPI.removeConnection(id)
const conns = await window.electronAPI.getConnections()
connections.set(conns)
config.set(await window.electronAPI.getConfig())
if (conns.length === 0) appState.set('setup')
if (($connections ?? []).length === 0) appState.set('setup')
}
</script>
@@ -1,7 +1,7 @@
<script lang="ts">
import { fade, fly } from 'svelte/transition'
import { onMount } from 'svelte'
import { connections, config, serverInfo } from '../../stores'
import { config, serverInfo } from '../../stores'
import i18n from '../../i18n'
import logoImage from '../../assets/images/splash.png'
@@ -10,24 +10,30 @@
let phase = $state(autoStart ? 'working' : 'ready') // ready | working | done | error
let errorMsg = $state('')
let installDir = $state('')
let defaultInstallDir = $state('')
onMount(async () => {
defaultInstallDir = await window.electronAPI.getInstallDir()
installDir = defaultInstallDir
if (autoStart) install()
})
const install = async () => {
phase = 'working'
try {
// Save custom install directory before installing
if (installDir && installDir !== defaultInstallDir) {
await window.electronAPI.setConfig({ installDir })
}
const ok = await window.electronAPI.installPackage()
if (!ok) { phase = 'error'; errorMsg = $i18n.t('setup.install.failed'); return }
await window.electronAPI.startServer()
const info = await window.electronAPI.getServerInfo()
await window.electronAPI.addConnection({
id: 'local',
name: 'Local',
type: 'local',
url: info?.url || 'http://127.0.0.1:8080'
})
await window.electronAPI.setDefaultConnection('local')
connections.set(await window.electronAPI.getConnections())
config.set(await window.electronAPI.getConfig())
phase = 'done'
@@ -41,9 +47,12 @@
}
}
onMount(() => {
if (autoStart) install()
})
const changeInstallDir = async () => {
const folder = await window.electronAPI.selectFolder()
if (folder) {
installDir = folder
}
}
</script>
<div class="flex flex-col" in:fade={{ duration: 200 }}>
@@ -58,10 +67,30 @@
{#if phase === 'ready'}
<div class="mb-1 text-sm font-normal opacity-50">{$i18n.t('app.name')}</div>
<h1 class="text-2xl font-light tracking-tight mb-2">{$i18n.t('setup.install.title')}</h1>
<p class="text-[12px] opacity-30 mb-8 leading-relaxed">
<p class="text-[12px] opacity-30 mb-6 leading-relaxed">
{$i18n.t('setup.install.description')}
</p>
<!-- Install location -->
<div class="mb-6">
<div class="text-[11px] opacity-40 mb-1.5">{$i18n.t('setup.install.installLocation')}</div>
<div class="flex items-center gap-2">
<div
class="flex-1 min-w-0 px-3 py-2 bg-black/[0.04] dark:bg-white/[0.06] text-[12px] text-[#1d1d1f] dark:text-[#fafafa] opacity-50 font-mono truncate rounded-lg"
title={installDir}
>
{installDir || '…'}
</div>
<button
class="shrink-0 text-[11px] opacity-40 hover:opacity-70 px-2.5 py-2 bg-black/[0.04] dark:bg-white/[0.06] transition border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-lg"
onclick={changeInstallDir}
>
{$i18n.t('setup.install.changeLocation')}
</button>
</div>
<div class="text-[10px] opacity-20 mt-1">{$i18n.t('setup.install.installLocationDesc')}</div>
</div>
<button
class="w-fit inline-flex items-center gap-2 bg-white px-8 py-2.5 text-black text-[13px] transition hover:bg-gray-100 border-none"
onclick={install}
@@ -1,7 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte'
import { fly, fade } from 'svelte/transition'
import { appState, connections, config } from '../../stores'
import { appState, config } from '../../stores'
import i18n from '../../i18n'
import LocalInstall from './LocalInstall.svelte'
@@ -30,16 +30,15 @@
try {
const valid = await window.electronAPI.validateUrl(u)
if (!valid) { error = $i18n.t('setup.couldNotReachServer'); connecting = false; return }
const connId = crypto.randomUUID()
await window.electronAPI.addConnection({
id: crypto.randomUUID(),
id: connId,
name: new URL(u).hostname,
type: 'remote',
url: u
})
connections.set(await window.electronAPI.getConnections())
config.set(await window.electronAPI.getConfig())
const conns = await window.electronAPI.getConnections()
await window.electronAPI.connectTo(conns[conns.length - 1].id)
await window.electronAPI.connectTo(connId)
appState.set('ready')
} catch {
error = $i18n.t('setup.connectionFailed')
@@ -55,6 +55,9 @@
"setup.couldNotReachServer": "Could not reach this server",
"setup.invalidUrl": "Please enter a valid URL",
"setup.connectionFailed": "Connection failed",
"setup.couldNotLoadPage": "Could not load this page",
"setup.pageCrashed": "This page crashed unexpectedly",
"setup.openInBrowser": "Open in Browser",
"setup.urlPlaceholder": "e.g. https://your-server.com",
"setup.preparingEnvironment": "Preparing environment…",
"setup.settingUp": "Setting up…",
@@ -66,6 +69,9 @@
"setup.install.mightTakeMinutes": "This might take a few minutes",
"setup.install.failed": "Install failed",
"setup.install.somethingWentWrong": "Something went wrong",
"setup.install.installLocation": "Install location",
"setup.install.installLocationDesc": "Where data, models, and runtimes will be stored",
"setup.install.changeLocation": "Change",
"error.notEnoughDiskSpace": "Not enough disk space",
"error.diskSpaceDetail": "At least 5 GB is required. Only {{available}} GB available.",
@@ -152,6 +158,8 @@
"settings.general.globalShortcutDesc": "Bring the app to the foreground from anywhere",
"settings.general.globalShortcutRecording": "Press a key combination… (Esc to cancel, Del to clear)",
"settings.general.pressShortcut": "Press shortcut…",
"settings.general.spotlightShortcut": "Spotlight shortcut",
"settings.general.spotlightShortcutDesc": "Open the quick-chat input bar from anywhere",
"settings.general.appearance": "Appearance",
"settings.general.appearanceDesc": "Choose light, dark, or system default",
"settings.general.language": "Language",
@@ -167,6 +175,9 @@
"settings.general.light": "Light",
"settings.general.dark": "Dark",
"settings.general.keyPlaceholder": "KEY",
"settings.general.installLocation": "Install location",
"settings.general.installLocationDesc": "Root directory for Python, models, and runtimes",
"settings.general.installLocationNote": "Changing this requires reinstalling components",
"settings.openwebui.notInstalled": "Not installed",
"settings.openwebui.notInstalledDesc": "Set up a local Open WebUI server",
+8
View File
@@ -0,0 +1,8 @@
import { mount } from 'svelte'
import Spotlight from './components/Spotlight.svelte'
const app = mount(Spotlight, {
target: document.getElementById('app')!
})
export default app
+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>
File diff suppressed because one or more lines are too long