Compare commits

..

144 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
Timothy Jaeryang Baek dd7a9c59ca refac 2026-03-22 22:29:38 -05:00
Timothy Jaeryang Baek 44c81fc626 infra: refac 2026-03-22 22:27:55 -05:00
Timothy Jaeryang Baek 2dfb178ca0 refac 2026-03-21 00:32:01 -05:00
Timothy Jaeryang Baek e1e2277fd8 refac 2026-03-21 00:26:24 -05:00
Timothy Jaeryang Baek bef08424cc refac 2026-03-21 00:22:05 -05:00
Timothy Jaeryang Baek f583dd1175 i18n: translations 2026-03-21 00:17:40 -05:00
Timothy Jaeryang Baek 0c03262f59 refac 2026-03-21 00:12:49 -05:00
Timothy Jaeryang Baek 3c9ed8c7b5 refac 2026-03-20 23:54:37 -05:00
Timothy Jaeryang Baek 10c055c368 refac 2026-03-20 23:31:32 -05:00
Timothy Jaeryang Baek 967cd01a86 refac 2026-03-20 23:26:36 -05:00
Timothy Jaeryang Baek 650bdd4b6d refac 2026-03-20 23:06:11 -05:00
Timothy Jaeryang Baek 95aba87530 refac 2026-03-20 22:52:37 -05:00
Timothy Jaeryang Baek 8be489d9d6 refac 2026-03-20 22:48:01 -05:00
Timothy Jaeryang Baek 5132ef790e refac 2026-03-20 22:29:08 -05:00
Timothy Jaeryang Baek abd49e1084 refac 2026-03-20 20:36:21 -05:00
Timothy Jaeryang Baek a8a77e31af refac 2026-03-20 20:32:26 -05:00
Timothy Jaeryang Baek 7d0cf09640 refac 2026-03-20 18:27:00 -05:00
Timothy Jaeryang Baek f63d2a1d55 refac 2026-03-20 18:14:49 -05:00
Timothy Jaeryang Baek d71ff21c7d refac 2026-03-20 18:05:53 -05:00
Timothy Jaeryang Baek 88d28ba0a6 refac 2026-03-20 17:52:07 -05:00
Timothy Jaeryang Baek f6790b739d refac 2026-03-20 17:49:42 -05:00
Timothy Jaeryang Baek e930ada5f2 refac 2026-03-20 17:44:39 -05:00
Timothy Jaeryang Baek 89339fd373 refac 2026-03-20 16:37:24 -05:00
Timothy Jaeryang Baek 9a87f15a7f refac 2026-03-20 16:35:27 -05:00
Timothy Jaeryang Baek d870a27c1e refac 2026-03-20 16:33:23 -05:00
Timothy Jaeryang Baek 6d2d176cd9 refac 2026-03-20 15:41:26 -05:00
Timothy Jaeryang Baek 19e264f494 refac 2026-03-18 19:17:58 -05:00
Timothy Jaeryang Baek b7399409fe refac 2026-03-18 19:12:58 -05:00
Timothy Jaeryang Baek c44660ce7c refac 2026-03-18 19:04:56 -05:00
Timothy Jaeryang Baek 61f55e6fd6 doc: readme 2026-03-18 19:04:24 -05:00
Timothy Jaeryang Baek 9678c513d1 refac 2026-03-18 19:01:52 -05:00
Timothy Jaeryang Baek 04c3acf6f5 refac 2026-03-18 18:58:24 -05:00
Timothy Jaeryang Baek ce24b23130 refac 2026-03-18 18:36:55 -05:00
Timothy Jaeryang Baek d0b48e16c5 refac 2026-03-18 18:30:20 -05:00
Timothy Jaeryang Baek b171cab68b i18n: translations 2026-03-18 18:09:00 -05:00
Timothy Jaeryang Baek 5b6478f207 refac 2026-03-18 18:05:05 -05:00
Timothy Jaeryang Baek a4f3bb252a refac 2026-03-18 18:04:04 -05:00
Timothy Jaeryang Baek 0a35434afa refac 2026-03-18 17:59:45 -05:00
Timothy Jaeryang Baek b7b1eecf55 i18n: jp 2026-03-18 17:54:59 -05:00
Timothy Jaeryang Baek 04d60c4184 refac 2026-03-18 17:54:53 -05:00
Timothy Jaeryang Baek 1186291a11 refac 2026-03-18 17:52:49 -05:00
Timothy Jaeryang Baek b524aa369a refac 2026-03-18 17:49:32 -05:00
Timothy Jaeryang Baek ecf1a143fa refac 2026-03-18 17:32:16 -05:00
Timothy Jaeryang Baek 726ef9aeb9 refac 2026-03-18 17:30:56 -05:00
Timothy Jaeryang Baek 8f247fd801 refac 2026-03-18 17:25:42 -05:00
Timothy Jaeryang Baek 546c191609 refac 2026-03-18 17:22:15 -05:00
Timothy Jaeryang Baek 98d10cb362 refac 2026-03-18 17:12:09 -05:00
Timothy Jaeryang Baek d03e3c06c8 refac 2026-03-18 16:00:31 -05:00
Timothy Jaeryang Baek e8e9349d18 refac 2026-03-18 15:38:50 -05:00
Timothy Jaeryang Baek a9be68c695 refac 2026-03-18 15:35:07 -05:00
Timothy Jaeryang Baek 8776b85ae0 refac 2026-03-18 04:16:03 -05:00
Timothy Jaeryang Baek 6383d7040e refac 2026-03-18 04:05:09 -05:00
Timothy Jaeryang Baek 35f5c6ee6d refac 2026-03-18 04:00:33 -05:00
Timothy Jaeryang Baek c6f840d0e5 refac 2026-03-18 03:59:04 -05:00
Timothy Jaeryang Baek 146f75f979 refac 2026-03-18 03:56:16 -05:00
Timothy Jaeryang Baek 86cf4834cd refac 2026-03-18 03:28:06 -05:00
Timothy Jaeryang Baek d1b6d15330 refac 2026-03-18 03:09:14 -05:00
Timothy Jaeryang Baek 7c05fe4f74 refac 2026-03-18 03:02:08 -05:00
Timothy Jaeryang Baek 0abeddde48 refac 2026-03-18 01:52:48 -05:00
Timothy Jaeryang Baek f73e3adeeb refac 2026-03-18 00:52:10 -05:00
Timothy Jaeryang Baek d26bd9718d refac 2026-03-18 00:45:36 -05:00
Timothy Jaeryang Baek 3d148ee531 refac 2026-03-18 00:43:46 -05:00
Timothy Jaeryang Baek 0e61600416 refac 2026-03-17 23:59:33 -05:00
Timothy Jaeryang Baek f1eb615e9a refac 2026-03-17 16:11:33 -05:00
Timothy Jaeryang Baek ee20d42184 doc: readme 2026-03-17 16:10:25 -05:00
Timothy Jaeryang Baek 20ec676f3a Add _old to gitignore 2026-03-17 16:09:04 -05:00
Timothy Jaeryang Baek e6bcb8e21f Use checkbox-based CLA in PR template, remove CLA bot workflow 2026-03-17 16:06:31 -05:00
Timothy Jaeryang Baek 637a675fcd Add AGPL-3.0 license, CLA, and CLA enforcement workflow 2026-03-17 16:04:41 -05:00
Timothy Jaeryang Baek 9f4cc2948c Initial commit 2026-03-17 16:01:25 -05:00
125 changed files with 25718 additions and 14674 deletions
+9
View File
@@ -0,0 +1,9 @@
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
+14 -64
View File
@@ -1,74 +1,24 @@
# Pull Request Checklist
## Description
**Before submitting, make sure you've checked the following:**
<!-- Describe your changes in detail. What problem does this solve? -->
- [ ] **Target branch:** Please verify that the pull request targets the `dev` branch.
- [ ] **Description:** Provide a concise description of the changes made in this pull request.
- [ ] **Changelog:** Ensure a changelog entry following the format of [Keep a Changelog](https://keepachangelog.com/) is added at the bottom of the PR description.
- [ ] **Documentation:** Have you updated relevant documentation [Open WebUI Docs](https://github.com/open-webui/docs), or other documentation sources?
- [ ] **Dependencies:** Are there any new dependencies? Have you updated the dependency versions in the documentation?
- [ ] **Testing:** Have you written and run sufficient tests to validate the changes?
- [ ] **Code review:** Have you performed a self-review of your code, addressing any coding standard issues and ensuring adherence to the project's coding standards?
- [ ] **Prefix:** To clearly categorize this pull request, prefix the pull request title using one of the following:
- **BREAKING CHANGE**: Significant changes that may affect compatibility
- **build**: Changes that affect the build system or external dependencies
- **ci**: Changes to our continuous integration processes or workflows
- **chore**: Refactor, cleanup, or other non-functional code changes
- **docs**: Documentation update or addition
- **feat**: Introduces a new feature or enhancement to the codebase
- **fix**: Bug fix or error correction
- **i18n**: Internationalization or localization changes
- **perf**: Performance improvement
- **refactor**: Code restructuring for better maintainability, readability, or scalability
- **style**: Changes that do not affect the meaning of the code (white space, formatting, missing semi-colons, etc.)
- **test**: Adding missing tests or correcting existing tests
- **WIP**: Work in progress, a temporary label for incomplete or ongoing work
## Related Issues
# Changelog Entry
### Description
- [Concisely describe the changes made in this pull request, including any relevant motivation and impact (e.g., fixing a bug, adding a feature, or improving performance)]
### Added
- [List any new features, functionalities, or additions]
### Changed
- [List any changes, updates, refactorings, or optimizations]
### Deprecated
- [List any deprecated functionality or features that have been removed]
### Removed
- [List any removed features, files, or functionalities]
### Fixed
- [List any fixes, corrections, or bug fixes]
### Security
- [List any new or updated security-related changes, including vulnerability fixes]
### Breaking Changes
- **BREAKING CHANGE**: [List any breaking changes affecting compatibility or functionality]
<!-- Link any related issues: Fixes #123, Closes #456 -->
---
### Additional Information
## Contributor License Agreement
- [Insert any additional context, notes, or explanations for the changes]
- [Reference any related issues, commits, or other relevant information]
<!--
🚨 DO NOT DELETE THE TEXT BELOW 🚨
Keep the "Contributor License Agreement" confirmation text intact.
Deleting it will trigger the CLA-Bot to INVALIDATE your PR.
### Screenshots or Videos
Your PR will NOT be reviewed or merged until you check the box below confirming that you have read and agree to the terms of the CLA.
-->
- [Attach any relevant screenshots or videos demonstrating the changes]
- [ ] By submitting this pull request, I confirm that I have read and fully agree to the [Contributor License Agreement (CLA)](https://github.com/open-webui/desktop/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT), and I am providing my contributions under its terms.
### Contributor License Agreement
By submitting this pull request, I confirm that I have read and fully agree to the [Contributor License Agreement (CLA)](/CONTRIBUTOR_LICENSE_AGREEMENT), and I am providing my contributions under its terms.
> [!NOTE]
> Deleting the CLA section will lead to immediate closure of your PR and it will not be merged in.
-189
View File
@@ -1,189 +0,0 @@
name: Build and Release Electron App (electron-builder)
on:
push:
branches:
- main
pull_request:
jobs:
build:
name: Build and Package
runs-on: ${{ matrix.os }}
strategy:
matrix:
include:
# - os: ubuntu-latest
# arch: x64
# - os: ubuntu-latest
# arch: arm64
- os: windows-latest
arch: x64
- os: macos-latest
arch: x64
- os: macos-latest
arch: arm64
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: Install Apple codesigning certificate
if: ${{ matrix.os == 'macos-latest' }}
env:
BUILD_CERTIFICATE_BASE64: ${{ secrets.BUILD_CERTIFICATE_BASE64 }}
P12_PASSWORD: ${{ secrets.P12_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: |
CERTIFICATE_PATH=$RUNNER_TEMP/build_certificate.p12
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
echo -n "$BUILD_CERTIFICATE_BASE64" | base64 --decode > $CERTIFICATE_PATH
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
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 list-keychain -d user -s $KEYCHAIN_PATH
# Build commands
- name: Create Windows Builds
if: ${{ matrix.os == 'windows-latest' }}
run: npm run build:win
- name: Create macOS Builds
if: ${{ matrix.os == 'macos-latest' }}
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: npm run build:mac
- name: Create Linux Builds
if: ${{ matrix.os == 'ubuntu-latest' }}
run: npm run build:linux
- name: Find and Rename Windows Executable
if: ${{ matrix.os == 'windows-latest' }}
shell: pwsh
run: |
$exePath = Get-ChildItem -Path dist -Recurse -Filter "*.exe" | Select-Object -First 1
if (-not $exePath) { throw "Error: No .exe file was found in dist."; }
Write-Host "The found executable is: $($exePath.FullName)"
$destinationPath = "${{ matrix.os }}-${{ matrix.arch }}.exe"
Copy-Item -Path $exePath.FullName -Destination $destinationPath
Write-Host "Copied executable to: $destinationPath"
- name: Find and Rename macOS Package
if: ${{ matrix.os == 'macos-latest' }}
run: |
if [ -d "dist" ]; then
package_file=$(find dist -maxdepth 1 -name "*.dmg" -o -name "*.zip" -o -name "*.pkg" | head -1)
if [ -n "$package_file" ]; then
extension="${package_file##*.}"
cp "$package_file" "${{ matrix.os }}-${{ matrix.arch }}.$extension"
echo "Copied package to: ${{ matrix.os }}-${{ matrix.arch }}.$extension"
else
echo "No macOS package found in dist"
ls -la dist/ || echo "dist directory not found or empty"
fi
else
echo "dist directory not found"
fi
- name: Find and Rename Linux Package
if: ${{ matrix.os == 'ubuntu-latest' }}
run: |
if [ -d "dist" ]; then
package_file=$(find dist -maxdepth 1 -name "*.deb" -o -name "*.rpm" -o -name "*.AppImage" -o -name "*.tar.gz" | head -1)
if [ -n "$package_file" ]; then
extension="${package_file##*.}"
if [[ "$package_file" == *.tar.gz ]]; then
extension="tar.gz"
fi
cp "$package_file" "${{ matrix.os }}-${{ matrix.arch }}.$extension"
echo "Copied package to: ${{ matrix.os }}-${{ matrix.arch }}.$extension"
else
echo "No Linux package found in dist"
ls -la dist/ || echo "dist directory not found or empty"
fi
else
echo "dist directory not found"
fi
# (Optional Windows Signing step remains)
- name: Azure Trusted Signing (Windows Only)
if: ${{ matrix.os == 'windows-latest' }}
uses: azure/trusted-signing-action@v0.5.1
with:
azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }}
azure-client-id: ${{ secrets.AZURE_CLIENT_ID }}
azure-client-secret: ${{ secrets.AZURE_CLIENT_SECRET }}
endpoint: https://eus.codesigning.azure.net/
trusted-signing-account-name: open-webui
certificate-profile-name: open-webui
files-folder: .
files-folder-filter: exe
- name: List files for debugging
shell: bash
run: |
echo "Files in current directory:"
ls -la
echo "Files in dist directory (if exists):"
ls -la dist/ || echo "dist directory not found"
- name: Upload Artifacts
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.os }}-${{ matrix.arch }}
path: |
${{ matrix.os }}-${{ matrix.arch }}.*
if-no-files-found: warn
release:
needs: build
runs-on: ubuntu-latest
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Get Short SHA
id: slug
run: echo "sha8=$(echo ${GITHUB_SHA} | cut -c1-8)" >> $GITHUB_OUTPUT
- name: Download Artifacts
uses: actions/download-artifact@v4
- name: List downloaded artifacts
run: |
echo "Downloaded artifacts:"
find . -type f -name "*" | grep -E "\.(exe|zip|dmg|pkg|deb|rpm|AppImage|tar\.gz)$" || echo "No package files found"
ls -la
- name: Create Release
id: create_release
uses: softprops/action-gh-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: build-${{ steps.slug.outputs.sha8 }}
name: Build ${{ steps.slug.outputs.sha8 }}
draft: false
prerelease: false
files: |
**/*.zip
**/*.exe
**/*.dmg
**/*.pkg
**/*.deb
**/*.rpm
**/*.AppImage
**/*.tar.gz
+424
View File
@@ -0,0 +1,424 @@
name: Build and Release Electron App (electron-builder)
on:
push:
branches:
- release
jobs:
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
matrix:
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
arch: arm64
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: 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: runner.os == 'Linux' && matrix.arch == 'x64'
continue-on-error: true
run: |
sudo apt-get update
sudo apt-get install -y flatpak flatpak-builder
flatpak remote-add --user --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo
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'
continue-on-error: true
env:
BUILD_CERTIFICATE_BASE64: ${{ secrets.BUILD_CERTIFICATE_BASE64 }}
P12_PASSWORD: ${{ secrets.P12_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: |
if [ -z "$BUILD_CERTIFICATE_BASE64" ]; then
echo "No certificate provided, will build unsigned"
exit 1
fi
CERTIFICATE_PATH=$RUNNER_TEMP/build_certificate.p12
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
echo -n "$BUILD_CERTIFICATE_BASE64" | base64 --decode > $CERTIFICATE_PATH
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
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
# ── 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: 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: npx electron-builder --mac --${{ matrix.arch }} -c.mac.notarize=true --publish never
- 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: |
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 }} --publish never
fi
# ── Windows code signing ──
- name: Azure Trusted Signing (Windows Only)
if: runner.os == 'Windows'
continue-on-error: true
uses: azure/trusted-signing-action@v0.5.1
with:
azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }}
azure-client-id: ${{ secrets.AZURE_CLIENT_ID }}
azure-client-secret: ${{ secrets.AZURE_CLIENT_SECRET }}
endpoint: https://eus.codesigning.azure.net/
trusted-signing-account-name: open-webui
certificate-profile-name: open-webui
files-folder: dist
files-folder-filter: exe
# ── Upload release artifacts ──
- name: Upload Artifacts
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.os }}-${{ matrix.arch }}
path: |
dist/*.exe
dist/*.dmg
dist/*.zip
dist/*.pkg
dist/*.deb
dist/*.rpm
dist/*.AppImage
dist/*.snap
dist/*.flatpak
dist/*.tar.gz
dist/*.blockmap
dist/latest*.yml
if-no-files-found: warn
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
run: |
VERSION=$(node -p "require('./package.json').version")
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Release version: $VERSION"
- name: Extract changelog for this version
id: changelog
run: |
# Extract the section for the current version from CHANGELOG.md
VERSION="${{ steps.version.outputs.version }}"
# Get content between ## [VERSION] and the next ## [ heading
NOTES=$(awk "/^## \[$VERSION\]/{found=1; next} /^## \[/{if(found) exit} found{print}" CHANGELOG.md)
if [ -z "$NOTES" ]; then
NOTES="Release v$VERSION"
fi
# Write to file to preserve multiline content
echo "$NOTES" > /tmp/release_notes.md
echo "Changelog notes:"
cat /tmp/release_notes.md
- name: Download Artifacts
uses: actions/download-artifact@v4
with:
pattern: '*-*'
merge-multiple: false
- name: Install js-yaml for manifest merging
run: npm install --no-save js-yaml
- name: Merge macOS latest-mac.yml (x64 + arm64)
run: |
# 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
uses: softprops/action-gh-release@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: v${{ steps.version.outputs.version }}
name: v${{ steps.version.outputs.version }}
body_path: /tmp/release_notes.md
draft: false
prerelease: false
files: |
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
+8 -101
View File
@@ -1,102 +1,9 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
.DS_Store
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# TypeScript v1 declaration files
typings/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
.env.test
# parcel-bundler cache (https://parceljs.org/)
.cache
# next.js build output
.next
# nuxt.js build output
.nuxt
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# Webpack
.webpack/
# Vite
.vite/
# Electron-Forge
out/
resources/python
resources/python.tar.gz
.webui_secret_key
_old
dist
.vscode
node_modules
dist
out
.DS_Store
.eslintcache
*.log*
*.tsbuildinfo
+1
View File
@@ -0,0 +1 @@
+6
View File
@@ -0,0 +1,6 @@
out
dist
pnpm-lock.yaml
LICENSE.md
tsconfig.json
tsconfig.*.json
+10
View File
@@ -0,0 +1,10 @@
singleQuote: true
semi: false
printWidth: 100
trailingComma: none
plugins:
- prettier-plugin-svelte
overrides:
- files: '*.svelte'
options:
parser: svelte
+211
View File
@@ -0,0 +1,211 @@
# Changelog
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 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.
+4 -4
View File
@@ -1,7 +1,7 @@
# Open WebUI Contributor License Agreement
# Contributor License Agreement
By submitting my contributions ("Contribution") to this project, I grant the project maintainers a perpetual, irrevocable, worldwide, royalty-free, transferable, non-exclusive license to use, reproduce, modify, prepare derivative works of, publicly display, publicly perform, sublicense, and distribute my Contribution, in whole or in part, under any terms and for any purpose, including commercial purposes, both now and in the future. To the fullest extent permitted by law, I waive, or agree not to assert, any moral rights I may have in my Contribution, so that the project maintainers may freely exercise their rights in the Contribution.
By submitting my contributions to this repository in any form, I grant Open WebUI Inc. a perpetual, worldwide, irrevocable, royalty-free license, under copyright and patent, to use, modify, distribute, sublicense, and commercialize my work under any terms they choose, both now and in the future.
Taking part in this process means my work can be seamlessly integrated and combined with others, ensuring longevity and adaptability for everyone who benefits from the project. This collaborative approach strengthens the projects future and helps guarantee that improvements can always be shared and distributed in the most effective way possible.
I represent that my contributions are my original work (or that I have sufficient rights to grant this license) and that I have the authority to enter into this agreement.
My Contribution is provided "as is," without warranties or guarantees of any kind, and I disclaim any liability for any issues or damages arising from its use or incorporation into the project, regardless of the type of legal claim.
**_To the fullest extent permitted by law, my contributions are provided on an “as is” basis, with no warranties or guarantees of any kind, and I disclaim any liability for any issues or damages arising from their use or incorporation into the project, regardless of the type of legal claim._**
+637 -26
View File
@@ -1,50 +1,661 @@
# Open WebUI Sustainable Use License 1.0
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (c) 2025 Timothy Jaeryang Baek (Open WebUI)
All rights reserved.
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
1. Definitions
Preamble
For purposes of this License, "Software" refers to all code, documentation, interfaces, assets, and materials provided in this repository, in whole or in part, whether in source code or object code form, and any modified versions or derivative works thereof. “Licensor” means the entity that provides this License for the Software, as identified in the copyright notice, documentation, or otherwise designated in connection with the Software. "You" refers to any individual or entity exercising rights under this License.
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
2. Acceptance
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
By using, copying, modifying, or distributing the Software, you agree to all terms of this license.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
3. Grant of Rights
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
Subject to the terms and conditions set forth herein, you are hereby granted a non-exclusive, non-transferable, worldwide, royalty-free right to use and modify the Software exclusively for your own personal, non-commercial use, or for activities undertaken solely for your own internal business operations. You may prepare derivative works based upon the Software, provided your use remains strictly within the intended bounds of internal, non-commercial, or personal application.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
Any distribution of the Software or any part thereof, including derivative works, is permitted only if made accessible to others without charge and solely for non-commercial purposes. If you distribute the Software or any derivative works, you must do so only under the terms of this License. You are not permitted to sublicense or relicense the Software or any derivative work under any other license or terms, whether open source or otherwise, except with the prior written authorization of the Licensor.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
Any use, reproduction, modification, distribution, or making available of the Software or derivative works for commercial advantage or monetary compensation, including but not limited to business-to-business sales, resale, sublicensing, or inclusion in paid services, requires the prior written authorization of the Licensor. If you are uncertain whether your use qualifies as non-commercial or internal business use, you must contact the Licensor.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
4. Branding and Attribution
The precise terms and conditions for copying, distribution and
modification follow.
You shall not alter, remove, obscure, replace, or otherwise modify any branding or attribution identifying "Open WebUI," including but not limited to names, logos, graphical marks, or textual identifiers, present in the Software or its interfaces, whether in original or modified form. All such branding and attribution must be retained and presented exactly as included in the Software and in every instance and manner originally provided by the Licensor.
TERMS AND CONDITIONS
Any copy, deployment, distribution, derivative work, or integration of the Software must preserve all original "Open WebUI" branding and attribution, unless prior written permission has been obtained from the Licensor, or where an express waiver or enterprise agreement has been executed by both parties.
0. Definitions.
5. Contributions
"This License" refers to version 3 of the GNU Affero General Public License.
If you submit or propose any contributions (including code, documentation, or other materials) for inclusion in the Software, you hereby agree to and are bound by the Contributor License Agreement as presented in this repositorys documentation at the time of your contribution.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
6. Termination
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
Any violation of the terms and conditions of this License, including but not limited to unauthorized commercial use or modification of branding or attribution, results in the automatic termination of all rights granted to you under this License, effective immediately upon such violation.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
However, if you cure the violation within thirty (30) days of becoming aware of such violation, or within thirty (30) days of receiving written notice from the Licensor describing the breach (whichever is earlier), your license rights under this License will be automatically reinstated as of the cure date. If you fail to cure the violation within that period, your license remains terminated, and you must immediately cease all use, reproduction, modification, and distribution of the Software and any derivative works.
A "covered work" means either the unmodified Program or a work based
on the Program.
For the avoidance of doubt, no rights of use, distribution, or modification exist during any period of breach until cured in accordance with this section.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
7. No Warranty
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, WHETHER EXPRESS, IMPLIED, STATUTORY, OR OTHERWISE, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT. IN NO EVENT SHALL THE Licensor(S), AUTHOR(S), OR CONTRIBUTOR(S) BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT, OR OTHERWISE, ARISING FROM, OUT OF, OR IN CONNECTION WITH THE SOFTWARE OR THE USE, DISTRIBUTION, OR OTHER DEALINGS IN THE SOFTWARE.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
8. Patent Grant
1. Source Code.
The Licensor grants you a license, under any patent claims they can license or become able to license, to make, use, modify, and distribute the Software as permitted under this License. This grant is strictly subject to the limitations and conditions stated herein, and does not extend to any patent claims infringed by your modifications or additions to the Software. If you or your organization make any written claim that the Software infringes or contributes to the infringement of any patent, your rights under this patent license terminate immediately. If your organization makes such a claim, your patent license terminates immediately for all use or work on its behalf.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
9. Miscellaneous
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
This License constitutes the entire agreement between you and the Licensor concerning the Software. Any waiver or amendment of any term or condition of this License shall be effective only if made in writing and signed by the Licensor. If any provision of this License is held to be invalid or unenforceable, the remaining provisions shall remain in full force and effect.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.
+68 -41
View File
@@ -1,53 +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 App** is the upcoming 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)
This project is still in an **experimental phase** and under active development. 🛠️ Expect frequent updates and potential changes as we refine the application.
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]
> **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).
## Features
- **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.
## 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) |
## Project Setup
Internet required on first launch. After that, everything works offline. [All releases →](https://github.com/open-webui/desktop/releases)
### Install
## 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]
> Local models need serious RAM (7B ≈ 8 GB, 13B ≈ 16 GB). Lighter machine? Connect to a remote server instead.
## Privacy
No telemetry. No tracking. No phone-home. Your conversations stay on your machine. Period.
## Community
- 💬 [Discord](https://discord.gg/open-webui) - Come hang out
- 🐛 [Issues](https://github.com/open-webui/desktop/issues) - Report bugs or request features
- 🌐 [Open WebUI](https://github.com/open-webui/open-webui) - The main project
- 📖 [Docs](https://docs.openwebui.com) - Full documentation
## Contributing
```bash
$ npm install
npm install
npm run dev
```
### 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 **Open WebUI Sustainable Use License**. For details, see [LICENSE](LICENSE).
---
## Stay Tuned! 🌟
We're actively developing Open WebUI App. 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>
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 946 KiB

After

Width:  |  Height:  |  Size: 708 KiB

+3 -2
View File
@@ -1,3 +1,4 @@
provider: generic
url: https://example.com/auto-updates
provider: github
owner: open-webui
repo: desktop
updaterCacheDirName: desktop-updater
+76 -42
View File
@@ -1,54 +1,88 @@
appId: com.openwebui.desktop
productName: Open WebUI
directories:
buildResources: build
buildResources: build
files:
- "!**/.vscode/*"
- "!src/*"
- "!electron.vite.config.{js,ts,mjs,cjs}"
- "!svelte.config.mjs"
- "!{.eslintcache,eslint.config.mjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}"
- "!{.env,.env.*,.npmrc,pnpm-lock.yaml}"
- "!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}"
- '!**/.vscode/*'
- '!src/*'
- '!electron.vite.config.{js,ts,mjs,cjs}'
- '!svelte.config.mjs'
- '!{.eslintcache,eslint.config.mjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,README.md}'
- '!{.env,.env.*,.npmrc,pnpm-lock.yaml}'
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
extraResources:
- from: CHANGELOG.md
to: CHANGELOG.md
asarUnpack:
- resources/**
- resources/**
- node_modules/node-pty/**
win:
executableName: open-webui
executableName: open-webui
nsis:
artifactName: ${name}-${version}-setup.${ext}
shortcutName: ${productName}
uninstallDisplayName: ${productName}
createDesktopShortcut: always
artifactName: ${name}-${arch}-setup.${ext}
shortcutName: ${productName}
uninstallDisplayName: ${productName}
createDesktopShortcut: always
mac:
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
target:
- 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.
dmg:
background: build/dmg-background.png
artifactName: ${name}-${version}.${ext}
title: ${productName}
contents:
- x: 225
y: 250
type: file
- x: 400
y: 240
type: link
path: /Applications
background: build/dmg-background.png
artifactName: ${name}-${arch}.${ext}
title: ${productName}
contents:
- x: 225
y: 250
type: file
- x: 400
y: 240
type: link
path: /Applications
linux:
target:
- AppImage
- snap
- deb
maintainer: openwebui.com
category: Utility
target:
- AppImage
- snap
- deb
- flatpak
maintainer: openwebui.com
category: Utility
deb:
artifactName: ${name}_${arch}.${ext}
snap:
artifactName: ${name}_${arch}.${ext}
appImage:
artifactName: ${name}-${version}.${ext}
npmRebuild: false
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}.flatpak
finishArgs:
- --share=ipc
- --socket=x11
- --socket=wayland
- --socket=pulseaudio
- --share=network
- --device=dri
- --filesystem=home
- --talk-name=org.freedesktop.Notifications
- --talk-name=org.freedesktop.portal.Desktop
npmRebuild: true
publish:
provider: generic
url: https://example.com/auto-updates
provider: github
owner: open-webui
repo: desktop
+33 -12
View File
@@ -1,15 +1,36 @@
import { defineConfig, externalizeDepsPlugin } from "electron-vite";
import { svelte } from "@sveltejs/vite-plugin-svelte";
import tailwindcss from "@tailwindcss/vite";
import { resolve } from 'path'
import { defineConfig } from 'electron-vite'
import { svelte } from '@sveltejs/vite-plugin-svelte'
import tailwindcss from '@tailwindcss/vite'
if (process.platform === 'linux') {
process.env.ELECTRON_DISABLE_SANDBOX = '1'
}
export default defineConfig({
main: {
plugins: [externalizeDepsPlugin()],
main: {},
preload: {
build: {
rollupOptions: {
input: {
index: resolve(__dirname, 'src/preload/index.ts'),
'content-preload': resolve(__dirname, 'src/preload/content-preload.ts'),
'spotlight-preload': resolve(__dirname, 'src/preload/spotlight-preload.ts'),
'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')
}
}
},
preload: {
plugins: [externalizeDepsPlugin()],
},
renderer: {
plugins: [svelte(), tailwindcss()],
},
});
plugins: [tailwindcss(), svelte()]
}
})
+2 -2
View File
@@ -1,11 +1,11 @@
import { defineConfig } from 'eslint/config'
import tseslint from '@electron-toolkit/eslint-config-ts'
import eslintConfigPrettier from '@electron-toolkit/eslint-config-prettier'
import eslintPluginSvelte from 'eslint-plugin-svelte'
export default tseslint.config(
export default defineConfig(
{ ignores: ['**/node_modules', '**/dist', '**/out'] },
tseslint.configs.recommended,
indent: "off",
eslintPluginSvelte.configs['flat/recommended'],
{
files: ['**/*.svelte'],
+9420 -9669
View File
File diff suppressed because it is too large Load Diff
+57 -55
View File
@@ -1,57 +1,59 @@
{
"name": "open-webui-desktop",
"version": "0.0.1",
"description": "Open WebUI Desktop",
"main": "./out/main/index.js",
"author": "Timothy Jaeryang Baek (Open WebUI)",
"homepage": "https://openwebui.com",
"scripts": {
"format": "prettier --plugin prettier-plugin-svelte --write .",
"lint": "eslint --cache .",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
"svelte-check": "svelte-check --tsconfig ./tsconfig.json",
"typecheck": "npm run typecheck:node && npm run svelte-check",
"start": "electron-vite preview",
"dev": "electron-vite dev",
"build": "npm run typecheck && electron-vite build",
"postinstall": "electron-builder install-app-deps",
"build:unpack": "npm run build && electron-builder --dir",
"build:win": "npm run build && electron-builder --win",
"build:mac": "npm run build && electron-builder --mac",
"build:linux": "npm run build && electron-builder --linux"
},
"dependencies": {
"@electron-toolkit/preload": "^3.0.2",
"@electron-toolkit/utils": "^4.0.0",
"@tailwindcss/vite": "^4.1.11",
"bits-ui": "^2.8.13",
"dompurify": "^3.2.6",
"electron-log": "^5.4.1",
"electron-updater": "^6.3.9",
"focus-trap": "^7.6.5",
"marked": "^16.1.1",
"svelte-sonner": "^1.0.5",
"tailwindcss": "^4.1.11",
"tar": "^7.4.3",
"tippy.js": "^6.3.7"
},
"devDependencies": {
"@electron-toolkit/eslint-config-prettier": "^3.0.0",
"@electron-toolkit/eslint-config-ts": "^3.0.0",
"@electron-toolkit/tsconfig": "^1.0.1",
"@electron/notarize": "^2.5.0",
"@sveltejs/vite-plugin-svelte": "^6.1.0",
"@types/node": "^22.16.5",
"electron": "^37.2.3",
"electron-builder": "^25.1.8",
"electron-vite": "^4.0.0",
"eslint": "^9.31.0",
"eslint-plugin-svelte": "^3.11.0",
"prettier": "^3.6.2",
"prettier-plugin-svelte": "^3.4.0",
"svelte": "^5.36.10",
"svelte-check": "^4.3.0",
"typescript": "^5.8.3",
"vite": "^7.0.5"
}
"name": "open-webui",
"version": "0.0.20",
"license": "AGPL-3.0",
"description": "Open WebUI Desktop",
"main": "./out/main/index.js",
"author": "Open WebUI Inc. (Timothy Jaeryang Baek)",
"homepage": "https://openwebui.com",
"scripts": {
"format": "prettier --plugin prettier-plugin-svelte --write .",
"lint": "eslint --cache .",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
"svelte-check": "svelte-check --tsconfig ./tsconfig.json",
"typecheck": "npm run typecheck:node && npm run svelte-check",
"start": "electron-vite preview",
"dev": "electron-vite dev",
"build": "npm run typecheck && electron-vite build",
"postinstall": "electron-builder install-app-deps",
"build:unpack": "npm run build && electron-builder --dir",
"build:win": "npm run build && electron-builder --win",
"build:mac": "npm run build && electron-builder --mac",
"build:linux": "npm run build && electron-builder --linux"
},
"dependencies": {
"@electron-toolkit/preload": "^3.0.2",
"@electron-toolkit/utils": "^4.0.0",
"@tailwindcss/vite": "^4.2.1",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"electron-log": "^5.4.3",
"electron-updater": "^6.3.9",
"i18next": "^25.8.18",
"i18next-browser-languagedetector": "^8.2.1",
"i18next-resources-to-backend": "^1.2.1",
"node-pty": "^1.1.0",
"svelte-sonner": "^1.1.0",
"tailwindcss": "^4.2.1",
"tar": "^7.5.11",
"tippy.js": "^6.3.7"
},
"devDependencies": {
"@electron-toolkit/eslint-config-prettier": "^3.0.0",
"@electron-toolkit/eslint-config-ts": "^3.1.0",
"@electron-toolkit/tsconfig": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^6.2.0",
"@types/node": "^22.19.1",
"electron": "^39.2.6",
"electron-builder": "^26.0.12",
"electron-vite": "^5.0.0",
"eslint": "^9.39.1",
"eslint-plugin-svelte": "^3.13.1",
"prettier": "^3.7.4",
"prettier-plugin-svelte": "^3.4.0",
"svelte": "^5.45.6",
"svelte-check": "^4.3.4",
"typescript": "^5.9.3",
"vite": "^7.2.6"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 782 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 432 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

File diff suppressed because it is too large Load Diff
-13
View File
@@ -1,13 +0,0 @@
# environment.yml
channels:
- conda-forge
dependencies:
- python=3.11
- pip
platforms:
- linux-64
- linux-aarch64 # aka arm64, use for Docker on Apple Silicon
- osx-64
- osx-arm64 # For Apple Silicon, e.g. M1/M2
- win-64
# TODO: Add win-arm64 when available
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

+2184 -526
View File
File diff suppressed because it is too large Load Diff
+73
View File
@@ -0,0 +1,73 @@
import { autoUpdater, type UpdateInfo } from 'electron-updater'
import log from 'electron-log'
import { app, BrowserWindow } from 'electron'
let mainWin: BrowserWindow | null = null
const send = (type: string, data?: any): void => {
mainWin?.webContents.send('main:data', { type, data })
}
export function initUpdater(window: BrowserWindow): void {
mainWin = window
autoUpdater.logger = log
autoUpdater.autoDownload = false
autoUpdater.autoInstallOnAppQuit = true
autoUpdater.on('checking-for-update', () => {
send('update:checking')
})
autoUpdater.on('update-available', (info: UpdateInfo) => {
send('update:available', {
version: info.version,
releaseDate: info.releaseDate
})
})
autoUpdater.on('update-not-available', (_info: UpdateInfo) => {
send('update:not-available')
})
autoUpdater.on('download-progress', (progress) => {
send('update:download-progress', {
percent: progress.percent,
bytesPerSecond: progress.bytesPerSecond,
transferred: progress.transferred,
total: progress.total
})
})
autoUpdater.on('update-downloaded', (_info: UpdateInfo) => {
send('update:downloaded')
})
autoUpdater.on('error', (error: Error) => {
send('update:error', { message: error?.message ?? 'Update error' })
})
// Auto-check on launch (silently, only when packaged)
if (app.isPackaged) {
autoUpdater.checkForUpdates().catch((err) => {
log.warn('Auto update check failed:', err)
})
}
}
export async function checkForUpdates(): Promise<void> {
if (!app.isPackaged) {
log.info('Skipping update check — app is not packaged')
send('update:not-available')
return
}
await autoUpdater.checkForUpdates()
}
export async function downloadUpdate(): Promise<void> {
await autoUpdater.downloadUpdate()
}
export function installUpdate(): void {
autoUpdater.quitAndInstall(false, true)
}
+385
View File
@@ -0,0 +1,385 @@
// @ts-nocheck
/**
* Reusable Hugging Face utility module.
* Downloads files from HF repos, manages a local model cache,
* and provides listing/deletion of cached models.
*
* Cache dir: <userData>/models/<repo-slug>/<filename>
*/
import * as fs from 'fs'
import * as path from 'path'
import log from 'electron-log'
import { getInstallDir, downloadFileWithProgress } from './index'
// ─── Types ──────────────────────────────────────────────
export interface HfModel {
repo: string
filename: string
filepath: string
size: number // bytes
downloadedAt: string // ISO date
}
export interface HfDownloadProgress {
percent: number
downloadedBytes: number
totalBytes: number
}
// ─── Paths ──────────────────────────────────────────────
const getHfCacheDir = (): string => {
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
}
const repoSlug = (repo: string): string => repo.replace(/\//g, '--')
const getManifestPath = (): string => path.join(getHfCacheDir(), 'manifest.json')
// ─── Manifest ───────────────────────────────────────────
const readManifest = (): HfModel[] => {
const p = getManifestPath()
if (!fs.existsSync(p)) return []
try {
return JSON.parse(fs.readFileSync(p, 'utf-8'))
} catch {
return []
}
}
const writeManifest = (models: HfModel[]): void => {
fs.writeFileSync(getManifestPath(), JSON.stringify(models, null, 2))
}
// ─── Public API ─────────────────────────────────────────
const activeDownloads = new Map<string, AbortController>()
const downloadKey = (repo: string, filename: string): string => `${repo}/${filename}`
/**
* Cancel a specific download in progress.
* If no repo/filename given, cancels ALL active downloads.
*/
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()
}
}
/**
* List all downloaded models.
*/
export const listModels = (): HfModel[] => {
const manifest = readManifest()
// Filter out entries whose files no longer exist
return manifest.filter((m) => fs.existsSync(m.filepath))
}
/**
* Get the cache directory path (so runtimes can reference it).
*/
export const getModelsDir = (): string => {
const dir = path.join(getInstallDir(), 'models')
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true })
}
return dir
}
/**
* Download a file from a Hugging Face repository.
*
* @param repo - HF repo, e.g. "ggml-org/gemma-3-1b-it-GGUF"
* @param filename - File to download, e.g. "gemma-3-1b-it-Q4_K_M.gguf"
* @param onProgress - Progress callback
* @param token - Optional HF access token for private repos
* @returns Absolute path to the downloaded file
*/
export const downloadModel = async (
repo: string,
filename: string,
onProgress?: (progress: HfDownloadProgress) => void,
token?: string,
expectedSize?: number
): Promise<string> => {
const slug = repoSlug(repo)
const repoDir = path.join(getHfCacheDir(), slug)
if (!fs.existsSync(repoDir)) {
fs.mkdirSync(repoDir, { recursive: true })
}
const destPath = path.join(repoDir, filename)
// Already downloaded?
if (fs.existsSync(destPath)) {
log.info(`[huggingface] Already cached: ${destPath}`)
return destPath
}
// Build download URL
const downloadUrl = `https://huggingface.co/${repo}/resolve/main/${encodeURIComponent(filename)}`
log.info(`[huggingface] Downloading ${repo}/${filename}`)
log.info(`[huggingface] URL: ${downloadUrl}`)
// Download with progress
const headers: Record<string, string> = {}
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
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, {
headers,
redirect: 'follow',
signal
})
if (!response.ok) {
throw new Error(
`Failed to download ${repo}/${filename}: ${response.status} ${response.statusText}`
)
}
const contentLength = parseInt(response.headers.get('content-length') ?? '0', 10)
const totalBytes = contentLength || expectedSize || 0
const reader = response.body?.getReader()
if (!reader) {
throw new Error('Response body is not readable')
}
const tmpPath = destPath + '.tmp'
const writeStream = fs.createWriteStream(tmpPath)
let downloadedBytes = 0
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
writeStream.write(Buffer.from(value))
downloadedBytes += value.byteLength
if (onProgress && totalBytes > 0) {
onProgress({
percent: (downloadedBytes / totalBytes) * 100,
downloadedBytes,
totalBytes
})
}
}
} catch (err) {
writeStream.end()
// Clean up partial download
try { fs.unlinkSync(tmpPath) } catch {}
activeDownloads.delete(downloadKey(repo, filename))
throw err
} finally {
writeStream.end()
await new Promise((resolve) => writeStream.on('finish', resolve))
}
// Rename tmp to final
fs.renameSync(tmpPath, destPath)
activeDownloads.delete(downloadKey(repo, filename))
// Update manifest
const manifest = readManifest()
const existing = manifest.findIndex((m) => m.repo === repo && m.filename === filename)
const entry: HfModel = {
repo,
filename,
filepath: destPath,
size: fs.statSync(destPath).size,
downloadedAt: new Date().toISOString()
}
if (existing >= 0) {
manifest[existing] = entry
} else {
manifest.push(entry)
}
writeManifest(manifest)
log.info(`[huggingface] Downloaded: ${destPath} (${entry.size} bytes)`)
return destPath
}
/**
* Delete a downloaded model.
*/
export const deleteModel = (repo: string, filename: string): boolean => {
const slug = repoSlug(repo)
const filepath = path.join(getHfCacheDir(), slug, filename)
try {
if (fs.existsSync(filepath)) {
fs.unlinkSync(filepath)
}
} catch (e) {
log.error(`[huggingface] Failed to delete ${filepath}:`, e)
return false
}
// Remove from manifest
const manifest = readManifest()
const updated = manifest.filter((m) => !(m.repo === repo && m.filename === filename))
writeManifest(updated)
// Clean up empty repo dir
const repoDir = path.join(getHfCacheDir(), slug)
try {
const remaining = fs.readdirSync(repoDir)
if (remaining.length === 0) {
fs.rmdirSync(repoDir)
}
} catch {}
log.info(`[huggingface] Deleted: ${repo}/${filename}`)
return true
}
/**
* Get info about a specific model.
*/
export const getModelInfo = (repo: string, filename: string): HfModel | null => {
const manifest = readManifest()
return manifest.find((m) => m.repo === repo && m.filename === filename) ?? null
}
// ─── HF API Integration ────────────────────────────────
export interface HfRepoResult {
id: string // e.g. "ggml-org/gemma-3-1b-it-GGUF"
author: string
modelId: string
downloads: number
likes: number
tags: string[]
lastModified: string
}
export interface HfFileInfo {
filename: string
size: number // bytes
lfs?: { size: number }
}
/**
* Search HF for GGUF model repos.
*/
export const searchModels = async (
query: string,
token?: string
): Promise<HfRepoResult[]> => {
const params = new URLSearchParams({
search: query,
filter: 'gguf',
sort: 'downloads',
direction: '-1',
limit: '20'
})
const headers: Record<string, string> = { Accept: 'application/json' }
if (token) headers['Authorization'] = `Bearer ${token}`
const response = await fetch(`https://huggingface.co/api/models?${params}`, { headers })
if (!response.ok) {
throw new Error(`HF search failed: ${response.status} ${response.statusText}`)
}
const data = await response.json()
return data.map((item: any) => ({
id: item.id ?? item.modelId,
author: item.author ?? item.id?.split('/')[0] ?? '',
modelId: item.modelId ?? item.id,
downloads: item.downloads ?? 0,
likes: item.likes ?? 0,
tags: item.tags ?? [],
lastModified: item.lastModified ?? ''
}))
}
/**
* List GGUF files in a HF repo.
*/
export const getRepoFiles = async (
repo: string,
token?: string
): Promise<HfFileInfo[]> => {
const headers: Record<string, string> = { Accept: 'application/json' }
if (token) headers['Authorization'] = `Bearer ${token}`
const response = await fetch(`https://huggingface.co/api/models/${repo}`, { headers })
if (!response.ok) {
throw new Error(`Failed to fetch repo info: ${response.status} ${response.statusText}`)
}
const data = await response.json()
const siblings = data.siblings ?? []
// Filter to only GGUF files
return siblings
.filter((f: any) => f.rfilename?.endsWith('.gguf'))
.map((f: any) => ({
filename: f.rfilename,
size: f.lfs?.size ?? f.size ?? 0
}))
.sort((a: HfFileInfo, b: HfFileInfo) => a.size - b.size)
}
+903 -787
View File
File diff suppressed because it is too large Load Diff
+613
View File
@@ -0,0 +1,613 @@
// @ts-nocheck
import * as fs from 'fs'
import * as path from 'path'
import { execFileSync } from 'child_process'
import * as tar from 'tar'
import * as pty from 'node-pty'
import log from 'electron-log'
import {
getConfig,
setConfig,
getInstallDir,
portInUse,
downloadFileWithProgress
} from './index'
import { getModelsDir } from './huggingface'
import { ServiceLock, isProcessAlive } from './service-lock'
// ─── State ──────────────────────────────────────────────
let ptyProcess: pty.IPty | null = null
let pid: number | null = null
let url: string | null = null
let status: string | null = null // null | setting-up | starting | started | stopped | failed
let logBuffer: string[] = []
const lock = new ServiceLock('llamacpp')
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(getInstallDir(), 'llama.cpp')
const relative = path.relative(cacheBase, binaryPath)
const tag = relative.split(path.sep)[0]
if (tag) version = tag
}
return { url, status, pid, binaryPath, version }
}
export const getLlamaCppPty = (): pty.IPty | null => ptyProcess
export const getLlamaCppLog = (): string[] => logBuffer
// ─── Asset Resolution ───────────────────────────────────
interface ReleaseAsset {
name: string
browser_download_url: string
}
/**
* Detect the best GPU variant for the current platform.
* Returns the variant string (e.g. 'cuda-12.4', 'vulkan', 'rocm', 'cpu').
*/
const detectBestVariant = (): string => {
const platform = process.platform
// macOS: Metal is baked into the macOS binary; no variant choice needed.
if (platform === 'darwin') return 'cpu'
// Check for NVIDIA GPU (CUDA)
try {
execFileSync('nvidia-smi', ['--query-gpu=name', '--format=csv,noheader'], {
timeout: 5000,
stdio: 'pipe'
})
// NVIDIA GPU detected
if (platform === 'win32') return 'cuda-12.4'
// Linux: no CUDA asset currently available, fall through to other checks
} catch {
// nvidia-smi not available or no NVIDIA GPU
}
// Check for Vulkan support
try {
if (platform === 'win32') {
execFileSync('vulkaninfo', ['--summary'], { timeout: 5000, stdio: 'pipe' })
} else {
execFileSync('vulkaninfo', ['--summary'], { timeout: 5000, stdio: 'pipe' })
}
return 'vulkan'
} catch {
// Vulkan not available
}
// Linux: check for ROCm (AMD GPU)
if (platform === 'linux') {
try {
if (fs.existsSync('/opt/rocm') || fs.existsSync('/usr/lib/rocm')) {
return 'rocm'
}
} catch {
// ROCm not available
}
}
return 'cpu'
}
/**
* Resolve the variant — if 'auto' or empty, detect the best one.
*/
const resolveVariant = (variant: string | undefined): string => {
if (!variant || variant === 'auto') {
const detected = detectBestVariant()
log.info(`Auto-detected variant: ${detected}`)
return detected
}
return variant
}
/**
* Determine the correct release asset name for this platform/arch/variant.
*/
const getAssetPattern = (tag: string, variant: string): { pattern: string; isZip: boolean } => {
const platform = process.platform
const arch = process.arch
if (platform === 'darwin') {
const archStr = arch === 'arm64' ? 'arm64' : 'x64'
return { pattern: `llama-${tag}-bin-macos-${archStr}.tar.gz`, isZip: false }
}
if (platform === 'linux') {
const variantMap: Record<string, string> = {
cpu: `llama-${tag}-bin-ubuntu-x64.tar.gz`,
vulkan: `llama-${tag}-bin-ubuntu-vulkan-x64.tar.gz`,
rocm: `llama-${tag}-bin-ubuntu-rocm-7.2-x64.tar.gz`
}
const name = variantMap[variant] ?? variantMap.cpu
return { pattern: name, isZip: false }
}
if (platform === 'win32') {
const archStr = arch === 'arm64' ? 'arm64' : 'x64'
const variantMap: Record<string, string> = {
cpu: `llama-${tag}-bin-win-cpu-${archStr}.zip`,
'cuda-12.4': `llama-${tag}-bin-win-cuda-12.4-x64.zip`,
'cuda-13.1': `llama-${tag}-bin-win-cuda-13.1-x64.zip`,
vulkan: `llama-${tag}-bin-win-vulkan-x64.zip`
}
const name = variantMap[variant] ?? variantMap.cpu
return { pattern: name, isZip: true }
}
return { pattern: `llama-${tag}-bin-ubuntu-x64.tar.gz`, isZip: false }
}
/**
* Find the llama-server binary inside the extracted directory.
*/
const findBinary = (dir: string): string | null => {
const exeName = process.platform === 'win32' ? 'llama-server.exe' : 'llama-server'
const candidates = [
path.join(dir, exeName),
path.join(dir, 'bin', exeName),
path.join(dir, 'build', 'bin', exeName)
]
for (const candidate of candidates) {
if (fs.existsSync(candidate)) return candidate
}
try {
const entries = fs.readdirSync(dir, { withFileTypes: true })
for (const entry of entries) {
if (entry.isDirectory()) {
const nested = path.join(dir, entry.name, exeName)
if (fs.existsSync(nested)) return nested
const nestedBin = path.join(dir, entry.name, 'bin', exeName)
if (fs.existsSync(nestedBin)) return nestedBin
}
}
} catch {}
return null
}
// ─── Setup (Download & Extract) ─────────────────────────
export const setupLlamaCpp = async (
onStatus?: (status: string) => void
): Promise<string> => {
const config = await getConfig()
const llamaConfig = config.llamaCpp ?? {}
const version = llamaConfig.version || 'latest'
const variant = resolveVariant(llamaConfig.variant)
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'
? 'https://api.github.com/repos/ggml-org/llama.cpp/releases/latest'
: `https://api.github.com/repos/ggml-org/llama.cpp/releases/tags/${version}`
let releaseData: any
try {
const response = await fetch(apiUrl, {
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) {
// 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
log.info(`llama.cpp release tag: ${tag}`)
const versionDir = path.join(cacheBase, tag)
if (!fs.existsSync(versionDir)) {
fs.mkdirSync(versionDir, { recursive: true })
}
const existingBinary = findBinary(versionDir)
if (existingBinary) {
log.info(`llama-server binary already exists: ${existingBinary}`)
binaryPath = existingBinary
return existingBinary
}
const { pattern, isZip } = getAssetPattern(tag, variant)
const asset = (releaseData.assets as ReleaseAsset[]).find((a) => a.name === pattern)
if (!asset) {
const available = (releaseData.assets as ReleaseAsset[]).map((a) => a.name).join(', ')
throw new Error(
`No matching asset found for pattern "${pattern}". Available: ${available}`
)
}
log.info(`Downloading asset: ${asset.name}`)
onStatus?.(`Downloading ${asset.name}`)
const downloadPath = path.join(versionDir, asset.name)
if (!fs.existsSync(downloadPath)) {
await downloadFileWithProgress(asset.browser_download_url, downloadPath, (progress) => {
onStatus?.(`Downloading… ${progress.toFixed(0)}%`)
})
}
onStatus?.('Extracting…')
log.info(`Extracting ${downloadPath} to ${versionDir}`)
if (isZip) {
try {
if (process.platform === 'win32') {
execFileSync('powershell', [
'-Command',
`Expand-Archive -Path "${downloadPath}" -DestinationPath "${versionDir}" -Force`
])
} else {
execFileSync('unzip', ['-o', downloadPath, '-d', versionDir])
}
} catch (error) {
throw new Error(`Failed to extract zip: ${error?.message ?? error}`)
}
} else {
await tar.x({ cwd: versionDir, file: downloadPath })
}
try {
fs.unlinkSync(downloadPath)
} catch {}
if (process.platform !== 'win32') {
const binary = findBinary(versionDir)
if (binary) {
try {
fs.chmodSync(binary, 0o755)
} catch {}
}
}
const resultBinary = findBinary(versionDir)
if (!resultBinary) {
throw new Error(`llama-server binary not found after extraction in ${versionDir}`)
}
log.info(`llama-server binary ready: ${resultBinary}`)
binaryPath = resultBinary
onStatus?.('Ready')
return resultBinary
}
export const checkLlamaCppUpdate = async (): Promise<{ currentVersion: string | null; latestVersion: string | null; updateAvailable: boolean }> => {
const currentInfo = getLlamaCppInfo()
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(5000)
})
if (!response.ok) {
throw new Error(`GitHub API returned ${response.status}: ${response.statusText}`)
}
const releaseData = await response.json()
const latestVersion = releaseData.tag_name
const currentVersion = currentInfo.version
if (!currentVersion) {
return { currentVersion: null, latestVersion, updateAvailable: true }
}
return {
currentVersion,
latestVersion,
updateAvailable: currentVersion !== latestVersion
}
} catch (error) {
log.error('Failed to check for llama.cpp updates:', error)
return {
currentVersion: currentInfo.version,
latestVersion: null,
updateAvailable: false
}
}
}
export const updateLlamaCpp = async (
onStatus?: (status: string) => void
): Promise<{ url?: string; status?: string; pid?: number; binaryPath?: string; version?: string | null }> => {
// 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()
// 3. Clear old cache directory (safe — we verified network above)
const currentInfo = getLlamaCppInfo()
if (currentInfo.version) {
const cacheDir = path.join(getInstallDir(), 'llama.cpp', currentInfo.version)
if (fs.existsSync(cacheDir)) {
onStatus?.('Removing old version…')
try {
fs.rmSync(cacheDir, { recursive: true, force: true })
} catch (err) {
log.error(`Failed to remove old llama.cpp cache at ${cacheDir}:`, err)
}
}
}
// 4. Temporarily enforce 'latest' in config so it fetches the newest
const config = await getConfig()
await setConfig({ llamaCpp: { ...config.llamaCpp, version: 'latest' } })
// 5. Download new release
onStatus?.('Downloading update…')
await setupLlamaCpp(onStatus)
return getLlamaCppInfo()
}
// ─── Lifecycle ──────────────────────────────────────────
export const startLlamaCpp = async (
onStatus?: (status: string) => void
): Promise<{ url: string; pid: number }> => {
if (!lock.acquire()) {
return { url, pid }
}
await stopLlamaCpp()
status = 'setting-up'
onStatus?.('Setting up llama.cpp…')
const binary = await setupLlamaCpp(onStatus)
status = 'starting'
onStatus?.('Starting llama-server…')
const config = await getConfig()
const llamaConfig = config.llamaCpp ?? {}
const host = '127.0.0.1'
let desiredPort = llamaConfig.port || 18881
let availablePort = desiredPort
while (await portInUse(availablePort, host)) {
availablePort++
if (availablePort > desiredPort + 100) {
throw new Error('No available port found for llama-server')
}
}
const extraArgs = llamaConfig.extraArgs ?? []
const modelsDir = getModelsDir()
const commandArgs = ['--host', host, '--port', availablePort.toString(), '--models-dir', modelsDir, ...extraArgs]
log.info('Starting llama-server:', binary, commandArgs.join(' '))
let spawned: pty.IPty
try {
spawned = pty.spawn(binary, commandArgs, {
name: 'xterm-256color',
cols: 200,
rows: 50,
env: {
...process.env,
...(config.envVars ?? {})
}
})
} catch (error) {
status = 'failed'
throw new Error(`Failed to spawn llama-server: ${error?.message ?? error}`)
}
const spawnedPid = spawned.pid
logBuffer = []
ptyProcess = spawned
pid = spawnedPid
spawned.onData((data: string) => {
logBuffer.push(data)
log.info(`[llamacpp:${spawnedPid}] ${data.replace(/[\r\n]+/g, ' ').trim()}`)
})
spawned.onExit(({ exitCode, signal }) => {
log.info(`[llamacpp:${spawnedPid}] Exited code=${exitCode} signal=${signal}`)
const exitMsg = `\r\n[Process exited with code ${exitCode}${signal ? ` signal ${signal}` : ''}]\r\n`
logBuffer.push(exitMsg)
ptyProcess = null
pid = null
url = null
status = 'stopped'
})
const serverUrl = `http://${host}:${availablePort}`
const maxAttempts = 30
let ready = false
for (let i = 0; i < maxAttempts; i++) {
await new Promise((r) => setTimeout(r, 1000))
try {
const resp = await fetch(`${serverUrl}/health`, { signal: AbortSignal.timeout(2000) })
if (resp.ok) {
const body = await resp.json()
if (body.status === 'ok' || body.status === 'no slot available') {
ready = true
break
}
}
} catch {
// Not ready yet
}
}
if (!ready) {
log.warn('llama-server did not report healthy within 30s, continuing anyway')
}
url = serverUrl
status = 'started'
log.info(`llama-server started — PID: ${spawnedPid}, URL: ${serverUrl}`)
return { url: serverUrl, pid: spawnedPid }
}
export const stopLlamaCpp = async (): Promise<void> => {
if (ptyProcess) {
try {
ptyProcess.kill()
} catch (e) {
log.warn('Failed to kill llama-server PTY:', e)
}
await new Promise((r) => setTimeout(r, 2000))
if (pid) {
try {
process.kill(pid, 0)
process.kill(pid, 'SIGKILL')
} catch {
// already dead
}
}
}
ptyProcess = null
pid = null
url = null
status = null
logBuffer = []
lock.release()
}
/**
* Validate whether the tracked llama.cpp process is still alive.
* Used for crash recovery on app startup.
*/
export const validateLlamaCppProcess = (): boolean => {
if (!pid) return false
if (isProcessAlive(pid)) return true
// Stale PID — clean up
pid = null
status = null
lock.release()
return false
}
/**
* Uninstall llama.cpp — stop the server and remove all downloaded binaries.
*/
export const uninstallLlamaCpp = async (): Promise<void> => {
await stopLlamaCpp()
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)
}
binaryPath = null
}
+204
View File
@@ -0,0 +1,204 @@
// @ts-nocheck
import crypto from 'crypto'
import log from 'electron-log'
import * as pty from 'node-pty'
import {
getPythonPath,
getConfig,
setConfig,
installPackage,
isPackageInstalled,
isPythonInstalled,
installPython,
portInUse
} from './index'
import { ServiceLock, isProcessAlive } from './service-lock'
// ─── State ──────────────────────────────────────────────
let ptyProcess: pty.IPty | null = null
let pid: number | null = null
let url: string | null = null
let apiKey: string | null = null
let status: string | null = null // null | starting | started | stopped | failed
let logBuffer: string[] = []
const lock = new ServiceLock('open-terminal')
// ─── Public API ─────────────────────────────────────────
export const getOpenTerminalInfo = () => ({
url,
apiKey,
status,
pid
})
export const getOpenTerminalPty = (): pty.IPty | null => ptyProcess
export const getOpenTerminalLog = (): string[] => logBuffer
export const startOpenTerminal = async (
port: number | null = null,
onStatus?: (status: string) => void
): Promise<{ url: string; apiKey: string; pid: number }> => {
if (!lock.acquire()) {
return { url, apiKey, pid }
}
await stopOpenTerminal()
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, 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()
const host = '127.0.0.1'
const config = await getConfig()
const configEnvVars = config.envVars ?? {}
// 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
let availablePort = desiredPort
while (await portInUse(availablePort, host)) {
availablePort++
if (availablePort > desiredPort + 100) {
throw new Error('No available port found for Open Terminal')
}
}
const cwd = config.openTerminal?.cwd || require('os').homedir()
const commandArgs = [
'-m', 'uv', 'run', 'open-terminal', 'run',
'--host', host,
'--port', availablePort.toString(),
'--api-key', generatedKey,
'--cwd', cwd
]
log.info('Starting Open Terminal...', pythonPath, commandArgs.join(' '))
let spawned: pty.IPty
try {
spawned = pty.spawn(pythonPath, commandArgs, {
name: 'xterm-256color',
cols: 200,
rows: 50,
env: {
...process.env,
...(configEnvVars ?? {}),
PYTHONUNBUFFERED: '1',
...(process.platform === 'win32' ? { PYTHONIOENCODING: 'utf-8' } : {})
}
})
} catch (error) {
throw new Error(
`Failed to spawn Open Terminal: ${error?.message ?? error}`
)
}
const spawnedPid = spawned.pid
logBuffer = []
ptyProcess = spawned
pid = spawnedPid
apiKey = generatedKey
status = 'starting'
spawned.onData((data: string) => {
logBuffer.push(data)
log.info(`[OpenTerminal:${spawnedPid}] ${data.replace(/[\r\n]+/g, ' ').trim()}`)
})
spawned.onExit(({ exitCode, signal }) => {
log.info(`[OpenTerminal:${spawnedPid}] Exited code=${exitCode} signal=${signal}`)
ptyProcess = null
pid = null
url = null
apiKey = null
status = 'stopped'
})
const serverUrl = `http://${host}:${availablePort}`
url = serverUrl
status = 'started'
log.info(`Open Terminal started — PID: ${spawnedPid}, URL: ${serverUrl}`)
return { url: serverUrl, apiKey: generatedKey, pid: spawnedPid }
}
export const stopOpenTerminal = async (): Promise<void> => {
if (ptyProcess) {
try {
ptyProcess.kill()
} catch (e) {
log.warn('Failed to kill Open Terminal PTY:', e)
}
// Give it a moment to exit
await new Promise((r) => setTimeout(r, 1000))
// Force kill if still running
if (pid) {
try {
process.kill(pid, 0) // check alive
process.kill(pid, 'SIGKILL')
} catch {
// already dead
}
}
}
ptyProcess = null
pid = null
url = null
apiKey = null
status = null
logBuffer = []
lock.release()
}
/**
* Validate whether the tracked Open Terminal process is still alive.
*/
export const validateOpenTerminalProcess = (): boolean => {
if (!pid) return false
if (isProcessAlive(pid)) return true
pid = null
status = null
lock.release()
return false
}
+67
View File
@@ -0,0 +1,67 @@
// @ts-nocheck
/**
* ServiceLock — reusable singleton lock for managed child processes.
*
* In Node.js, the synchronous check-and-set before any `await` is atomic
* (event loop guarantees no interleaving). This class makes that pattern
* explicit and self-documenting.
*
* Usage:
* const lock = new ServiceLock('my-service')
* if (!lock.acquire()) return existingResult
* try { ... } catch { lock.release() }
* // release in stop(), not in start()
*/
import log from 'electron-log'
export class ServiceLock {
private locked = false
private name: string
constructor(name: string) {
this.name = name
}
/**
* Try to acquire the lock. Returns false if already locked.
* This is synchronous — no interleaving possible in Node.js event loop.
*/
acquire(): boolean {
if (this.locked) {
log.info(`[${this.name}] Lock held — rejecting duplicate start`)
return false
}
this.locked = true
return true
}
/**
* Release the lock. Called in stop() or on failure.
*/
release(): void {
this.locked = false
}
/**
* Check if the lock is currently held.
*/
isLocked(): boolean {
return this.locked
}
}
/**
* Validate whether a PID is still alive.
* Returns true if the process exists, false if it's gone.
*/
export const isProcessAlive = (pid: number | null): boolean => {
if (!pid) return false
try {
process.kill(pid, 0) // signal 0 = existence check, no actual signal sent
return true
} catch {
return false
}
}
+52
View File
@@ -0,0 +1,52 @@
import { ipcRenderer, contextBridge } from 'electron'
// ─── Desktop ↔ Open WebUI Generic Protocol ──────────────
// This preload is a dumb relay. It passes typed {type, data}
// messages between the embedder (desktop renderer) and the
// Open WebUI page. Business logic lives elsewhere.
// To add new features, just add new event types — this file
// never needs to change.
type EventCallback = (data: any) => void
const eventCallbacks: EventCallback[] = []
// Embedder → Guest (push events from desktop)
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
onEvent: (callback: EventCallback): void => {
eventCallbacks.push(callback)
},
// Request/Response: Open WebUI → desktop
send: (data: any): Promise<any> => {
return new Promise((resolve) => {
const id = Math.random().toString(36).slice(2)
const handler = (_event: any, response: any) => {
if (response?._responseId === id) {
ipcRenderer.removeListener('desktop:response', handler)
resolve(response.data)
}
}
ipcRenderer.on('desktop:response', handler)
ipcRenderer.sendToHost('webview:send', { ...data, _requestId: id })
})
},
// Navigation: Open WebUI → desktop
load: (page: string): void => {
ipcRenderer.sendToHost('webview:load', page)
}
})
+190 -192
View File
@@ -1,205 +1,203 @@
import { ipcRenderer, contextBridge } from "electron";
import { electronAPI } from "@electron-toolkit/preload";
import { ipcRenderer, contextBridge } from 'electron'
import { electronAPI } from '@electron-toolkit/preload'
const isLocalSource = () => {
// Check if the execution environment is local
const origin = window.location.origin;
// ─── PTY MessagePort ────────────────────────────────────
// 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
// Allow local sources: file protocol, localhost, or 0.0.0.0
return (
origin.startsWith("file://") ||
origin.includes("localhost") ||
origin.includes("127.0.0.1") ||
origin.includes("0.0.0.0")
);
};
ipcRenderer.on('pty:port', (event, _data) => {
const [port] = event.ports
if (!port) return
if (activePtyPort) activePtyPort.close()
activePtyPort = port
port.onmessage = (ev: MessageEvent) => {
if (ev.data?.type === 'output' && ptyOutputCallback) ptyOutputCallback(ev.data.data)
}
port.start()
})
window.addEventListener("DOMContentLoaded", () => {
// Listen for messages from the main process
ipcRenderer.on("main:data", (event, data) => {
// Forward the message to the renderer using window.postMessage
window.postMessage(
{
...data,
type: `electron:${data.type}`,
},
window.location.origin
);
});
});
// ─── Open Terminal PTY MessagePort ──────────────────────
let activeOtPtyPort: MessagePort | null = null
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()
activeOtPtyPort = port
port.onmessage = (ev: MessageEvent) => {
if (ev.data?.type === 'output' && otPtyOutputCallback) otPtyOutputCallback(ev.data.data)
}
port.start()
})
// ─── 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()
activeLsCppPtyPort = port
port.onmessage = (ev: MessageEvent) => {
if (ev.data?.type === 'output' && lsCppPtyOutputCallback) lsCppPtyOutputCallback(ev.data.data)
}
port.start()
})
// Custom APIs for renderer
const api = {
onLog: (callback: (message: string) => void) => {
if (!isLocalSource()) {
throw new Error(
"Access restricted: This operation is only allowed in a local environment."
);
}
onData: (callback: (data: any) => void) => {
const handler = (_: any, data: any): void => callback(data)
ipcRenderer.on('main:data', handler)
return () => ipcRenderer.removeListener('main:data', handler)
},
ipcRenderer.on("main:log", (_, message: string) => callback(message));
},
// App
getAppInfo: () => ipcRenderer.invoke('app:info'),
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'),
setLaunchAtLogin: (enabled: boolean) => ipcRenderer.invoke('app:launchAtLogin:set', enabled),
openInBrowser: (url: string) => ipcRenderer.invoke('open:browser', { url }),
openPath: (folderPath: string) => ipcRenderer.invoke('open:path', folderPath),
notification: (title: string, body: string) =>
ipcRenderer.invoke('notification', { title, body }),
send: async ({ type, data }: { type: string; data?: any }) => {
return await ipcRenderer.invoke("renderer:data", { type, data });
},
// Config
getConfig: () => ipcRenderer.invoke('get:config'),
setConfig: (config: Record<string, any>) => ipcRenderer.invoke('set:config', config),
openInBrowser: async (url: string) => {
if (!isLocalSource()) {
throw new Error(
"Access restricted: This operation is only allowed in a local environment."
);
}
// Python/uv
installPython: () => ipcRenderer.invoke('install:python'),
getPythonStatus: () => ipcRenderer.invoke('status:python'),
await ipcRenderer.invoke("open:browser", { url });
},
// Package
installPackage: () => ipcRenderer.invoke('install:package'),
getPackageStatus: () => ipcRenderer.invoke('status:package'),
getVersion: async () => {
if (!isLocalSource()) {
throw new Error(
"Access restricted: This operation is only allowed in a local environment."
);
}
// Server
startServer: () => ipcRenderer.invoke('server:start'),
stopServer: () => ipcRenderer.invoke('server:stop'),
restartServer: () => ipcRenderer.invoke('server:restart'),
getServerInfo: () => ipcRenderer.invoke('server:info'),
getServerLogs: () => ipcRenderer.invoke('server:logs'),
clearServerLogs: () => ipcRenderer.invoke('server:logs:clear'),
return await ipcRenderer.invoke("get:version");
},
getConfig: async () => {
if (!isLocalSource()) {
throw new Error(
"Access restricted: This operation is only allowed in a local environment."
);
}
return await ipcRenderer.invoke("get:config");
},
setConfig: async (config: Record<string, any>) => {
if (!isLocalSource()) {
throw new Error(
"Access restricted: This operation is only allowed in a local environment."
);
}
return await ipcRenderer.invoke("set:config", config);
},
installPython: async () => {
if (!isLocalSource()) {
throw new Error(
"Access restricted: This operation is only allowed in a local environment."
);
}
return await ipcRenderer.invoke("install:python");
},
installPackage: async () => {
if (!isLocalSource()) {
throw new Error(
"Access restricted: This operation is only allowed in a local environment."
);
}
return await ipcRenderer.invoke("install:package");
},
getPythonStatus: async () => {
return await ipcRenderer.invoke("status:python");
},
getPackageStatus: async () => {
return await ipcRenderer.invoke("status:package");
},
getServerStatus: async () => {
if (!isLocalSource()) {
throw new Error(
"Access restricted: This operation is only allowed in a local environment."
);
}
return await ipcRenderer.invoke("status:server");
},
getServerInfo: async () => {
if (!isLocalSource()) {
throw new Error(
"Access restricted: This operation is only allowed in a local environment."
);
}
return await ipcRenderer.invoke("server:info");
},
resetApp: async () => {
if (!isLocalSource()) {
throw new Error(
"Access restricted: This operation is only allowed in a local environment."
);
}
return await ipcRenderer.invoke("app:reset");
},
startServer: async () => {
if (!isLocalSource()) {
throw new Error(
"Access restricted: This operation is only allowed in a local environment."
);
}
return await ipcRenderer.invoke("server:start");
},
stopServer: async () => {
if (!isLocalSource()) {
throw new Error(
"Access restricted: This operation is only allowed in a local environment."
);
}
return await ipcRenderer.invoke("server:stop");
},
restartServer: async () => {
if (!isLocalSource()) {
throw new Error(
"Access restricted: This operation is only allowed in a local environment."
);
}
return await ipcRenderer.invoke("server:restart");
},
getServerUrl: async () => {
return await ipcRenderer.invoke("server:url");
},
notification: async (title: string, body: string) => {
if (!isLocalSource()) {
throw new Error(
"Access restricted: This operation is only allowed in a local environment."
);
}
return await ipcRenderer.invoke("notification", { title, body });
},
};
// Use `contextBridge` APIs to expose Electron APIs to
// renderer only if context isolation is enabled, otherwise
// just add to the DOM global.
if (process.contextIsolated) {
try {
contextBridge.exposeInMainWorld("electron", electronAPI);
contextBridge.exposeInMainWorld("electronAPI", api);
} catch (error) {
console.error(error);
// PTY — MessagePort stays in preload, renderer uses these functions
listPtys: () => ipcRenderer.invoke('pty:list'),
connectPty: (onOutput: (data: string) => void, pid?: number) => {
ptyOutputCallback = onOutput
ipcRenderer.invoke('pty:connect', pid)
},
writePty: (data: string) => {
activePtyPort?.postMessage({ type: 'input', data })
},
resizePty: (cols: number, rows: number) => {
activePtyPort?.postMessage({ type: 'resize', cols, rows })
},
disconnectPty: () => {
ptyOutputCallback = null
if (activePtyPort) {
activePtyPort.close()
activePtyPort = null
}
} else {
// @ts-ignore (define in dts)
window.electron = electronAPI;
// @ts-ignore (define in dts)
window.electronAPI = api;
},
// Open Terminal
startOpenTerminal: () => ipcRenderer.invoke('open-terminal:start'),
stopOpenTerminal: () => ipcRenderer.invoke('open-terminal:stop'),
getOpenTerminalInfo: () => ipcRenderer.invoke('open-terminal:info'),
getOpenTerminalStatus: () => ipcRenderer.invoke('open-terminal:status'),
connectOpenTerminalPty: (onOutput: (data: string) => void) => {
otPtyOutputCallback = onOutput
ipcRenderer.invoke('open-terminal:pty:connect')
},
disconnectOpenTerminalPty: () => {
otPtyOutputCallback = null
if (activeOtPtyPort) {
activeOtPtyPort.close()
activeOtPtyPort = null
}
},
// llama.cpp
setupLlamaCpp: () => ipcRenderer.invoke('llamacpp:setup'),
startLlamaCpp: () => ipcRenderer.invoke('llamacpp:start'),
stopLlamaCpp: () => ipcRenderer.invoke('llamacpp:stop'),
getLlamaCppInfo: () => ipcRenderer.invoke('llamacpp:info'),
getLlamaCppLogs: () => ipcRenderer.invoke('llamacpp:logs'),
connectLlamaCppPty: (onOutput: (data: string) => void) => {
lsCppPtyOutputCallback = onOutput
ipcRenderer.invoke('llamacpp:pty:connect')
},
disconnectLlamaCppPty: () => {
lsCppPtyOutputCallback = null
if (activeLsCppPtyPort) {
activeLsCppPtyPort.close()
activeLsCppPtyPort = null
}
},
checkLlamaCppUpdate: () => ipcRenderer.invoke('llamacpp:check-update'),
updateLlamaCpp: () => ipcRenderer.invoke('llamacpp:update'),
uninstallLlamaCpp: () => ipcRenderer.invoke('llamacpp:uninstall'),
// Hugging Face models
listHfModels: () => ipcRenderer.invoke('huggingface:models:list'),
getHfModelsDir: () => ipcRenderer.invoke('huggingface:models:dir'),
downloadHfModel: (repo: string, filename: string, token?: string, expectedSize?: number) =>
ipcRenderer.invoke('huggingface:models:download', repo, filename, token, expectedSize),
deleteHfModel: (repo: string, filename: string) =>
ipcRenderer.invoke('huggingface:models:delete', repo, filename),
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) =>
ipcRenderer.invoke('huggingface:repo:files', repo, token),
// Package
getPackageVersion: (packageName: string) => ipcRenderer.invoke('package:version', packageName),
uninstallPackage: (packageName: string) => ipcRenderer.invoke('package:uninstall', packageName),
// Connections
getConnections: () => ipcRenderer.invoke('connections:list'),
addConnection: (connection: any) => ipcRenderer.invoke('connections:add', connection),
removeConnection: (id: string) => ipcRenderer.invoke('connections:remove', id),
updateConnection: (id: string, updates: any) => ipcRenderer.invoke('connections:update', id, updates),
setDefaultConnection: (id: string) => ipcRenderer.invoke('connections:setDefault', id),
connectTo: (id: string) => ipcRenderer.invoke('connections:connect', id),
validateUrl: (url: string) => ipcRenderer.invoke('validate:url', url),
selectFolder: () => ipcRenderer.invoke('dialog:selectFolder'),
// Updater
checkForUpdates: () => ipcRenderer.invoke('updater:check'),
downloadUpdate: () => ipcRenderer.invoke('updater:download'),
installUpdate: () => ipcRenderer.invoke('updater:install'),
// Changelog
getChangelog: () => ipcRenderer.invoke('app:changelog'),
// Auth token relay from webview
setAuthToken: (token: string) => ipcRenderer.invoke('app:setAuthToken', token)
}
if (process.contextIsolated) {
try {
contextBridge.exposeInMainWorld('electron', electronAPI)
contextBridge.exposeInMainWorld('electronAPI', api)
} catch (error) {
console.error(error)
}
} else {
// @ts-ignore
window.electron = electronAPI
// @ts-ignore
window.electronAPI = api
}
+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
@@ -6,7 +6,7 @@
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
<meta
http-equiv="Content-Security-Policy"
content="script-src 'self'; style-src 'self' 'unsafe-inline';"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data:; media-src 'self' https://community.s3.openwebui.com; connect-src 'self' https://community.s3.openwebui.com https://fonts.googleapis.com https://fonts.gstatic.com"
/>
</head>
+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>
+56 -57
View File
@@ -1,70 +1,69 @@
<script lang="ts">
import { toast, Toaster } from "svelte-sonner";
import { onMount } from "svelte";
import { onMount, onDestroy } from 'svelte'
import { fade } from 'svelte/transition'
import { appInfo, config, connections, serverInfo, appState } from './lib/stores'
import Controls from "./lib/components/Controls.svelte";
import Installation from "./lib/components/Installation.svelte";
import Loading from "./lib/components/Loading.svelte";
import Main from './lib/components/Main.svelte'
import { info, config } from "./lib/stores";
let themeMediaQuery: MediaQueryList
let themeChangeHandler: ((e: MediaQueryListEvent) => void) | null = null
let installed = $state(false);
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 () => {
config.set(await window?.electronAPI?.getConfig());
onMount(async () => {
const api = window?.electronAPI
if (!api) return
const pythonStatus = await window?.electronAPI?.getPythonStatus();
if (pythonStatus) {
const packageStatus = await window?.electronAPI?.getPackageStatus();
if (packageStatus) {
installed = true;
} else {
installed = false;
}
}
appInfo.set(await api.getAppInfo())
config.set(await api.getConfig())
connections.set(await api.getConnections())
window.addEventListener("message", async (event) => {
console.log("Received message from main process:", event);
if (event.data?.type === "electron:notification") {
if (event.data?.data?.type) {
toast(event.data.data.message, {
type: event.data?.data?.type,
});
} else {
toast(event.data?.data.message);
}
}
// Apply saved theme
const savedTheme = (await api.getConfig())?.theme ?? 'system'
applyResolvedTheme(savedTheme)
if (event.data?.type === "electron:reload") {
window.location.reload();
}
// 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')
}
}
themeMediaQuery.addEventListener('change', themeChangeHandler)
if (event.data?.type === "electron:server") {
info.set(await window.electronAPI.getServerInfo());
}
});
api.onData((data: any) => {
if (data.type === 'status:server') {
serverInfo.update((info) => ({ ...info, status: data.data }))
}
if (data.type === 'server:ready') {
serverInfo.update((info) => ({ ...info, reachable: true, url: data.data?.url }))
}
})
info.set(await window.electronAPI.getServerInfo());
setInterval(async () => {
info.set(await window.electronAPI.getServerInfo());
}, 1000);
});
// 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-screen h-screen bg-gray-900">
{#if installed === null}
<Loading />
{:else if installed === false}
<Installation bind:installed />
{:else}
<Controls bind:installed />
{/if}
<main class="w-full h-full bg-[#f5f5f7] dark:bg-[#0a0a0a]">
<Main />
</main>
<Toaster
theme={window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"}
richColors
position="top-center"
/>
+31 -130
View File
@@ -1,13 +1,7 @@
@import "tailwindcss";
/*
The default border color has changed to `currentColor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
@variant dark (&:where(.dark, .dark *));
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
@@ -19,73 +13,17 @@
}
@layer base {
html,
pre {
font-family:
-apple-system, BlinkMacSystemFont, "Inter", "Vazirmatn",
ui-sans-serif, system-ui, "Segoe UI", Roboto, Ubuntu, Cantarell,
"Noto Sans", sans-serif, "Helvetica Neue", Arial,
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
"Noto Color Emoji";
}
pre {
white-space: pre-wrap;
}
button {
@apply cursor-pointer;
}
input::placeholder,
textarea::placeholder {
color: theme(--color-gray-400);
}
input[type="checkbox"] {
@apply appearance-none size-3.5 align-middle bg-white border border-gray-300 rounded transition cursor-pointer focus:ring-2 focus:ring-blue-500 focus:outline-none dark:bg-gray-800 dark:border-gray-600 self-center;
/* Center the custom mark */
display: inline-block;
position: relative;
}
input[type="checkbox"]:checked {
@apply bg-blue-600 border-blue-600;
}
input[type="checkbox"]:after {
content: "";
display: block;
width: 100%;
height: 100%;
/* Hide by default */
opacity: 0;
transition: opacity 0.2s;
position: absolute;
top: 0;
left: 0;
pointer-events: none;
/* SVG checkmark as background image */
background: url('data:image/svg+xml;utf8,<svg viewBox="0 0 16 16" fill="none" stroke="white" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><path d="M4 8l3 3l5-5"/></svg>')
center/80% no-repeat;
}
input[type="checkbox"]:checked:after {
opacity: 1;
}
}
@custom-variant hover (&:hover);
@font-face {
font-family: "Archivo";
src: url("./lib/assets/fonts/Archivo-Variable.ttf");
font-display: swap;
}
@font-face {
font-family: "InstrumentSerif";
src: url("./lib/assets/fonts/InstrumentSerif-Regular.ttf");
font-display: swap;
}
.drag-region {
-webkit-app-region: drag;
}
@@ -95,35 +33,10 @@
-webkit-app-region: no-drag;
}
.no-drag-region {
.no-drag {
-webkit-app-region: no-drag;
}
.font-secondary {
font-family: "InstrumentSerif", sans-serif;
}
.font-system {
font-family:
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
Roboto,
"Helvetica Neue",
Arial,
"Noto Sans",
sans-serif,
"Apple Color Emoji",
"Segoe UI Emoji",
"Segoe UI Symbol",
"Noto Color Emoji";
}
html {
font-family: "Archivo";
}
@theme {
--color-white: #fff;
--color-black: #000;
@@ -141,60 +54,48 @@ html {
--color-gray-950: #0d0d0d;
}
.tippy-box[data-theme~="dark"] {
@apply rounded-lg bg-gray-950 text-xs border border-gray-900 shadow-xl;
html {
font-family: "Archivo", -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
background: #f5f5f7;
color: #1d1d1f;
font-size: 13px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
user-select: none;
}
.tippy-box[data-theme~="transparent"] {
@apply bg-transparent p-0 m-0;
html.dark {
background: #0a0a0a;
color: #fafafa;
}
.scrollbar-hidden:active::-webkit-scrollbar-thumb,
.scrollbar-hidden:focus::-webkit-scrollbar-thumb,
.scrollbar-hidden:hover::-webkit-scrollbar-thumb {
visibility: visible;
}
.scrollbar-hidden::-webkit-scrollbar-thumb {
visibility: hidden;
body {
height: 100%;
width: 100%;
overflow: hidden;
}
.scrollbar-hidden::-webkit-scrollbar-corner {
display: none;
#app {
height: 100vh;
}
.scrollbar-none::-webkit-scrollbar {
display: none; /* for Chrome, Safari and Opera */
}
.scrollbar-none::-webkit-scrollbar-corner {
display: none;
}
.scrollbar-none {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
::selection {
background: rgba(0, 0, 0, 0.12);
}
.dark ::selection {
background: rgba(255, 255, 255, 0.15);
}
::-webkit-scrollbar { width: 4px; }
::-webkit-scrollbar-thumb {
--tw-border-opacity: 1;
background-color: rgba(215, 215, 215, 0.8);
border-color: rgba(255, 255, 255, var(--tw-border-opacity));
border-radius: 9999px;
border-width: 1px;
background: rgba(0, 0, 0, 0.08);
border-radius: 99px;
}
/* Dark theme scrollbar styles */
.dark ::-webkit-scrollbar-thumb {
background-color: rgba(67, 67, 67, 0.8); /* Darker color for dark theme */
border-color: rgba(0, 0, 0, var(--tw-border-opacity));
background: rgba(255, 255, 255, 0.08);
}
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar {
height: 0.6rem;
width: 0.4rem;
}
::-webkit-scrollbar-track {
background-color: transparent;
border-radius: 9999px;
.xterm-screen {
height: 100% !important;
}
+79
View File
@@ -0,0 +1,79 @@
:root {
--ev-c-white: #ffffff;
--ev-c-white-soft: #f8f8f8;
--ev-c-white-mute: #f2f2f2;
--ev-c-black: #1b1b1f;
--ev-c-black-soft: #222222;
--ev-c-black-mute: #282828;
--ev-c-gray-1: #515c67;
--ev-c-gray-2: #414853;
--ev-c-gray-3: #32363f;
--ev-c-text-1: rgba(255, 255, 245, 0.86);
--ev-c-text-2: rgba(235, 235, 245, 0.6);
--ev-c-text-3: rgba(235, 235, 245, 0.38);
--ev-button-alt-border: transparent;
--ev-button-alt-text: var(--ev-c-text-1);
--ev-button-alt-bg: var(--ev-c-gray-3);
--ev-button-alt-hover-border: transparent;
--ev-button-alt-hover-text: var(--ev-c-text-1);
--ev-button-alt-hover-bg: var(--ev-c-gray-2);
}
:root {
--color-background: var(--ev-c-black);
--color-background-soft: var(--ev-c-black-soft);
--color-background-mute: var(--ev-c-black-mute);
--color-text: var(--ev-c-text-1);
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
font-weight: normal;
}
ul {
list-style: none;
}
body {
min-height: 100vh;
color: var(--color-text);
background: var(--color-background);
line-height: 1.6;
font-family:
Inter,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
Oxygen,
Ubuntu,
Cantarell,
'Fira Sans',
'Droid Sans',
'Helvetica Neue',
sans-serif;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Light theme overrides */
html.light {
--color-background: #f5f5f7;
--color-background-soft: #e8e8ed;
--color-background-mute: #d1d1d6;
--color-text: #1d1d1f;
--ev-c-text-1: rgba(29, 29, 31, 0.9);
--ev-c-text-2: rgba(29, 29, 31, 0.6);
--ev-c-text-3: rgba(29, 29, 31, 0.4);
color-scheme: light;
}
+10
View File
@@ -0,0 +1,10 @@
<svg viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="64" cy="64" r="64" fill="#2F3242"/>
<ellipse cx="63.9835" cy="23.2036" rx="4.48794" ry="4.495" stroke="#A2ECFB" stroke-width="3.6" stroke-linecap="round"/>
<path d="M51.3954 39.5028C52.3733 39.6812 53.3108 39.033 53.4892 38.055C53.6676 37.0771 53.0194 36.1396 52.0414 35.9612L51.3954 39.5028ZM28.6153 43.5751L30.1748 44.4741L30.1748 44.4741L28.6153 43.5751ZM28.9393 60.9358C29.4332 61.7985 30.5329 62.0976 31.3957 61.6037C32.2585 61.1098 32.5575 60.0101 32.0636 59.1473L28.9393 60.9358ZM37.6935 66.7457C37.025 66.01 35.8866 65.9554 35.1508 66.6239C34.415 67.2924 34.3605 68.4308 35.029 69.1666L37.6935 66.7457ZM53.7489 81.7014L52.8478 83.2597L53.7489 81.7014ZM96.9206 89.515C97.7416 88.9544 97.9526 87.8344 97.3919 87.0135C96.8313 86.1925 95.7113 85.9815 94.8904 86.5422L96.9206 89.515ZM52.0414 35.9612C46.4712 34.9451 41.2848 34.8966 36.9738 35.9376C32.6548 36.9806 29.0841 39.1576 27.0559 42.6762L30.1748 44.4741C31.5693 42.0549 34.1448 40.3243 37.8188 39.4371C41.5009 38.5479 46.1547 38.5468 51.3954 39.5028L52.0414 35.9612ZM27.0559 42.6762C24.043 47.9029 25.2781 54.5399 28.9393 60.9358L32.0636 59.1473C28.6579 53.1977 28.1088 48.0581 30.1748 44.4741L27.0559 42.6762ZM35.029 69.1666C39.6385 74.24 45.7158 79.1355 52.8478 83.2597L54.6499 80.1432C47.8081 76.1868 42.0298 71.5185 37.6935 66.7457L35.029 69.1666ZM52.8478 83.2597C61.344 88.1726 70.0465 91.2445 77.7351 92.3608C85.359 93.4677 92.2744 92.6881 96.9206 89.515L94.8904 86.5422C91.3255 88.9767 85.4902 89.849 78.2524 88.7982C71.0793 87.7567 62.809 84.8612 54.6499 80.1432L52.8478 83.2597ZM105.359 84.9077C105.359 81.4337 102.546 78.6127 99.071 78.6127V82.2127C100.553 82.2127 101.759 83.4166 101.759 84.9077H105.359ZM99.071 78.6127C95.5956 78.6127 92.7831 81.4337 92.7831 84.9077H96.3831C96.3831 83.4166 97.5892 82.2127 99.071 82.2127V78.6127ZM92.7831 84.9077C92.7831 88.3817 95.5956 91.2027 99.071 91.2027V87.6027C97.5892 87.6027 96.3831 86.3988 96.3831 84.9077H92.7831ZM99.071 91.2027C102.546 91.2027 105.359 88.3817 105.359 84.9077H101.759C101.759 86.3988 100.553 87.6027 99.071 87.6027V91.2027Z" fill="#A2ECFB"/>
<path d="M91.4873 65.382C90.8456 66.1412 90.9409 67.2769 91.7002 67.9186C92.4594 68.5603 93.5951 68.465 94.2368 67.7058L91.4873 65.382ZM99.3169 43.6354L97.7574 44.5344L99.3169 43.6354ZM84.507 35.2412C83.513 35.2282 82.6967 36.0236 82.6838 37.0176C82.6708 38.0116 83.4661 38.8279 84.4602 38.8409L84.507 35.2412ZM74.9407 39.8801C75.9127 39.6716 76.5315 38.7145 76.323 37.7425C76.1144 36.7706 75.1573 36.1517 74.1854 36.3603L74.9407 39.8801ZM53.7836 46.3728L54.6847 47.931L53.7836 46.3728ZM25.5491 80.9047C25.6932 81.8883 26.6074 82.5688 27.5911 82.4247C28.5747 82.2806 29.2552 81.3664 29.1111 80.3828L25.5491 80.9047ZM94.2368 67.7058C97.8838 63.3907 100.505 58.927 101.752 54.678C103.001 50.4213 102.9 46.2472 100.876 42.7365L97.7574 44.5344C99.1494 46.9491 99.3603 50.0419 98.2974 53.6644C97.2323 57.2945 94.9184 61.3223 91.4873 65.382L94.2368 67.7058ZM100.876 42.7365C97.9119 37.5938 91.7082 35.335 84.507 35.2412L84.4602 38.8409C91.1328 38.9278 95.7262 41.0106 97.7574 44.5344L100.876 42.7365ZM74.1854 36.3603C67.4362 37.8086 60.0878 40.648 52.8826 44.8146L54.6847 47.931C61.5972 43.9338 68.5948 41.2419 74.9407 39.8801L74.1854 36.3603ZM52.8826 44.8146C44.1366 49.872 36.9669 56.0954 32.1491 62.3927C27.3774 68.63 24.7148 75.2115 25.5491 80.9047L29.1111 80.3828C28.4839 76.1026 30.4747 70.5062 35.0084 64.5802C39.496 58.7143 46.2839 52.7889 54.6847 47.931L52.8826 44.8146Z" fill="#A2ECFB"/>
<path d="M49.0825 87.2295C48.7478 86.2934 47.7176 85.8059 46.7816 86.1406C45.8455 86.4753 45.358 87.5055 45.6927 88.4416L49.0825 87.2295ZM78.5635 96.4256C79.075 95.5732 78.7988 94.4675 77.9464 93.9559C77.0941 93.4443 75.9884 93.7205 75.4768 94.5729L78.5635 96.4256ZM79.5703 85.1795C79.2738 86.1284 79.8027 87.1379 80.7516 87.4344C81.7004 87.7308 82.71 87.2019 83.0064 86.2531L79.5703 85.1795ZM84.3832 64.0673H82.5832H84.3832ZM69.156 22.5301C68.2477 22.1261 67.1838 22.535 66.7799 23.4433C66.3759 24.3517 66.7848 25.4155 67.6931 25.8194L69.156 22.5301ZM45.6927 88.4416C47.5994 93.7741 50.1496 98.2905 53.2032 101.505C56.2623 104.724 59.9279 106.731 63.9835 106.731V103.131C61.1984 103.131 58.4165 101.765 55.8131 99.0249C53.2042 96.279 50.8768 92.2477 49.0825 87.2295L45.6927 88.4416ZM63.9835 106.731C69.8694 106.731 74.8921 102.542 78.5635 96.4256L75.4768 94.5729C72.0781 100.235 68.0122 103.131 63.9835 103.131V106.731ZM83.0064 86.2531C85.0269 79.7864 86.1832 72.1831 86.1832 64.0673H82.5832C82.5832 71.8536 81.4723 79.0919 79.5703 85.1795L83.0064 86.2531ZM86.1832 64.0673C86.1832 54.1144 84.4439 44.922 81.4961 37.6502C78.5748 30.4436 74.3436 24.8371 69.156 22.5301L67.6931 25.8194C71.6364 27.5731 75.3846 32.1564 78.1598 39.0026C80.9086 45.7836 82.5832 54.507 82.5832 64.0673H86.1832Z" fill="#A2ECFB"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M103.559 84.9077C103.559 82.4252 101.55 80.4127 99.071 80.4127C96.5924 80.4127 94.5831 82.4252 94.5831 84.9077C94.5831 87.3902 96.5924 89.4027 99.071 89.4027C101.55 89.4027 103.559 87.3902 103.559 84.9077V84.9077Z" stroke="#A2ECFB" stroke-width="3.6" stroke-linecap="round"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M28.8143 89.4027C31.2929 89.4027 33.3023 87.3902 33.3023 84.9077C33.3023 82.4252 31.2929 80.4127 28.8143 80.4127C26.3357 80.4127 24.3264 82.4252 24.3264 84.9077C24.3264 87.3902 26.3357 89.4027 28.8143 89.4027V89.4027V89.4027Z" stroke="#A2ECFB" stroke-width="3.6" stroke-linecap="round"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M64.8501 68.0857C62.6341 68.5652 60.451 67.1547 59.9713 64.9353C59.4934 62.7159 60.9007 60.5293 63.1167 60.0489C65.3326 59.5693 67.5157 60.9798 67.9954 63.1992C68.4742 65.4186 67.066 67.6052 64.8501 68.0857Z" fill="#A2ECFB"/>
</svg>

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.
+171
View File
@@ -0,0 +1,171 @@
@import './base.css';
body {
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
background-image: url('./wavy-lines.svg');
background-size: cover;
user-select: none;
}
code {
font-weight: 600;
padding: 3px 5px;
border-radius: 2px;
background-color: var(--color-background-mute);
font-family:
ui-monospace,
SFMono-Regular,
SF Mono,
Menlo,
Consolas,
Liberation Mono,
monospace;
font-size: 85%;
}
#app {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
margin-bottom: 80px;
}
.logo {
margin-bottom: 20px;
-webkit-user-drag: none;
height: 128px;
width: 128px;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 1.2em #6988e6aa);
}
.creator {
font-size: 14px;
line-height: 16px;
color: var(--ev-c-text-2);
font-weight: 600;
margin-bottom: 10px;
}
.text {
font-size: 28px;
color: var(--ev-c-text-1);
font-weight: 700;
line-height: 32px;
text-align: center;
margin: 0 10px;
padding: 16px 0;
}
.tip {
font-size: 16px;
line-height: 24px;
color: var(--ev-c-text-2);
font-weight: 600;
}
.svelte {
background: -webkit-linear-gradient(315deg, #ff3e00 35%, #647eff);
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
font-weight: 700;
}
.ts {
background: -webkit-linear-gradient(315deg, #3178c6 45%, #f0dc4e);
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
font-weight: 700;
}
.actions {
display: flex;
padding-top: 32px;
margin: -6px;
flex-wrap: wrap;
justify-content: flex-start;
}
.action {
flex-shrink: 0;
padding: 6px;
}
.action a {
cursor: pointer;
text-decoration: none;
display: inline-block;
border: 1px solid transparent;
text-align: center;
font-weight: 600;
white-space: nowrap;
border-radius: 20px;
padding: 0 20px;
line-height: 38px;
font-size: 14px;
border-color: var(--ev-button-alt-border);
color: var(--ev-button-alt-text);
background-color: var(--ev-button-alt-bg);
}
.action a:hover {
border-color: var(--ev-button-alt-hover-border);
color: var(--ev-button-alt-hover-text);
background-color: var(--ev-button-alt-hover-bg);
}
.versions {
position: absolute;
bottom: 30px;
margin: 0 auto;
padding: 15px 0;
font-family: 'Menlo', 'Lucida Console', monospace;
display: inline-flex;
overflow: hidden;
align-items: center;
border-radius: 22px;
background-color: #202127;
backdrop-filter: blur(24px);
}
.versions li {
display: block;
float: left;
border-right: 1px solid var(--ev-c-gray-1);
padding: 0 20px;
font-size: 14px;
line-height: 14px;
opacity: 0.8;
&:last-child {
border: none;
}
}
@media (max-width: 720px) {
.text {
font-size: 20px;
}
}
@media (max-width: 620px) {
.versions {
display: none;
}
}
@media (max-width: 350px) {
.tip,
.actions {
display: none;
}
}
+25
View File
@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1422 800" opacity="0.3">
<defs>
<linearGradient x1="50%" y1="0%" x2="50%" y2="100%" id="oooscillate-grad">
<stop stop-color="hsl(206, 75%, 49%)" stop-opacity="1" offset="0%"></stop>
<stop stop-color="hsl(331, 90%, 56%)" stop-opacity="1" offset="100%"></stop>
</linearGradient>
</defs>
<g stroke-width="1" stroke="url(#oooscillate-grad)" fill="none" stroke-linecap="round">
<path d="M 0 448 Q 355.5 -100 711 400 Q 1066.5 900 1422 448" opacity="0.05"></path>
<path d="M 0 420 Q 355.5 -100 711 400 Q 1066.5 900 1422 420" opacity="0.11"></path>
<path d="M 0 392 Q 355.5 -100 711 400 Q 1066.5 900 1422 392" opacity="0.18"></path>
<path d="M 0 364 Q 355.5 -100 711 400 Q 1066.5 900 1422 364" opacity="0.24"></path>
<path d="M 0 336 Q 355.5 -100 711 400 Q 1066.5 900 1422 336" opacity="0.30"></path>
<path d="M 0 308 Q 355.5 -100 711 400 Q 1066.5 900 1422 308" opacity="0.37"></path>
<path d="M 0 280 Q 355.5 -100 711 400 Q 1066.5 900 1422 280" opacity="0.43"></path>
<path d="M 0 252 Q 355.5 -100 711 400 Q 1066.5 900 1422 252" opacity="0.49"></path>
<path d="M 0 224 Q 355.5 -100 711 400 Q 1066.5 900 1422 224" opacity="0.56"></path>
<path d="M 0 196 Q 355.5 -100 711 400 Q 1066.5 900 1422 196" opacity="0.62"></path>
<path d="M 0 168 Q 355.5 -100 711 400 Q 1066.5 900 1422 168" opacity="0.68"></path>
<path d="M 0 140 Q 355.5 -100 711 400 Q 1066.5 900 1422 140" opacity="0.75"></path>
<path d="M 0 112 Q 355.5 -100 711 400 Q 1066.5 900 1422 112" opacity="0.81"></path>
<path d="M 0 84 Q 355.5 -100 711 400 Q 1066.5 900 1422 84" opacity="0.87"></path>
<path d="M 0 56 Q 355.5 -100 711 400 Q 1066.5 900 1422 56" opacity="0.94"></path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -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,9 @@
<script lang="ts">
const versions = window.electron.process.versions
</script>
<ul class="versions">
<li class="electron-version">Electron v{versions.electron}</li>
<li class="chrome-version">Chromium v{versions.chrome}</li>
<li class="node-version">Node v{versions.node}</li>
</ul>
@@ -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>
+25
View File
@@ -0,0 +1,25 @@
import tippy, { type Props } from 'tippy.js'
import 'tippy.js/dist/tippy.css'
import 'tippy.js/themes/translucent.css'
export function tooltip(node: HTMLElement, content: string | Partial<Props>) {
const options: Partial<Props> =
typeof content === 'string'
? { content, placement: 'bottom', theme: 'translucent', delay: [500, 0] }
: { placement: 'bottom', theme: 'translucent', delay: [500, 0], ...content }
const instance = tippy(node, options)
return {
update(newContent: string | Partial<Props>) {
const newOptions: Partial<Props> =
typeof newContent === 'string'
? { content: newContent }
: newContent
instance.setProps(newOptions)
},
destroy() {
instance.destroy()
}
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1022 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 782 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 432 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

@@ -1,118 +0,0 @@
<script lang="ts">
import { onMount } from "svelte";
import Launching from "./Launching.svelte";
import logoImage from "../assets/images/splash.png";
import General from "./Controls/General.svelte";
import About from "./Controls/About.svelte";
import { info } from "../stores";
let { installed = $bindable(false) } = $props();
let selectedTab = $state("general");
onMount(async () => {});
</script>
{#if $info?.reachable ?? false}
<div
class="flex flex-col w-full h-full relative text-gray-850 dark:text-gray-100"
>
<div
class="pt-3 pb-1.5 pl-22 pr-4 w-full drag-region flex flex-row gap-3 items-center justify-between"
>
<div class=" font-medium">Controls</div>
<div>
<img
src={logoImage}
class="w-6 rounded-full dark:invert"
alt="logo"
/>
</div>
</div>
<div class=" absolute w-full top-0 left-0 right-0 z-10">
<div class="h-6 drag-region"></div>
</div>
<hr class=" my-1 border-gray-850" />
<div
class="flex flex-col sm:flex-row w-full h-full pb-2 sm:space-x-4 px-4 pt-1"
>
<div
id="admin-settings-tabs-container"
class="tabs flex flex-row overflow-x-auto gap-2 max-w-full sm:gap-0.5 sm:flex-col sm:flex-none sm:w-26 dark:text-gray-200 text-sm font-medium text-left scrollbar-none"
>
<button
id="general"
class="px-0.5 py-1 min-w-fit rounded-lg sm:flex-none flex text-right transition {selectedTab ===
'general'
? ''
: ' text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
onclick={() => {
selectedTab = "general";
}}
>
<div class=" self-center mr-2">
<svg
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
>
<path
fill-rule="evenodd"
d="M8.34 1.804A1 1 0 019.32 1h1.36a1 1 0 01.98.804l.295 1.473c.497.144.971.342 1.416.587l1.25-.834a1 1 0 011.262.125l.962.962a1 1 0 01.125 1.262l-.834 1.25c.245.445.443.919.587 1.416l1.473.294a1 1 0 01.804.98v1.361a1 1 0 01-.804.98l-1.473.295a6.95 6.95 0 01-.587 1.416l.834 1.25a1 1 0 01-.125 1.262l-.962.962a1 1 0 01-1.262.125l-1.25-.834a6.953 6.953 0 01-1.416.587l-.294 1.473a1 1 0 01-.98.804H9.32a1 1 0 01-.98-.804l-.295-1.473a6.957 6.957 0 01-1.416-.587l-1.25.834a1 1 0 01-1.262-.125l-.962-.962a1 1 0 01-.125-1.262l.834-1.25a6.957 6.957 0 01-.587-1.416l-1.473-.294A1 1 0 011 10.68V9.32a1 1 0 01.804-.98l1.473-.295c.144-.497.342-.971.587-1.416l-.834-1.25a1 1 0 01.125-1.262l.962-.962A1 1 0 015.38 3.03l1.25.834a6.957 6.957 0 011.416-.587l.294-1.473zM13 10a3 3 0 11-6 0 3 3 0 016 0z"
clip-rule="evenodd"
/>
</svg>
</div>
<div class=" self-center">{"General"}</div>
</button>
<button
id="about"
class="px-0.5 py-1 min-w-fit rounded-lg md:flex-none flex text-left transition {selectedTab ===
'about'
? ''
: ' text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
onclick={() => {
selectedTab = "about";
}}
>
<div class=" self-center mr-2">
<svg
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
>
<path
fill-rule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z"
clip-rule="evenodd"
/>
</svg>
</div>
<div class=" self-center">{"About"}</div>
</button>
</div>
<div
class="flex-1 pt-1 sm:mt-0 overflow-y-scroll pr-1 scrollbar-hidden"
>
{#if selectedTab === "general"}
<General bind:installed info={$info} />
{:else if selectedTab === "about"}
<About />
{/if}
</div>
</div>
</div>
{:else}
<Launching />
{/if}
@@ -1,115 +0,0 @@
<script>
import { onMount } from "svelte";
import Versions from "../Versions.svelte";
import Spinner from "../common/Spinner.svelte";
let version = $state(null);
onMount(async () => {
// Fetch the version from the main process
version = await window.electronAPI.getVersion();
});
</script>
{#if version}
<div
id="tab-about"
class="flex flex-col h-full justify-between space-y-3 text-sm mb-6"
>
<div class=" space-y-3 overflow-y-scroll max-h-[28rem] lg:max-h-full">
<div>
<div
class=" mb-1 text-sm font-medium flex space-x-2 items-center"
>
<div>Open WebUI Desktop Version</div>
</div>
<div class="flex w-full justify-between items-center">
<div
class="flex flex-col text-xs text-gray-700 dark:text-gray-200"
>
<div class="flex gap-1">
v{version}
</div>
<button
class=" underline flex items-center space-x-1 text-xs text-gray-500 dark:text-gray-500 cursor-pointer"
onclick={() => {
window.electronAPI.openInBrowser(
"https://desktop.openwebui.com"
);
}}
>
<div>{"See what's new"}</div>
</button>
</div>
</div>
</div>
<hr class=" border-gray-100 dark:border-gray-850" />
<div class="flex space-x-1">
<a href="https://discord.gg/5rJgQTnV4s" target="_blank">
<img
alt="Discord"
src="https://img.shields.io/badge/Discord-Open_WebUI-blue?logo=discord&logoColor=white"
/>
</a>
<a href="https://twitter.com/OpenWebUI" target="_blank">
<img
alt="X (formerly Twitter) Follow"
src="https://img.shields.io/twitter/follow/OpenWebUI"
/>
</a>
<a
href="https://github.com/open-webui/open-webui"
target="_blank"
>
<img
alt="Github Repo"
src="https://img.shields.io/github/stars/open-webui/open-webui?style=social&label=Star us on Github"
/>
</a>
</div>
<div class="mt-1 text-xs text-gray-400 dark:text-gray-500">
Emoji graphics provided by
<a href="https://github.com/jdecked/twemoji" target="_blank"
>Twemoji</a
>, licensed under
<a
href="https://creativecommons.org/licenses/by/4.0/"
target="_blank">CC-BY 4.0</a
>.
</div>
<div>
<div class="text-xs text-gray-400 dark:text-gray-500">
Copyright (c) {new Date().getFullYear()}
<a
href="https://openwebui.com"
target="_blank"
class="underline">Open WebUI (Timothy Jaeryang Baek)</a
>
All rights reserved.
</div>
</div>
<div class="text-xs text-transparent">
<Versions />
</div>
</div>
</div>
{:else}
<div
class="flex flex-row w-full h-full relative text-gray-850 dark:text-gray-100 drag-region"
>
<div class="flex-1 w-full flex justify-center relative">
<div class="m-auto">
<Spinner />
</div>
</div>
</div>
{/if}
@@ -1,276 +0,0 @@
<script lang="ts">
import { toast } from "svelte-sonner";
import { onMount } from "svelte";
import { copyToClipboard } from "../../utils";
import Switch from "../common/Switch.svelte";
import Spinner from "../common/Spinner.svelte";
import ConfirmDialog from "../common/ConfirmDialog.svelte";
let { info, installed = $bindable(false) } = $props();
let config = $state(null);
let serveOnLocalNetwork = $state(false);
let autoUpdate = $state(true);
let showConfirm = $state(false);
const onOpen = async () => {
try {
await window.electronAPI.openInBrowser(info?.url);
} catch (error) {
toast.error("Failed to open URL in browser");
}
};
const onUpdate = async () => {
try {
await window.electronAPI.setConfig({
...config,
serveOnLocalNetwork: serveOnLocalNetwork,
autoUpdate: autoUpdate,
});
toast.success("Configuration updated successfully");
} catch (error) {
toast.error("Failed to update configuration");
}
};
const resetHandler = async () => {};
onMount(async () => {
config = await window.electronAPI.getConfig();
serveOnLocalNetwork = config?.serveOnLocalNetwork ?? false;
autoUpdate = config?.autoUpdate ?? true;
});
</script>
<ConfirmDialog
bind:show={showConfirm}
title="Factory Reset"
message="Are you sure you want to reset the app? This will remove all configurations, user data, and the bundled Python environment, restoring the app to its original state."
confirmLabel="Reset"
onConfirm={async () => {
try {
installed = null;
config = null;
await window.electronAPI.resetApp();
toast.success("App has been reset successfully.");
setTimeout(() => {
window.location.reload();
}, 1000);
} catch (error) {
toast.error("Failed to reset the app");
}
}}
/>
{#if config}
<div class="text-sm">
<div class="text-sm font-medium">Server Settings</div>
<div class="flex flex-col space-y-1 mt-2">
<div>
<div class="flex flex-row items-center justify-between">
<div
class="flex flex-row items-center space-x-1.5 text-green-100"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
class="size-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-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 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 0 1 3 12c0-1.605.42-3.113 1.157-4.418"
/>
</svg>
<div>Reachable at</div>
</div>
<div class="flex flex-row items-center">
<button
class="p-1 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-850 transition cursor-pointer flex items-center px-2.5 text-xs"
aria-label="copy"
onclick={() => {
copyToClipboard(info?.url || "");
toast.success("URL copied to clipboard");
}}
>
<div class=" flex items-center pr-2">
<span class="relative flex size-2">
<span
class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"
/>
<span
class="relative inline-flex rounded-full size-2 bg-green-500"
/>
</span>
</div>
<div class="">
{info?.url}
</div>
</button>
<button
class="p-1 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-850 transition cursor-pointer"
aria-label="copy"
onclick={() => {
onOpen();
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="size-4"
>
<path
d="M6.22 8.72a.75.75 0 0 0 1.06 1.06l5.22-5.22v1.69a.75.75 0 0 0 1.5 0v-3.5a.75.75 0 0 0-.75-.75h-3.5a.75.75 0 0 0 0 1.5h1.69L6.22 8.72Z"
/>
<path
d="M3.5 6.75c0-.69.56-1.25 1.25-1.25H7A.75.75 0 0 0 7 4H4.75A2.75 2.75 0 0 0 2 6.75v4.5A2.75 2.75 0 0 0 4.75 14h4.5A2.75 2.75 0 0 0 12 11.25V9a.75.75 0 0 0-1.5 0v2.25c0 .69-.56 1.25-1.25 1.25h-4.5c-.69 0-1.25-.56-1.25-1.25v-4.5Z"
/>
</svg>
</button>
</div>
</div>
</div>
<!-- <div>
<div class="flex flex-row items-center justify-between">
<div>Port</div>
<div>
<input
type="text"
class="w-20 px-2 py-1 rounded text-right"
bind:value={port}
readonly
/>
</div>
</div>
</div> -->
<div>
<div class="flex flex-row items-center justify-between">
<div
class="flex flex-row items-center space-x-1.5 text-green-100"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
class="size-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M8.288 15.038a5.25 5.25 0 0 1 7.424 0M5.106 11.856c3.807-3.808 9.98-3.808 13.788 0M1.924 8.674c5.565-5.565 14.587-5.565 20.152 0M12.53 18.22l-.53.53-.53-.53a.75.75 0 0 1 1.06 0Z"
/>
</svg>
<div>Serve on local network</div>
</div>
<div>
<Switch
id="network"
bind:state={serveOnLocalNetwork}
onChange={async () => {
await onUpdate();
await window.electronAPI.restartServer();
}}
/>
</div>
</div>
<div class="text-xs text-gray-500 mt-0.5">
Allow other devices on your local network to access the
server.
</div>
</div>
</div>
<hr class="my-3 border-gray-300 dark:border-gray-850" />
<div class="text-sm font-medium">App</div>
<div class="flex flex-col space-y-1 mt-2">
<div>
<div class="flex flex-row items-center justify-between">
<div
class="flex flex-row items-center space-x-1.5 text-green-100"
>
<div>Automatic updates</div>
</div>
<div>
<Switch
id="auto-updates"
bind:state={autoUpdate}
onChange={() => {
onUpdate();
}}
/>
</div>
</div>
<div class="text-xs text-gray-500 mt-0.5">
Turn off to disable automatic updates on startup.
</div>
</div>
</div>
<div class="flex flex-col space-y-1 mt-2">
<div>
<div class="flex flex-row items-center justify-between">
<div
class="flex flex-row items-center space-x-1.5 text-green-100"
>
<div>Factory Reset</div>
</div>
<div>
<button
class="text-xs cursor-pointer"
onclick={() => {
showConfirm = true;
}}
>
Reset
</button>
</div>
</div>
<div class="text-xs text-gray-500 mt-0.5">
Warning: Resetting the app will remove everything, including
all configurations, user data, and the bundled Python
environment. This action will fully restore the app to its
original state.
</div>
</div>
</div>
</div>
{:else}
<div
class="flex flex-row w-full h-full relative text-gray-850 dark:text-gray-100 drag-region"
>
<div class="flex-1 w-full flex justify-center relative">
<div class="m-auto">
<Spinner />
</div>
</div>
</div>
{/if}
@@ -1,54 +0,0 @@
<script lang="ts">
import { toast } from "svelte-sonner";
import Tooltip from "../common/Tooltip.svelte";
import { copyToClipboard } from "../../utils";
export let logs = [];
</script>
<div class="relative max-w-full w-full px-3">
{#if logs.length > 0}
<div
class="absolute top-0 right-0 p-1 bg-transparent text-xs font-mono"
>
<Tooltip content="Copy">
<button
class="text-xs cursor-pointer"
type="button"
onclick={async () => {
await copyToClipboard(logs.join("\n"));
toast.success("Logs copied to clipboard");
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2.3"
stroke="currentColor"
class="w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184"
/>
</svg>
</button>
</Tooltip>
</div>
{/if}
<div
class="text-xs font-mono text-left max-h-40 overflow-auto max-w-full w-full flex flex-col-reverse scrollbar-hidden no-drag-region"
>
{#each logs.reverse() as log, idx}
<div
class="text-xs font-mono whitespace-pre-wrap text-wrap max-w-full w-full"
>
{log}
</div>
{/each}
</div>
</div>
@@ -1,236 +0,0 @@
<script lang="ts">
import { onMount, tick } from "svelte";
import { fly } from "svelte/transition";
import Logs from "./setup/Logs.svelte";
import Spinner from "./common/Spinner.svelte";
import ArrowRightCircle from "./icons/ArrowRightCircle.svelte";
import logoImage from "../assets/images/splash.png";
import galaxyImage from "../assets/images/galaxy.jpg";
import greenImage from "../assets/images/green.jpg";
import adamImage from "../assets/images/adam.jpg";
import nasaImage from "../assets/images/nasa.jpg";
import neomImage from "../assets/images/neom.jpg";
let { installed = $bindable() } = $props();
let images = [galaxyImage, greenImage, adamImage, nasaImage, neomImage];
let mounted = $state(false);
let currentTime = Date.now();
let showLogs = $state(false);
let installing = $state(false);
const continueHandler = async () => {
if (window?.electronAPI) {
installing = true;
const pythonStatus = await window.electronAPI.getPythonStatus();
console.log("Python Status:", pythonStatus);
if (!pythonStatus) {
await window.electronAPI.installPython();
}
const packageStatus = await window.electronAPI.getPackageStatus();
console.log("Package Status:", packageStatus);
if (!packageStatus) {
await window.electronAPI.installPackage();
}
// Wait for the installation to complete
await tick();
if (
(await window.electronAPI.getPythonStatus()) &&
(await window.electronAPI.getPackageStatus())
) {
// Notify the user that the installation is complete
if (!(await window.electronAPI.getServerStatus())) {
await window.electronAPI.startServer();
}
await window.electronAPI.notification(
"Installation Complete",
"Open WebUI is now ready to use."
);
installed = true; // Update the installed state
} else {
// Handle the case where installation failed
await window.electronAPI.notification(
"Installation Failed",
"There was an error during the installation process."
);
}
installing = false;
}
};
let selectedImageIdx = $state(0);
onMount(() => {
mounted = true;
const imageInterval = setInterval(() => {
selectedImageIdx = (selectedImageIdx + 1) % 5;
}, 10000);
const interval = setInterval(() => {
currentTime = Date.now();
}, 1000); // Update every second
return () => {
clearInterval(interval); // Cleanup interval on destroy
};
});
</script>
<div
class="flex flex-row w-full h-full relative text-gray-850 dark:text-gray-100 p-1"
>
<div class="fixed right-0 my-5 mx-6 z-50">
<div class="flex space-x-2">
<button
class=" self-center cursor-pointer outline-none"
onclick={() => (showLogs = !showLogs)}
>
<img
src={logoImage}
class=" w-6 rounded-full dark:invert"
alt="logo"
/>
</button>
</div>
</div>
{#each images as image, index (index)}
<div
class="image w-full h-full absolute top-0 left-0 bg-cover bg-center transition-opacity duration-1000"
style="opacity: {selectedImageIdx === index
? 1
: 0}; background-image: url({image})"
></div>
{/each}
<div
class="w-full h-full absolute top-0 left-0 bg-gradient-to-t from-20% from-white dark:from-black to-transparent"
></div>
<div
class="w-full h-full absolute top-0 left-0 backdrop-blur-sm bg-white dark:bg-black opacity-50"
></div>
<div class=" absolute w-full top-0 left-0 right-0 z-10">
<div class="h-6 drag-region"></div>
</div>
<div class="flex-1 w-full flex justify-center relative">
<div
class="m-auto flex flex-col justify-center text-center max-w-2xl w-full"
>
{#if mounted}
<div
class=" font-medium text-5xl md:text-6xl xl:text-7xl text-center mb-4 xl:mb-5 font-secondary"
in:fly={{ duration: 750, y: 20 }}
>
Open WebUI
</div>
<div
class=" text-sm xl:text-base text-center mb-3"
in:fly={{ delay: 250, duration: 750, y: 10 }}
>
To get started with Open WebUI, click Continue.
</div>
{/if}
{#if showLogs}
<Logs />
{/if}
</div>
<div class="absolute bottom-0 pb-10">
<div class="flex justify-center mt-8">
<div class="flex flex-col justify-center items-center">
{#if installing}
<div class="flex flex-col gap-3 text-center">
<Spinner className="size-5" />
<div class=" font-secondary xl:text-lg -mt-0.5">
Installing...
</div>
<div
class=" font-default text-xs"
in:fly={{
delay: 100,
duration: 500,
y: 10,
}}
>
This might take a few minutes, Well notify you
when its ready.
</div>
<!-- {#if $serverLogs.length > 0}
<div
class="text-[0.5rem] text-gray-500 font-mono text-center line-clamp-1 px-10"
>
{$serverLogs.at(-1)}
</div>
{/if} -->
</div>
{:else if mounted}
<button
class="relative z-20 flex p-1 rounded-full bg-black/5 hover:bg-black/10 dark:bg-white/5 dark:hover:bg-white/10 transition font-medium text-sm cursor-pointer"
onclick={() => {
continueHandler();
}}
in:fly={{
delay: 500,
duration: 750,
y: 10,
}}
>
<ArrowRightCircle className="size-6" />
</button>
<div
class="mt-1.5 font-primary text-base font-medium"
in:fly={{
delay: 500,
duration: 750,
y: 10,
}}
>
{`Continue`}
</div>
<div
class="text-xs mt-3 text-gray-500 cursor-pointer"
in:fly={{
delay: 500,
duration: 750,
y: 10,
}}
>
By continuing, you agree to our
<button
class="underline"
onclick={() => {
window.electronAPI.openInBrowser(
"https://github.com/open-webui/desktop/blob/main/LICENSE"
);
}}>license agreement</button
>.
</div>
{/if}
</div>
</div>
</div>
</div>
</div>
@@ -1,98 +0,0 @@
<script lang="ts">
import { onMount } from "svelte";
import Spinner from "./common/Spinner.svelte";
import logoImage from "../assets/images/splash.png";
import galaxyImage from "../assets/images/galaxy.jpg";
import greenImage from "../assets/images/green.jpg";
import adamImage from "../assets/images/adam.jpg";
import nasaImage from "../assets/images/nasa.jpg";
import neomImage from "../assets/images/neom.jpg";
import { fly } from "svelte/transition";
let images = [galaxyImage, greenImage, adamImage, nasaImage, neomImage];
let startTime = $state(null);
let currentTime = $state(null);
let selectedImageIdx = $state(0);
onMount(() => {
startTime = Date.now();
currentTime = Date.now();
setInterval(async () => {
currentTime = Date.now();
}, 1000);
const imageInterval = setInterval(() => {
selectedImageIdx = (selectedImageIdx + 1) % 5;
}, 10000);
});
</script>
<div
class="flex flex-row w-full h-full relative text-gray-850 dark:text-gray-100 p-1"
>
<div class="fixed right-0 my-5 mx-6 z-50">
<div class="flex space-x-2">
<button class=" self-center cursor-pointer outline-none">
<img
src={logoImage}
class=" w-6 rounded-full dark:invert"
alt="logo"
/>
</button>
</div>
</div>
{#each images as image, index (index)}
<div
class="image w-full h-full absolute top-0 left-0 bg-cover bg-center transition-opacity duration-1000"
style="opacity: {selectedImageIdx === index
? 1
: 0}; background-image: url({image})"
></div>
{/each}
<div
class="w-full h-full absolute top-0 left-0 bg-gradient-to-t from-20% from-white dark:from-black to-transparent"
></div>
<div
class="w-full h-full absolute top-0 left-0 backdrop-blur-sm bg-white dark:bg-black opacity-50"
></div>
<div class=" absolute w-full top-0 left-0 right-0 z-10">
<div class="h-6 drag-region"></div>
</div>
<div class="flex-1 w-full flex justify-center relative">
<div
class="m-auto flex flex-col justify-center text-center max-w-2xl w-full"
>
<div class="flex-1 w-full flex justify-center relative">
<div class="m-auto max-w-2xl w-full">
<div class="flex flex-col gap-3 text-center">
<Spinner className="size-5" />
<div class=" font-secondary xl:text-lg">
Launching Open WebUI...
</div>
{#if currentTime - startTime > 10000}
<div
class=" font-default text-xs"
in:fly={{ duration: 500, y: 10 }}
>
If it's your first time, it might take a few
minutes to start.
</div>
{/if}
</div>
</div>
</div>
</div>
</div>
</div>
@@ -1,17 +0,0 @@
<script lang="ts">
import splashImage from "../assets/images/splash.png";
</script>
<div
class="flex flex-row w-full h-full relative text-gray-850 dark:text-gray-100 drag-region"
>
<div class="flex-1 w-full flex justify-center relative">
<div class="m-auto">
<img
src={splashImage}
class="size-18 rounded-full dark:invert"
alt="logo"
/>
</div>
</div>
</div>
+142
View File
@@ -0,0 +1,142 @@
<script lang="ts">
import { onMount } from 'svelte'
import { fade } from 'svelte/transition'
import { connections, config, appInfo } from '../stores'
import { tooltip } from '../actions/tooltip'
import i18n from '../i18n'
import Connections from './Main/Connections.svelte'
import Settings from './Main/Settings.svelte'
let visible = $state(false)
let settingsOpen = $state(false)
let sidebarOpen = $state(true)
let activeConnectionName = $state('')
onMount(async () => {
connections.set(await window.electronAPI.getConnections())
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}
<div
class="h-full w-full flex flex-col bg-[#f5f5f7] dark:bg-[#0a0a0a] text-[#1d1d1f] dark:text-[#fafafa] relative"
in:fade={{ duration: 200 }}
>
<!-- Persistent top bar -->
<div
class="flex items-center shrink-0 drag-region {$appInfo?.platform === 'darwin'
? 'h-10'
: 'h-8'}"
>
<div
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={toggleSidebar}
use:tooltip={sidebarOpen ? $i18n.t('sidebar.tooltip.closeSidebar') : $i18n.t('sidebar.tooltip.openSidebar')}
>
<svg
class="w-[15px] h-[15px]"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M3.75 3.75h16.5v16.5H3.75V3.75zM9 3.75v16.5"
/>
</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>
</div>
<div class="pr-3 flex items-center shrink-0 translate-y-[0.5px]">
{#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?.reload) wv.reload()
}}
use:tooltip={$i18n.t('common.refresh')}
>
<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="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M20.015 4.356v4.992m0 0h-4.992m4.993 0l-3.181-3.183a8.25 8.25 0 00-13.803 3.7" />
</svg>
</button>
{/if}
</div>
</div>
<!-- Content area below top bar -->
<div class="flex-1 min-h-0">
<Connections
{sidebarOpen}
bind:activeConnectionName
onOpenSettings={() => (settingsOpen = true)}
/>
</div>
{#if settingsOpen}
<div
class="absolute inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
in:fade={{ duration: 150 }}
out:fade={{ duration: 100 }}
onclick={() => (settingsOpen = false)}
role="button"
tabindex="-1"
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="w-[calc(100%-32px)] h-[calc(100%-32px)] max-w-[900px] max-h-[600px] rounded-3xl overflow-hidden shadow-2xl border border-black/[0.08] dark:border-white/[0.08]"
in:fade={{ duration: 150 }}
onclick={(e) => e.stopPropagation()}
>
<Settings onClose={() => (settingsOpen = false)} />
</div>
</div>
{/if}
</div>
{/if}
@@ -0,0 +1,627 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte'
import { fade } from 'svelte/transition'
import { connections, config, serverInfo, appState } from '../../stores'
import i18n from '../../i18n'
import Sidebar from './Connections/Sidebar.svelte'
import Content from './Connections/Content.svelte'
import StatusBar from './Connections/StatusBar.svelte'
import LogPanel from './Connections/LogPanel.svelte'
interface Props {
onOpenSettings: () => void
sidebarOpen: boolean
activeConnectionName?: string
}
let {
onOpenSettings,
sidebarOpen,
activeConnectionName = $bindable('')
}: Props = $props()
let isLocalConnection = $state(false)
let showingLogs = $state(false)
let url = $state('')
let connecting = $state(false)
let error = $state('')
let view = $state('welcome') // welcome | install | connected
let autoInstall = $state(false)
let installPhase = $state('idle') // idle | working | error
let installError = $state('')
let toastVisible = $state(false)
let toastTimeout: ReturnType<typeof setTimeout> | null = null
let installStatus = $state('')
let settingsOpen = $state(false)
let connectedUrl = $state('')
let activeConnectionId = $state('')
let connectingId = $state('')
let openConnections: Map<string, string> = $state(new Map())
let localInstalled = $state(false)
let openTerminalInstalled = $state(false)
let showAddConnectionModal = $state(false)
// Active log panel
let activeLog = $state<'server' | 'open-terminal' | 'llama-server' | null>(null)
const serverStatus = $derived($serverInfo?.status)
const serverReachable = $derived($serverInfo?.reachable)
const isInitializing = $derived($appState === 'initializing')
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)
let openTerminalInfo = $state<{ url?: string; apiKey?: string } | null>(null)
// Llama Server state
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; 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()
if (disk?.free >= 0 && disk.free < MINIMUM_DISK_BYTES) {
const availableGB = (disk.free / (1024 * 1024 * 1024)).toFixed(1)
throw new Error(`Not enough disk space. At least 5 GB is required (${availableGB} GB available).`)
}
// Ensure Python and uv are installed before attempting package install
const pythonReady = await window.electronAPI.getPythonStatus()
if (!pythonReady) {
const pythonOk = await window.electronAPI.installPython()
if (!pythonOk) throw new Error('Failed to install Python. Please try again.')
}
const ok = await window.electronAPI.installPackage()
if (!ok) throw new Error($i18n.t('error.installFailedGeneric'))
// Start optional services after packages are installed to avoid
// concurrent uv installs fighting over the lockfile
if (options?.installOpenTerminal) {
toggleOpenTerminal()
}
if (options?.installLlamaCpp) {
toggleLlamaCpp()
}
installStatus = $i18n.t('main.install.startingServer')
await window.electronAPI.startServer()
const info = await window.electronAPI.getServerInfo()
installStatus = $i18n.t('main.install.settingUpConnection')
await window.electronAPI.setDefaultConnection('local')
config.set(await window.electronAPI.getConfig())
// Wait for server to actually be reachable before showing connected view
installStatus = $i18n.t('main.install.launchingOpenWebUI')
const maxWait = 120000
const pollInterval = 2000
const startTime = Date.now()
let reachable = false
while (Date.now() - startTime < maxWait) {
const si = await window.electronAPI.getServerInfo()
if (si?.reachable) {
reachable = true
break
}
await new Promise((r) => setTimeout(r, pollInterval))
}
if (!reachable) {
throw new Error('Server did not become reachable. Please try again.')
}
// Now connect — the server is ready
installStatus = ''
connect('local')
installPhase = 'idle'
} catch (e: any) {
installPhase = 'error'
installError = e?.message || $i18n.t('error.somethingWentWrong')
toastVisible = true
if (toastTimeout) clearTimeout(toastTimeout)
toastTimeout = setTimeout(() => { toastVisible = false }, 5000)
}
}
const addConnection = async () => {
if (!url.trim()) return
let u = url.trim()
if (!u.startsWith('http')) u = 'https://' + u
error = ''
try {
new URL(u)
} catch {
error = $i18n.t('setup.invalidUrl')
return
}
connecting = true
try {
const valid = await window.electronAPI.validateUrl(u)
if (!valid) {
error = $i18n.t('setup.couldNotReachServer')
connecting = false
return
}
await window.electronAPI.addConnection({
id: crypto.randomUUID(),
name: new URL(u).hostname,
type: 'remote',
url: u
})
config.set(await window.electronAPI.getConfig())
url = ''
error = ''
showAddConnectionModal = false
view = 'welcome'
} catch {
error = $i18n.t('setup.connectionFailed')
} finally {
connecting = false
}
}
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
}
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'
}
}
const disconnect = () => {
activeConnectionId = ''
connectedUrl = ''
view = 'welcome'
}
const remove = async (id: string) => {
await window.electronAPI.removeConnection(id)
config.set(await window.electronAPI.getConfig())
if (activeConnectionId === id) {
disconnect()
}
openConnections.delete(id)
openConnections = new Map(openConnections)
}
$effect(() => {
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
// Only react when parent sets showingLogs to true; don't close on false
// (the status bar manages its own open/close via activeLog)
$effect(() => {
if (showingLogs) {
activeLog = 'server'
}
})
// Sync back: when panel closes, tell parent
$effect(() => {
if (activeLog === null) {
showingLogs = false
}
})
const openGithub = () => {
settingsOpen = false
window.electronAPI?.openInBrowser?.('https://github.com/open-webui/desktop')
}
// ── Log panel PTY helpers ─────────────────────────────
const getConnectPty = (log: string) => {
return (callback: (data: string) => void) => {
if (log === 'server') {
window.electronAPI.connectPty(callback)
} else if (log === 'open-terminal') {
window.electronAPI.connectOpenTerminalPty(callback)
} else if (log === 'llama-server') {
window.electronAPI.connectLlamaCppPty(callback)
}
}
}
const getDisconnectPty = (log: string) => {
return () => {
if (log === 'server') {
window.electronAPI.disconnectPty()
} else if (log === 'open-terminal') {
window.electronAPI?.disconnectOpenTerminalPty?.()
} else if (log === 'llama-server') {
window.electronAPI?.disconnectLlamaCppPty?.()
}
}
}
const getOnWrite = (log: string) => {
if (log === 'server') {
return (data: string) => window.electronAPI.writePty(data)
}
return undefined
}
const getOnResize = (log: string) => {
if (log === 'server') {
return (cols: number, rows: number) => window.electronAPI.resizePty(cols, rows)
}
return undefined
}
// ── Status bar log selection ──────────────────────────
const selectLog = (log: string) => {
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 ?? ''
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
}
// ── 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
}
// ── Call shortcut ─────────────────────────────────
if (data.type === 'call' && data.data?.connectionId) {
const connId = data.data.connectionId ?? ''
const baseUrl = data.data.url ?? ''
if (!openConnections.has(connId)) {
openConnections.set(connId, baseUrl)
openConnections = new Map(openConnections)
connectedUrl = baseUrl
} else {
connectedUrl = openConnections.get(connId)!
}
activeConnectionId = connId
if (installPhase !== 'working') view = 'connected'
// Targeted delivery — wait a frame for the webview DOM to exist
requestAnimationFrame(() => {
sendToWebview({ type: 'call' }, connId)
})
return
}
// ── Desktop-only state (not forwarded to webviews) ─
if (data.type === 'status:open-terminal') { openTerminalStatus = data.data; return }
if (data.type === '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 === 'connections:changed') {
connections.set(data.data ?? [])
return
}
// ── 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)
}
})
// Check current Open Terminal state on mount
window.electronAPI.getOpenTerminalInfo().then((info: any) => {
if (info?.status) {
openTerminalStatus = info.status
openTerminalInfo = info
}
})
// Check if Open Terminal package is installed
window.electronAPI.getOpenTerminalStatus().then((installed: boolean) => {
openTerminalInstalled = installed
})
// Check if Open WebUI package is installed
window.electronAPI.getPackageVersion('open-webui').then((v: string | null) => {
localInstalled = v !== null
})
// Check llama-server state on mount
window.electronAPI.getLlamaCppInfo().then((info: any) => {
if (info?.status) {
llamaCppStatus = info.status
}
if (info?.binaryPath || info?.status) {
llamaCppInfo = info
}
})
})
const toggleOpenTerminal = async () => {
if (openTerminalStatus === 'starting') return
if (openTerminalStatus === 'started') {
openTerminalStatus = 'stopping'
await window.electronAPI.stopOpenTerminal()
openTerminalStatus = null
openTerminalInfo = null
openTerminalSetupStatus = ''
} else {
openTerminalStatus = 'starting'
openTerminalSetupStatus = ''
const result = await window.electronAPI.startOpenTerminal()
if (result) {
openTerminalInfo = result
openTerminalStatus = 'started'
} else {
openTerminalStatus = 'failed'
}
openTerminalSetupStatus = ''
}
}
const toggleLlamaCpp = async () => {
if (llamaCppStatus === 'starting' || llamaCppStatus === 'setting-up') return
if (llamaCppStatus === 'started') {
llamaCppStatus = 'stopping'
await window.electronAPI.stopLlamaCpp()
llamaCppStatus = null
llamaCppInfo = null
} else {
llamaCppStatus = 'starting'
const result = await window.electronAPI.startLlamaCpp()
if (result) {
llamaCppInfo = result
llamaCppStatus = 'started'
} else {
llamaCppStatus = 'failed'
}
}
}
</script>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="h-full w-full flex flex-col bg-[#f5f5f7] dark:bg-[#0a0a0a] text-[#1d1d1f] dark:text-[#fafafa]" in:fade={{ duration: 200 }}>
<div class="flex-1 min-h-0 flex">
{#if sidebarOpen}
<Sidebar
{activeConnectionId}
{connectingId}
{localConn}
{localInstalled}
{remoteConnections}
{serverStatus}
{serverReachable}
bind:settingsOpen
onConnect={connect}
onDisconnect={disconnect}
onAddView={() => { showAddConnectionModal = true }}
{onOpenSettings}
onRename={async (id, name) => {
await window.electronAPI.updateConnection(id, { name })
}}
onRemove={remove}
{openGithub}
/>
{/if}
<Content
{sidebarOpen}
bind:view
{activeConnectionId}
{connectingId}
{openConnections}
{localConn}
{localInstalled}
{remoteConnections}
bind:installPhase
bind:installError
bind:installStatus
bind:toastVisible
bind:url
bind:connecting
bind:error
bind:showAddConnectionModal
bind:autoInstall
onStartInstall={startInstall}
onAddConnection={addConnection}
onSetView={(v) => { view = v }}
/>
</div>
{#if activeLog}
<LogPanel
{activeLog}
serviceReady={activeLog === 'server'
? serverStatus === 'started'
: activeLog === 'open-terminal'
? openTerminalStatus === 'started'
: llamaCppStatus === 'started'}
statusText={activeLog === 'server'
? (serverStatus === 'starting' ? 'Starting Open WebUI…' : serverStatus === 'running' && !serverReachable ? 'Waiting for server…' : installStatus || '')
: activeLog === '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)}
readonly={activeLog !== 'server'}
onWrite={getOnWrite(activeLog)}
onResize={getOnResize(activeLog)}
onStop={activeLog === 'open-terminal' ? toggleOpenTerminal : activeLog === 'llama-server' ? toggleLlamaCpp : undefined}
onClose={() => { activeLog = null; showingLogs = false }}
/>
{/if}
<StatusBar
{serverStatus}
{serverReachable}
{openTerminalStatus}
{llamaCppStatus}
openWebuiInstalled={localInstalled}
{openTerminalInstalled}
llamaCppInstalled={!!llamaCppInfo?.binaryPath}
{activeLog}
onSelectLog={selectLog}
onStartServer={async () => {
if (!localInstalled) {
// Not installed — trigger full install (handles Python/uv + package)
startInstall()
return
}
// Already installed — start the server
await window.electronAPI.startServer()
// Force-refresh serverInfo immediately (don't wait for 3s poll)
const info = await window.electronAPI.getServerInfo()
serverInfo.set(info)
}}
onToggleOpenTerminal={toggleOpenTerminal}
onToggleLlamaCpp={toggleLlamaCpp}
/>
</div>
@@ -0,0 +1,114 @@
<script lang="ts">
import { fade, scale } from 'svelte/transition'
import i18n from '../../../i18n'
interface Props {
url: string
connecting: boolean
error: string
onConnect: () => void
onCancel: () => void
}
let {
url = $bindable(''),
connecting = $bindable(false),
error = $bindable(''),
onConnect,
onCancel
}: Props = $props()
</script>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-[100] flex items-center justify-center"
transition:fade={{ duration: 150 }}
onmousedown={onCancel}
>
<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"
transition:scale={{ start: 0.97, duration: 180 }}
onmousedown={(e) => e.stopPropagation()}
>
<!-- Visual header -->
<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="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>
</div>
</div>
<h2 class="text-lg font-semibold tracking-tight text-white dark:text-gray-900">
{$i18n.t('setup.newConnection')}
</h2>
<p class="mt-1 text-xs text-white/60 dark:text-gray-900/50">
{$i18n.t('setup.newConnectionDesc')}
</p>
</div>
</div>
<!-- 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="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()}
/>
{#if error}
<p class="mt-2 text-[11px] text-red-400">{error}</p>
{/if}
</div>
<!-- Footer -->
<div class="px-5 pb-5 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 disabled:opacity-40 disabled:cursor-default disabled:active:scale-100"
onclick={onConnect}
disabled={connecting || !url.trim()}
>
{#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>
{$i18n.t('common.connecting')}
</span>
{:else}
{$i18n.t('common.connect')}
{/if}
</button>
<button
class="w-full rounded-xl px-4 py-2 text-sm text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 transition bg-transparent border-none cursor-pointer"
onclick={onCancel}
>
{$i18n.t('common.cancel')}
</button>
</div>
</div>
</div>
@@ -0,0 +1,523 @@
<script lang="ts">
import { onMount } from 'svelte'
import { fade, fly } from 'svelte/transition'
import { config, serverInfo, appState } from '../../../stores'
import i18n from '../../../i18n'
import LocalInstall from '../../Setup/LocalInstall.svelte'
import GetStartedModal from './GetStartedModal.svelte'
import AddConnectionModal from './AddConnectionModal.svelte'
import landingVideo from '../../../../assets/landing.mp4'
interface Props {
sidebarOpen: boolean
view: string
activeConnectionId: string
connectingId: string
openConnections: Map<string, string>
localConn: any
localInstalled: boolean
remoteConnections: any[]
installPhase: string
installError: string
installStatus: string
toastVisible: boolean
url: string
connecting: boolean
error: string
autoInstall: boolean
onStartInstall: (options?: { installOpenTerminal?: boolean; installLlamaCpp?: boolean; installDir?: string }) => void
onAddConnection: () => void
onSetView: (v: string) => void
showAddConnectionModal: boolean
}
let {
sidebarOpen,
view,
activeConnectionId,
connectingId,
openConnections,
localConn,
localInstalled,
remoteConnections,
installPhase = $bindable('idle'),
installError = $bindable(''),
installStatus = $bindable(''),
toastVisible = $bindable(false),
url = $bindable(''),
connecting = $bindable(false),
error = $bindable(''),
autoInstall = $bindable(false),
onStartInstall,
onAddConnection,
onSetView,
showAddConnectionModal = $bindable(false)
}: Props = $props()
let showGetStartedModal = $state(false)
const isInitializing = $derived($appState === 'initializing')
const insufficientStorage = $derived(
$appState?.startsWith('insufficient-storage:')
? $appState.split(':')[1]
: null
)
const installFailed = $derived(
$appState?.startsWith('install-failed:')
? $appState.substring('install-failed:'.length)
: null
)
// 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(
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 && 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()
const observer = new MutationObserver(() => {
const container = document.querySelector('.content-webview-container')
if (!container) return
const webviews = container.querySelectorAll('webview')
webviews.forEach((wv: any) => {
if (wv._loadListenerAttached) return
wv._loadListenerAttached = true
const connId = wv.getAttribute('partition')?.replace('persist:connection-', '') ?? ''
if (!connId) return
// Mark loading when navigation starts
wv.addEventListener('did-start-loading', () => {
webviewLoading.set(connId, true)
webviewLoading = new Map(webviewLoading)
})
// Clear loading when done
wv.addEventListener('did-stop-loading', () => {
webviewLoading.set(connId, false)
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) {
wv.send('desktop:response', {
_responseId: requestData._requestId,
data: response
})
}
} catch (e) {
console.error('webview:send handler error:', e)
}
} 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())
}
}
})
})
})
const target = document.querySelector('.content-webview-container')
if (target) {
observer.observe(target, { childList: true, subtree: true })
}
return () => observer.disconnect()
})
</script>
<div
class="flex-1 flex flex-col min-w-0 overflow-clip bg-[#eee] dark:bg-[#111] border-t relative content-webview-container {sidebarOpen
? 'border-l border-black/[0.08] dark:border-white/[0.08] rounded-tl-xl'
: 'border-black/[0.08] dark:border-white/[0.10]'}"
>
<!-- Webviews — all open connections stay alive, only active one visible -->
{#each [...openConnections] as [connId, connUrl] (connId)}
<webview
src={connUrl}
class="flex-1 min-h-0 border-none"
style="display: {view === 'connected' && activeConnectionId === connId ? 'flex' : 'none'}"
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 }}>
<div class="flex flex-col items-center gap-3">
<div class="w-6 h-6 rounded-full border-2 border-black/10 dark:border-white/15 border-t-black/50 dark:border-t-white/50 animate-spin"></div>
<span class="text-[11px] opacity-30">{$i18n.t('common.loading')}</span>
</div>
</div>
{/if}
{#if view !== 'connected'}
{#if insufficientStorage}
<div class="px-5 py-2.5 flex items-center gap-3 bg-red-500/[0.06] border-b border-red-500/10">
<div class="flex-1">
<div class="text-[12px] text-red-400 font-medium">{$i18n.t('main.notEnoughDiskSpace')}</div>
<div class="text-[11px] opacity-40 mt-0.5">
{$i18n.t('main.diskSpaceRequired', { available: insufficientStorage })}
</div>
</div>
<button
class="shrink-0 text-[11px] px-3 py-1 rounded-lg bg-black/[0.04] dark:bg-white/[0.06] opacity-60 hover:opacity-90 transition border-none text-[#1d1d1f] dark:text-[#fafafa] cursor-pointer"
onclick={async () => {
const MINIMUM_DISK_BYTES = 5 * 1024 * 1024 * 1024
const disk = await window.electronAPI.getDiskSpace()
if (disk?.free >= 0 && disk.free < MINIMUM_DISK_BYTES) {
const gb = (disk.free / (1024 * 1024 * 1024)).toFixed(1)
appState.set(`insufficient-storage:${gb}`)
return
}
appState.set('initializing')
window.electronAPI.installPython().then(() => appState.set('ready')).catch((e: any) => {
appState.set(`install-failed:${e?.message || 'Python installation failed. Please try again.'}`)
})
}}
>
{$i18n.t('common.retry')}
</button>
</div>
{:else if installFailed}
<div class="px-5 py-2.5 flex items-center gap-3 bg-red-500/[0.06] border-b border-red-500/10">
<div class="flex-1">
<div class="text-[12px] text-red-400 font-medium">{$i18n.t('error.installFailedGeneric')}</div>
<div class="text-[11px] opacity-40 mt-0.5 line-clamp-2">
{installFailed}
</div>
</div>
<button
class="shrink-0 text-[11px] px-3 py-1 rounded-lg bg-black/[0.04] dark:bg-white/[0.06] opacity-60 hover:opacity-90 transition border-none text-[#1d1d1f] dark:text-[#fafafa] cursor-pointer"
onclick={async () => {
const MINIMUM_DISK_BYTES = 5 * 1024 * 1024 * 1024
const disk = await window.electronAPI.getDiskSpace()
if (disk?.free >= 0 && disk.free < MINIMUM_DISK_BYTES) {
const gb = (disk.free / (1024 * 1024 * 1024)).toFixed(1)
appState.set(`insufficient-storage:${gb}`)
return
}
appState.set('initializing')
window.electronAPI.installPython().then(() => appState.set('ready')).catch((e: any) => {
appState.set(`install-failed:${e?.message || 'Python installation failed. Please try again.'}`)
})
}}
>
{$i18n.t('common.retry')}
</button>
</div>
{:else if isInitializing}
<div class="px-5 py-1.5 text-[11px] opacity-25">
{$i18n.t('setup.settingUp')}{$serverInfo?.status ? ` ${$serverInfo.status}` : ''}
</div>
{/if}
<div class="flex-1 flex items-center justify-center px-6 relative overflow-hidden">
{#if view === 'welcome'}
{#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">
{$i18n.t('main.selectConnection')}
</div>
</div>
{:else}
<!-- Theme-responsive hero section -->
<div class="absolute inset-0 bg-[#fafafa] dark:bg-[#111]">
<!-- Video background -->
<video
autoplay
muted
loop
playsinline
class="absolute inset-0 w-full h-full object-cover opacity-30 dark:opacity-40 pointer-events-none"
>
<source src={landingVideo} type="video/mp4" />
</video>
<!-- Gradient overlay -->
<div class="absolute inset-0 bg-gradient-to-t from-[#fafafa] dark:from-[#111] via-[#fafafa]/30 dark:via-[#111]/30 to-transparent pointer-events-none"></div>
<!-- Content positioned bottom-left -->
<div class="absolute bottom-0 left-0 right-0 p-10" in:fade={{ duration: 300 }}>
<div class="max-w-sm">
<div class="text-3xl font-medium mb-3 tracking-tight text-[#1d1d1f] dark:text-[#fafafa]">{$i18n.t('app.name')}</div>
<div class="text-base opacity-50 mb-8 leading-relaxed text-[#1d1d1f] dark:text-[#fafafa]">
{$i18n.t('main.heroDescription')}
</div>
{#if !localInstalled}
<button
class="inline-flex items-center gap-2 bg-black dark:bg-white px-6 py-2 rounded-xl text-white dark:text-black text-[13px] transition hover:bg-gray-800 dark:hover:bg-gray-100 border-none disabled:opacity-50"
onclick={() => {
if (installPhase === 'error') {
onStartInstall()
} else {
showGetStartedModal = true
}
}}
disabled={installPhase === 'working'}
>
{#if installPhase === 'working'}
<div 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"></div>
{$i18n.t('common.installing')}
{:else if installPhase === 'error'}
{$i18n.t('common.retry')}
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M20.015 4.356v4.992m0 0h-4.992m4.993 0l-3.181-3.183a8.25 8.25 0 00-13.803 3.7" />
</svg>
{:else}
{$i18n.t('main.getStarted')}
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M17 8l4 4m0 0l-4 4m4-4H3" />
</svg>
{/if}
</button>
{#if installPhase === 'working' && installStatus}
<div class="mt-3 text-[12px] opacity-40 font-mono line-clamp-1" in:fade={{ duration: 200 }}>
{installStatus}
</div>
{/if}
{/if}
{#if installPhase !== 'working'}
<div class="mt-6">
<button
class="text-sm opacity-40 hover:opacity-70 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa]"
onclick={() => { showAddConnectionModal = true }}
>
{$i18n.t('setup.connectToServer')}
</button>
</div>
{/if}
</div>
</div>
</div>
{/if}
<!-- Error toast -->
{#if toastVisible && installError}
<div
class="absolute top-4 left-1/2 -translate-x-1/2 z-50 bg-red-500/90 backdrop-blur-sm text-white text-[12px] px-4 py-2 rounded-xl shadow-lg"
in:fly={{ y: -10, duration: 200 }}
out:fade={{ duration: 150 }}
>
{installError}
</div>
{/if}
{:else if view === 'install'}
<div class="w-full max-w-[260px]">
<LocalInstall
autoStart={autoInstall}
onBack={() => { autoInstall = false; onSetView('welcome') }}
onComplete={async () => {
config.set(await window.electronAPI.getConfig())
onSetView('welcome')
}}
/>
</div>
{/if}
</div>
{/if}
{#if showGetStartedModal}
<GetStartedModal
onContinue={(options) => {
showGetStartedModal = false
onStartInstall(options)
}}
onCancel={() => { showGetStartedModal = false }}
/>
{/if}
{#if showAddConnectionModal}
<AddConnectionModal
bind:url
bind:connecting
bind:error
onConnect={() => {
onAddConnection()
}}
onCancel={() => {
showAddConnectionModal = false
error = ''
}}
/>
{/if}
</div>
@@ -0,0 +1,151 @@
<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; installDir: string }) => void
onCancel: () => void
}
let { onContinue, onCancel }: Props = $props()
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 -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-[100] flex items-center justify-center"
transition:fade={{ duration: 150 }}
onmousedown={onCancel}
>
<div class="absolute inset-0 bg-black/50 backdrop-blur-sm"></div>
<div
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()}
>
<!-- Visual header -->
<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="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="M3.75 6A2.25 2.25 0 0 1 6 3.75h2.25A2.25 2.25 0 0 1 10.5 6v2.25a2.25 2.25 0 0 1-2.25 2.25H6a2.25 2.25 0 0 1-2.25-2.25V6ZM3.75 15.75A2.25 2.25 0 0 1 6 13.5h2.25a2.25 2.25 0 0 1 2.25 2.25V18a2.25 2.25 0 0 1-2.25 2.25H6A2.25 2.25 0 0 1 3.75 18v-2.25ZM13.5 6a2.25 2.25 0 0 1 2.25-2.25H18A2.25 2.25 0 0 1 20.25 6v2.25A2.25 2.25 0 0 1 18 10.5h-2.25a2.25 2.25 0 0 1-2.25-2.25V6ZM13.5 15.75a2.25 2.25 0 0 1 2.25-2.25H18a2.25 2.25 0 0 1 2.25 2.25V18A2.25 2.25 0 0 1 18 20.25h-2.25a2.25 2.25 0 0 1-2.25-2.25v-2.25Z" />
</svg>
</div>
</div>
<h2 class="text-lg font-semibold tracking-tight text-white dark:text-gray-900">
{$i18n.t('main.getStarted.title')}
</h2>
<p class="mt-1 text-xs text-white/60 dark:text-gray-900/50">
{$i18n.t('main.getStarted.description')}
</p>
</div>
</div>
<!-- Options -->
<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>
<div class="text-[11px] text-gray-400 dark:text-gray-500 mt-0.5">{$i18n.t('main.getStarted.openTerminalDesc')}</div>
</div>
<Switch
checked={installOpenTerminal}
onchange={(v) => { installOpenTerminal = v }}
/>
</div>
<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 flex items-center gap-1.5">
{$i18n.t('main.getStarted.llamaCpp')}
<span class="text-[9px] opacity-30 uppercase tracking-wide">{$i18n.t('common.experimental')}</span>
</div>
<div class="text-[11px] text-gray-400 dark:text-gray-500 mt-0.5">{$i18n.t('main.getStarted.llamaCppDesc')}</div>
</div>
<Switch
checked={installLlamaCpp}
onchange={(v) => { installLlamaCpp = v }}
/>
</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 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, installDir })}
>
{$i18n.t('main.getStarted.continue')}
</button>
<button
class="w-full rounded-xl px-4 py-2 text-sm text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 transition bg-transparent border-none cursor-pointer"
onclick={onCancel}
>
{$i18n.t('common.cancel')}
</button>
</div>
</div>
</div>
@@ -0,0 +1,193 @@
<script lang="ts">
import { fly } from 'svelte/transition'
import i18n from '../../../i18n'
import { tooltip } from '../../../actions/tooltip'
import LogViewer from '../../common/LogViewer.svelte'
interface Props {
activeLog: 'server' | 'open-terminal' | 'llama-server'
serviceReady: boolean
statusText?: string
connectPty: (callback: (data: string) => void) => void
disconnectPty: () => void
readonly?: boolean
onWrite?: (data: string) => void
onResize?: (cols: number, rows: number) => void
onStop?: () => Promise<void>
onClose: () => void
}
let {
activeLog,
serviceReady,
statusText = '',
connectPty,
disconnectPty,
readonly = false,
onWrite,
onResize,
onStop,
onClose
}: Props = $props()
let stopping = $state(false)
let panelHeight = $state(250)
let dragging = $state(false)
let startY = 0
let startHeight = 0
let copied = $state(false)
let refreshKey = $state(0)
let logViewerRef: LogViewer | undefined = $state()
const logLabels: Record<string, () => string> = {
'server': () => $i18n.t('statusBar.server'),
'open-terminal': () => $i18n.t('sidebar.openTerminal'),
'llama-server': () => $i18n.t('sidebar.llamaCpp')
}
const onDragStart = (e: MouseEvent) => {
dragging = true
startY = e.clientY
startHeight = panelHeight
// Overlay prevents webview from capturing mouse events during drag
const overlay = document.createElement('div')
overlay.style.cssText = 'position:fixed;inset:0;z-index:9999;cursor:ns-resize;'
document.body.appendChild(overlay)
const onDragMove = (e: MouseEvent) => {
const delta = startY - e.clientY
panelHeight = Math.max(120, Math.min(600, startHeight + delta))
}
const onDragEnd = () => {
dragging = false
overlay.remove()
window.removeEventListener('mousemove', onDragMove)
window.removeEventListener('mouseup', onDragEnd)
}
window.addEventListener('mousemove', onDragMove)
window.addEventListener('mouseup', onDragEnd)
}
const copyLogs = () => {
const text = logViewerRef?.getBufferText?.()
if (text) {
navigator.clipboard.writeText(text)
copied = true
setTimeout(() => { copied = false }, 1500)
}
}
</script>
<div
class="shrink-0 flex flex-col border-t border-black/[0.1] dark:border-white/[0.1] bg-[#0a0a0a] overflow-hidden"
style="height: {panelHeight}px"
in:fly={{ y: 60, duration: 200 }}
out:fly={{ y: 60, duration: 150 }}
>
<!-- Resize edge -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="h-0 shrink-0 cursor-ns-resize relative"
onmousedown={onDragStart}
>
<div class="absolute -top-[3px] left-0 right-0 h-[6px] z-10"></div>
</div>
<!-- Header bar -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="flex items-center justify-between px-3 py-1 shrink-0 border-b border-white/[0.06] cursor-pointer" onclick={onClose}>
<div class="flex items-center gap-2">
<span class="text-[11px] uppercase tracking-wider text-white/40 font-medium">{logLabels[activeLog]?.() ?? activeLog}</span>
</div>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="flex items-center gap-0.5" onclick={(e) => e.stopPropagation()}>
<!-- Stop button (Open Terminal / llama.cpp only) -->
{#if onStop && serviceReady}
<button
class="p-1 rounded-md hover:bg-white/[0.08] transition bg-transparent border-none cursor-pointer {stopping ? 'opacity-30 pointer-events-none' : 'opacity-40 hover:opacity-80'} text-white"
disabled={stopping}
onclick={async () => {
stopping = true
try {
await onStop()
} finally {
stopping = false
onClose()
}
}}
use:tooltip={$i18n.t('common.stop')}
>
{#if stopping}
<div class="w-3.5 h-3.5 rounded-full border-[1.5px] border-white/30 border-t-transparent animate-spin"></div>
{:else}
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M5.636 5.636a9 9 0 1012.728 0M12 3v9" />
</svg>
{/if}
</button>
{/if}
<!-- Copy button -->
<button
class="p-1 rounded-md opacity-40 hover:opacity-80 hover:bg-white/[0.08] transition bg-transparent border-none text-white cursor-pointer"
onclick={copyLogs}
title={$i18n.t('logs.copyLogs')}
>
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
{#if copied}
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
{:else}
<path stroke-linecap="round" stroke-linejoin="round" d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9.75a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184" />
{/if}
</svg>
</button>
<!-- Refresh button -->
<button
class="p-1 rounded-md opacity-40 hover:opacity-80 hover:bg-white/[0.08] transition bg-transparent border-none text-white cursor-pointer"
onclick={() => { disconnectPty(); refreshKey++ }}
title={$i18n.t('common.refresh')}
>
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M20.015 4.356v4.992m0 0h-4.992m4.993 0l-3.181-3.183a8.25 8.25 0 00-13.803 3.7" />
</svg>
</button>
<!-- Close button -->
<button
class="p-1 rounded-md opacity-40 hover:opacity-80 hover:bg-white/[0.08] transition bg-transparent border-none text-white cursor-pointer"
onclick={onClose}
title={$i18n.t('common.close')}
>
<svg class="w-3.5 h-3.5" 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>
</div>
<!-- Log content -->
<div class="flex-1 min-h-0 relative overflow-hidden">
{#if serviceReady}
{#key `${activeLog}-${refreshKey}`}
<LogViewer
bind:this={logViewerRef}
connect={connectPty}
disconnect={disconnectPty}
{readonly}
{onWrite}
{onResize}
/>
{/key}
{:else}
<div class="absolute inset-0 flex items-center justify-center bg-[#0a0a0a]">
<div class="flex flex-col items-center gap-3">
<div class="w-5 h-5 rounded-full border-2 border-white/15 border-t-white/50 animate-spin"></div>
<span class="text-[11px] text-white/50">{statusText || $i18n.t('common.loading')}</span>
</div>
</div>
{/if}
</div>
</div>
@@ -0,0 +1,495 @@
<script lang="ts">
import { fly, fade } from 'svelte/transition'
import { connections, config, appInfo, serverInfo } from '../../../stores'
import i18n from '../../../i18n'
interface Props {
activeConnectionId: string
connectingId: string
localConn: any
localInstalled: boolean
remoteConnections: any[]
serverStatus: string | undefined
serverReachable: boolean | undefined
settingsOpen: boolean
onConnect: (id: string) => void
onDisconnect: () => void
onAddView: () => void
onOpenSettings: () => void
onRename: (id: string, name: string) => void
onRemove: (id: string) => void
openGithub: () => void
}
let {
activeConnectionId,
connectingId,
localConn,
localInstalled,
remoteConnections,
serverStatus,
serverReachable,
settingsOpen = $bindable(false),
onConnect,
onDisconnect,
onAddView,
onOpenSettings,
onRename,
onRemove,
openGithub
}: Props = $props()
// Inline rename state
let editingId = $state<string | null>(null)
let editValue = $state('')
let menuOpenId = $state<string | null>(null)
const startRename = (id: string, currentName: string) => {
editingId = id
editValue = currentName
}
const commitRename = () => {
if (editingId && editValue.trim()) {
onRename(editingId, editValue.trim())
}
editingId = null
editValue = ''
}
const cancelRename = () => {
editingId = null
editValue = ''
}
</script>
<div
class="w-[200px] shrink-0 flex flex-col bg-[#f5f5f7] dark:bg-[#0a0a0a] relative"
in:fly={{ x: -200, duration: 200 }}
>
<!-- 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
>
<button
class="opacity-25 hover:opacity-60 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] leading-none"
onclick={() => {
onAddView()
}}
title={$i18n.t('sidebar.addConnection')}
>
<svg
class="w-[14px] h-[14px]"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
</button>
</div>
<!-- Connection list -->
<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)}
<div
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={() => 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">
<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>
</div>
{:else if serverReachable}
<div class="w-[14px] h-[14px] shrink-0 flex items-center justify-center">
<div
class="w-2 h-2 rounded-full bg-emerald-400 shadow-[0_0_6px_rgba(52,211,153,0.5)]"
></div>
</div>
{:else}
<div class="w-[14px] h-[14px] shrink-0 flex items-center justify-center">
<div class="w-2 h-2 rounded-full bg-black/10 dark:bg-white/15"></div>
</div>
{/if}
{#if editingId === localConn.id}
<!-- svelte-ignore a11y_autofocus -->
<input
type="text"
class="text-[12px] bg-transparent text-[#1d1d1f] dark:text-[#fafafa] px-0 py-0 border-none outline-none rounded-md flex-1 min-w-0"
bind:value={editValue}
autofocus
onfocus={(e) => e.currentTarget.select()}
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => {
e.stopPropagation()
if (e.key === 'Enter') commitRename()
if (e.key === 'Escape') cancelRename()
}}
onblur={commitRename}
/>
{:else}
<span
class="text-[12px] {activeConnectionId === localConn.id
? 'font-medium opacity-100'
: 'opacity-70'} transition-opacity truncate flex-1 min-w-0"
>{localConn.name ?? 'Open WebUI'}</span
>
{/if}
<div class="ml-auto relative shrink-0">
<button
class="opacity-20 hover:opacity-70 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] p-0.5 leading-none"
onclick={(e) => {
e.stopPropagation()
menuOpenId = menuOpenId === 'local' ? null : 'local'
}}
>
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 20 20">
<path
d="M6 10a2 2 0 11-4 0 2 2 0 014 0zM12 10a2 2 0 11-4 0 2 2 0 014 0zM18 10a2 2 0 11-4 0 2 2 0 014 0z"
/>
</svg>
</button>
{#if menuOpenId === 'local'}
<div
class="fixed inset-0 z-40"
onclick={(e) => {
e.stopPropagation()
menuOpenId = null
}}
></div>
<div
class="absolute right-0 top-6 z-50 w-[160px] bg-white dark:bg-[#1a1a1a]/90 backdrop-blur-xl border border-black/[0.08] dark:border-white/[0.08] rounded-2xl shadow-2xl py-0.5 overflow-hidden"
in:fly={{ y: -4, duration: 150 }}
out:fade={{ duration: 100 }}
>
<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:hover:bg-white/[0.06] transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-xl"
onclick={(e) => {
e.stopPropagation()
menuOpenId = null
startRename(localConn.id, localConn.name ?? 'Open WebUI')
}}
>
<svg
class="w-[14px] h-[14px] shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L6.832 19.82a4.5 4.5 0 01-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 011.13-1.897L16.863 4.487z"
/>
</svg>
{$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:hover:bg-white/[0.06] transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-xl"
onclick={(e) => {
e.stopPropagation()
menuOpenId = null
window.electronAPI?.openInBrowser?.(localConn.url)
}}
>
<svg
class="w-[14px] h-[14px] shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"
/>
</svg>
{$i18n.t('sidebar.openInBrowser')}
</button>
</div>
</div>
{/if}
</div>
</div>
{/if}
{#if localConn && localInstalled && remoteConnections.length > 0}
<div class="my-1 mx-2 border-t border-black/[0.04] dark:border-white/[0.04]"></div>
{/if}
{#each remoteConnections as conn (conn.id)}
<div
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.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)}
onkeydown={(e) => e.key === 'Enter' && editingId !== conn.id && onConnect(conn.id)}
>
{#if connectingId === conn.id}
<div class="w-[14px] h-[14px] shrink-0 flex items-center justify-center">
<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>
</div>
{:else}
<svg
class="w-[14px] h-[14px] shrink-0 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}
{#if editingId === conn.id}
<!-- svelte-ignore a11y_autofocus -->
<input
type="text"
class="text-[12px] bg-transparent text-[#1d1d1f] dark:text-[#fafafa] px-0 py-0 border-none outline-none rounded-md flex-1 min-w-0"
bind:value={editValue}
autofocus
onfocus={(e) => e.currentTarget.select()}
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => {
e.stopPropagation()
if (e.key === 'Enter') commitRename()
if (e.key === 'Escape') cancelRename()
}}
onblur={commitRename}
/>
{:else}
<span
class="text-[12px] {activeConnectionId === conn.id
? 'font-medium opacity-100'
: 'opacity-70'} transition-opacity truncate flex-1 min-w-0">{conn.name}</span
>
{/if}
<!-- Three-dots menu -->
<div class="ml-auto relative shrink-0">
<button
class="opacity-20 hover:opacity-70 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] p-0.5 leading-none"
onclick={(e) => {
e.stopPropagation()
menuOpenId = menuOpenId === conn.id ? null : conn.id
}}
>
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 20 20">
<path
d="M6 10a2 2 0 11-4 0 2 2 0 014 0zM12 10a2 2 0 11-4 0 2 2 0 014 0zM18 10a2 2 0 11-4 0 2 2 0 014 0z"
/>
</svg>
</button>
{#if menuOpenId === conn.id}
<div
class="fixed inset-0 z-40"
onclick={(e) => {
e.stopPropagation()
menuOpenId = null
}}
></div>
<div
class="absolute right-0 top-6 z-50 w-[160px] bg-white dark:bg-[#1a1a1a]/90 backdrop-blur-xl border border-black/[0.08] dark:border-white/[0.08] rounded-2xl shadow-2xl py-0.5 overflow-hidden"
in:fly={{ y: -4, duration: 150 }}
out:fade={{ duration: 100 }}
>
<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:hover:bg-white/[0.06] transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-xl"
onclick={(e) => {
e.stopPropagation()
menuOpenId = null
startRename(conn.id, conn.name)
}}
>
<svg
class="w-[14px] h-[14px] shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L6.832 19.82a4.5 4.5 0 01-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 011.13-1.897L16.863 4.487z"
/>
</svg>
{$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:hover:bg-white/[0.06] transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-xl"
onclick={(e) => {
e.stopPropagation()
menuOpenId = null
window.electronAPI?.openInBrowser?.(conn.url)
}}
>
<svg
class="w-[14px] h-[14px] shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"
/>
</svg>
{$i18n.t('sidebar.openInBrowser')}
</button>
</div>
<div class="mx-3 border-t border-black/[0.06] dark:border-white/[0.06]"></div>
<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-red-500/10 transition bg-transparent border-none text-red-400 rounded-xl"
onclick={(e) => {
e.stopPropagation()
menuOpenId = null
onRemove(conn.id)
}}
>
<svg
class="w-[14px] h-[14px] shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
/>
</svg>
{$i18n.t('common.delete')}
</button>
</div>
</div>
{/if}
</div>
</div>
{/each}
</div>
<!-- Settings popover -->
{#if settingsOpen}
<div class="fixed inset-0 z-40" onclick={() => (settingsOpen = false)}></div>
<div
class="absolute bottom-12 left-2 right-2 z-50 bg-white dark:bg-[#1a1a1a]/90 backdrop-blur-xl border border-black/[0.08] dark:border-white/[0.08] rounded-2xl shadow-lg py-0.5 overflow-hidden"
in:fly={{ y: 8, duration: 150 }}
out:fade={{ duration: 100 }}
>
<div class="px-3.5 py-2.5 border-b border-black/[0.06] dark:border-white/[0.06]">
<div class="text-[11px] opacity-40">{$i18n.t('app.desktop')}</div>
<div class="text-[10px] opacity-20 mt-0.5">{$appInfo?.version ?? ''}</div>
</div>
<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/4 dark:hover:bg-white/4 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-xl"
onclick={() => {
settingsOpen = false
onOpenSettings()
}}
>
<svg
class="w-[14px] h-[14px] shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 010 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 010-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
{$i18n.t('sidebar.settings')}
</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/4 dark:hover:bg-white/4 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-xl"
onclick={openGithub}
>
<svg
class="w-[14px] h-[14px] shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"
/>
</svg>
{$i18n.t('sidebar.github')}
</button>
</div>
</div>
{/if}
<!-- Settings button (bottom) -->
<div class="px-2 pb-3">
<button
class="w-full flex items-center gap-2 px-2 py-[6px] rounded-xl text-[12px] opacity-80 hover:opacity-70 hover:bg-black/4 dark:hover:bg-white/4 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] text-left"
onclick={() => (settingsOpen = !settingsOpen)}
>
<svg
class="w-[14px] h-[14px] shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 010 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 010-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28z"
/>
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
{$i18n.t('sidebar.settings')}
</button>
</div>
</div>
@@ -0,0 +1,181 @@
<script lang="ts">
import { fade } from 'svelte/transition'
import i18n from '../../../i18n'
import { tooltip } from '../../../actions/tooltip'
import { appInfo } from '../../../stores'
import trayIcon from '../../../../../../../resources/tray.png'
interface Props {
serverStatus: string | undefined
serverReachable: boolean | undefined
openTerminalStatus: string | null
llamaCppStatus: string | null
openWebuiInstalled: boolean
openTerminalInstalled: boolean
llamaCppInstalled: boolean
activeLog: string | null
onSelectLog: (log: string) => void
onStartServer: () => void
onToggleOpenTerminal: () => void
onToggleLlamaCpp: () => void
}
let {
serverStatus,
serverReachable,
openTerminalStatus,
llamaCppStatus,
openWebuiInstalled,
openTerminalInstalled,
llamaCppInstalled,
activeLog,
onSelectLog,
onStartServer,
onToggleOpenTerminal,
onToggleLlamaCpp
}: Props = $props()
// Derived server state
const serverRunning = $derived(serverStatus === 'started' && serverReachable)
const serverStarting = $derived(
serverStatus === 'starting' || (serverStatus === 'started' && !serverReachable)
)
const otRunning = $derived(openTerminalStatus === 'started')
const otStarting = $derived(openTerminalStatus === 'starting' || openTerminalStatus === 'stopping')
const otFailed = $derived(openTerminalStatus === 'failed')
const lsRunning = $derived(llamaCppStatus === 'started')
const lsStarting = $derived(
llamaCppStatus === 'starting' || llamaCppStatus === 'setting-up' || llamaCppStatus === 'stopping'
)
const lsFailed = $derived(llamaCppStatus === 'failed')
// Derived visibility — show each section only when installed or active
const showServer = $derived(openWebuiInstalled || !!serverStatus)
const showTerminal = $derived(openTerminalInstalled || !!openTerminalStatus)
const showLlama = $derived(llamaCppInstalled || !!llamaCppStatus)
</script>
<div
class="shrink-0 flex items-center gap-1 px-3 h-7 border-t border-black/[0.08] dark:border-white/[0.08] bg-[#ebebed] dark:bg-[#111111]"
in:fade={{ duration: 150 }}
>
<!-- Open WebUI logo mark -->
<img src={trayIcon} alt="" class="w-3.5 opacity-30 mx-0.5 shrink-0 invert dark:invert-0" />
{#if showServer}
<!-- Open WebUI status -->
<button
class="flex items-center gap-1.5 px-2 py-0.5 rounded-md text-[11px] transition-all bg-transparent border-none cursor-pointer text-[#1d1d1f] dark:text-[#fafafa] {activeLog === 'server'
? 'bg-black/[0.08] dark:bg-white/[0.1] opacity-90'
: 'opacity-50 hover:opacity-80 hover:bg-black/[0.04] dark:hover:bg-white/[0.06]'}"
onclick={() => {
if (!serverRunning && !serverStarting) {
onStartServer()
}
onSelectLog('server')
}}
use:tooltip={serverRunning
? $i18n.t('statusBar.serverRunning')
: serverStarting
? $i18n.t('common.starting')
: $i18n.t('statusBar.serverStopped')}
>
<div class="w-[7px] h-[7px] shrink-0 rounded-full {serverRunning
? 'bg-emerald-400 shadow-[0_0_5px_rgba(52,211,153,0.6)]'
: serverStarting
? 'bg-amber-400 animate-pulse'
: 'bg-black/15 dark:bg-white/20'}">
</div>
<span>{$i18n.t('statusBar.server')}</span>
</button>
{/if}
{#if showTerminal}
{#if showServer}
<div class="w-px h-3 bg-black/[0.08] dark:bg-white/[0.08] mx-0.5"></div>
{/if}
<!-- Open Terminal status -->
<button
class="flex items-center gap-1.5 px-2 py-0.5 rounded-md text-[11px] transition-all bg-transparent border-none cursor-pointer text-[#1d1d1f] dark:text-[#fafafa] {activeLog === 'open-terminal'
? 'bg-black/[0.08] dark:bg-white/[0.1] opacity-90'
: 'opacity-50 hover:opacity-80 hover:bg-black/[0.04] dark:hover:bg-white/[0.06]'}"
onclick={() => {
if (!otRunning && !otStarting) {
onToggleOpenTerminal()
}
onSelectLog('open-terminal')
}}
oncontextmenu={(e) => {
e.preventDefault()
if (otRunning) onToggleOpenTerminal()
}}
use:tooltip={otRunning
? activeLog === 'open-terminal'
? $i18n.t('sidebar.tooltip.hideLogs')
: $i18n.t('sidebar.tooltip.viewLogs')
: otStarting
? $i18n.t('common.starting')
: otFailed
? $i18n.t('sidebar.tooltip.clickToRetry')
: $i18n.t('sidebar.tooltip.startTerminalServer')}
>
<div class="w-[7px] h-[7px] shrink-0 rounded-full {otRunning
? 'bg-emerald-400 shadow-[0_0_5px_rgba(52,211,153,0.6)]'
: otStarting
? 'bg-amber-400 animate-pulse'
: otFailed
? 'bg-red-400'
: 'bg-black/15 dark:bg-white/20'}">
</div>
<span>{$i18n.t('sidebar.openTerminal')}</span>
</button>
{/if}
{#if showLlama}
{#if showServer || showTerminal}
<div class="w-px h-3 bg-black/[0.08] dark:bg-white/[0.08] mx-0.5"></div>
{/if}
<!-- llama.cpp status -->
<button
class="flex items-center gap-1.5 px-2 py-0.5 rounded-md text-[11px] transition-all bg-transparent border-none cursor-pointer text-[#1d1d1f] dark:text-[#fafafa] {activeLog === 'llama-server'
? 'bg-black/[0.08] dark:bg-white/[0.1] opacity-90'
: 'opacity-50 hover:opacity-80 hover:bg-black/[0.04] dark:hover:bg-white/[0.06]'}"
onclick={() => {
if (!lsRunning && !lsStarting) {
onToggleLlamaCpp()
}
onSelectLog('llama-server')
}}
oncontextmenu={(e) => {
e.preventDefault()
if (lsRunning) onToggleLlamaCpp()
}}
use:tooltip={lsRunning
? activeLog === 'llama-server'
? $i18n.t('sidebar.tooltip.hideLogs')
: $i18n.t('sidebar.tooltip.viewLogs')
: lsStarting
? $i18n.t('common.starting')
: lsFailed
? $i18n.t('sidebar.tooltip.clickToRetry')
: $i18n.t('sidebar.tooltip.startLlamaServer')}
>
<div class="w-[7px] h-[7px] shrink-0 rounded-full {lsRunning
? 'bg-emerald-400 shadow-[0_0_5px_rgba(52,211,153,0.6)]'
: lsStarting
? 'bg-amber-400 animate-pulse'
: lsFailed
? 'bg-red-400'
: 'bg-black/15 dark:bg-white/20'}">
</div>
<span>{$i18n.t('sidebar.llamaCpp')}</span>
</button>
{/if}
<!-- Version (right-aligned) -->
<span class="ml-auto text-[10px] opacity-25 select-none">v{$appInfo?.version ?? ''}</span>
</div>
@@ -0,0 +1,146 @@
<script lang="ts">
import { fade } from 'svelte/transition'
import i18n from '../../i18n'
import General from './Settings/General.svelte'
import OpenWebUI from './Settings/OpenWebUI.svelte'
import Connections from './Settings/Connections.svelte'
import OpenTerminal from './Settings/OpenTerminal.svelte'
import InferenceRuntime from './Settings/InferenceRuntime.svelte'
import Models from './Settings/Models.svelte'
import About from './Settings/About.svelte'
interface Props {
onClose: () => void
}
let { onClose }: Props = $props()
let settingsTab = $state('general')
const tabs = [
{
id: 'general',
label: () => $i18n.t('settings.tabs.general'),
icon: 'M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 010 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 010-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28z',
extra: 'M15 12a3 3 0 11-6 0 3 3 0 016 0z'
},
{
id: 'openwebui',
label: () => $i18n.t('settings.tabs.openwebui'),
icon: 'M21 12a9 9 0 11-18 0 9 9 0 0118 0z',
extra:
'M9 3.51a9.012 9.012 0 016 0M9 20.49a9.012 9.012 0 006 0M3.51 9a9.012 9.012 0 000 6M20.49 9a9.012 9.012 0 010 6M12 3v18M3 12h18'
},
{
id: 'terminal',
label: () => $i18n.t('settings.tabs.terminal'),
icon: 'M6.75 7.5l3 2.25-3 2.25m4.5 0h3m-9 8.25h13.5A2.25 2.25 0 0021 18V6a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 6v12a2.25 2.25 0 002.25 2.25z'
},
{
id: 'inference',
label: () => $i18n.t('settings.tabs.inference'),
icon: 'M8.25 3v1.5M4.5 8.25H3m18 0h-1.5M4.5 12H3m18 0h-1.5m-15 3.75H3m18 0h-1.5M8.25 19.5V21M12 3v1.5m0 15V21m3.75-18v1.5m0 15V21m-9-1.5h10.5a2.25 2.25 0 002.25-2.25V6.75a2.25 2.25 0 00-2.25-2.25H6.75A2.25 2.25 0 004.5 6.75v10.5a2.25 2.25 0 002.25 2.25zm.75-12h9v9h-9v-9z'
},
{
id: 'models',
label: () => $i18n.t('settings.tabs.models'),
icon: 'M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75m16.5 0c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125'
},
{
id: 'connections',
label: () => $i18n.t('settings.tabs.connections'),
icon: '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'
},
{
id: 'about',
label: () => $i18n.t('settings.tabs.about'),
icon: 'M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z'
}
]
</script>
<div
class="h-full w-full flex bg-[#f5f5f7] dark:bg-[#0a0a0a] text-[#1d1d1f] dark:text-[#fafafa]"
in:fade={{ duration: 150 }}
>
<!-- Settings sidebar -->
<div
class="w-[180px] shrink-0 flex flex-col border-r border-black/[0.06] dark:border-white/[0.06] bg-[#eee] dark:bg-[#111] px-1.5"
>
<div class="h-4 shrink-0"></div>
<div class="px-3 pb-3">
<span class="text-[13px] opacity-60 font-medium">{$i18n.t('settings.title')}</span>
</div>
<div class="flex flex-col gap-0.5 px-1">
{#each tabs as tab}
<button
class="flex items-center gap-2 px-2.5 py-[6px] rounded-2xl text-[12px] transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] text-left w-full {settingsTab ===
tab.id
? 'bg-black/[0.06] dark:bg-white/[0.08] opacity-90'
: 'opacity-40 hover:opacity-70 hover:bg-black/[0.02] '}"
onclick={() => (settingsTab = tab.id)}
>
<svg
class="w-[14px] h-[14px] shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path stroke-linecap="round" stroke-linejoin="round" d={tab.icon} />
{#if tab.extra}
<path stroke-linecap="round" stroke-linejoin="round" d={tab.extra} />
{/if}
</svg>
{tab.label()}
</button>
{/each}
</div>
</div>
<div class="flex-1 min-w-0 flex flex-col overflow-hidden">
<!-- Content header -->
<div
class="flex items-center justify-between px-8 pt-5 pb-3 border-b border-black/[0.04] dark:border-white/[0.04]"
>
<span class="text-[15px] opacity-80 font-medium"
>{tabs.find((t) => t.id === settingsTab)?.label() ?? settingsTab}</span
>
<button
class="opacity-30 hover:opacity-70 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa]"
onclick={onClose}
title={$i18n.t('settings.closeSettings')}
>
<svg
class="w-[14px] h-[14px]"
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>
<div class="flex-1 overflow-y-auto px-8 py-4">
{#if settingsTab === 'general'}
<General />
{:else if settingsTab === 'openwebui'}
<OpenWebUI />
{:else if settingsTab === 'connections'}
<Connections />
{:else if settingsTab === 'terminal'}
<OpenTerminal />
{:else if settingsTab === 'inference'}
<InferenceRuntime />
{:else if settingsTab === 'models'}
<Models />
{:else if settingsTab === 'about'}
<About />
{/if}
</div>
</div>
</div>
@@ -0,0 +1,540 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte'
import { appInfo } from '../../../stores'
import i18n from '../../../i18n'
import logoImage from '../../../assets/images/splash-dark.png'
let openWebuiVersion = $state<string | null>(null)
let openTerminalVersion = $state<string | null>(null)
let llamaCppVersion = $state<string | null>(null)
// Update state
type UpdateStatus = 'idle' | 'checking' | 'available' | 'downloading' | 'downloaded' | 'up-to-date' | 'error'
let updateStatus = $state<UpdateStatus>('idle')
let updateVersion = $state<string | null>(null)
let downloadPercent = $state(0)
let updateError = $state<string | null>(null)
let cleanupDataListener: (() => void) | null = null
// Changelog state
let changelogOpen = $state(false)
let changelogLoading = $state(false)
let changelogEntries = $state<{ version: string; date: string; body: string }[]>([])
// ── Easter Egg ────────────────────────────────────────
let clickCount = $state(0)
let clickTimer: ReturnType<typeof setTimeout> | null = null
let easterEggActive = $state(false)
let dismissTimer: ReturnType<typeof setTimeout> | null = null
let showReveal = $state(false)
let typewriterText = $state('')
let showTypewriter = $state(false)
let typewriterTimers: ReturnType<typeof setTimeout>[] = []
const handleVersionClick = () => {
clickCount++
if (clickTimer) clearTimeout(clickTimer)
clickTimer = setTimeout(() => { clickCount = 0 }, 800)
if (clickCount >= 7) {
clickCount = 0
if (clickTimer) clearTimeout(clickTimer)
activateEasterEgg()
}
}
const activateEasterEgg = () => {
easterEggActive = true
showReveal = false
showTypewriter = true
typewriterText = ''
// Go fullscreen
document.documentElement.requestFullscreen?.().catch(() => {})
// Full Matrix intro sequence
const username = $appInfo?.username ?? 'Neo'
const lines = [
`Wake up, ${username}...`,
'The Matrix has you...',
'Follow the white rabbit.',
`Knock, knock, ${username}.`
]
let lineIdx = 0
const typeLine = () => {
if (lineIdx >= lines.length) {
// All lines done — show logo with knock sound
showTypewriter = false
typewriterTimers.push(setTimeout(() => {
showReveal = true
}, 800))
return
}
const line = lines[lineIdx]
let charIdx = 0
typewriterText = ''
showTypewriter = true
const typeChar = () => {
if (charIdx < line.length) {
typewriterText = line.slice(0, charIdx + 1)
charIdx++
typewriterTimers.push(setTimeout(typeChar, 140 + Math.random() * 60))
} else {
// Hold, then clear and move to next line
typewriterTimers.push(setTimeout(() => {
typewriterText = ''
showTypewriter = false
lineIdx++
typewriterTimers.push(setTimeout(typeLine, 600))
}, 1800))
}
}
typewriterTimers.push(setTimeout(typeChar, 400))
}
typewriterTimers.push(setTimeout(typeLine, 1200))
// Auto-dismiss after 25 seconds
dismissTimer = setTimeout(() => dismissEasterEgg(), 25000)
}
const dismissEasterEgg = () => {
showReveal = false
showTypewriter = false
typewriterText = ''
typewriterTimers.forEach(t => clearTimeout(t))
typewriterTimers = []
if (dismissTimer) {
clearTimeout(dismissTimer)
dismissTimer = null
}
// Exit fullscreen first, then remove overlay after transition
if (document.fullscreenElement) {
document.exitFullscreen?.().then(() => {
easterEggActive = false
}).catch(() => {
easterEggActive = false
})
} else {
easterEggActive = false
}
}
onMount(async () => {
openWebuiVersion = await window.electronAPI.getPackageVersion('open-webui')
openTerminalVersion = await window.electronAPI.getPackageVersion('open-terminal')
try {
const info = await window.electronAPI.getLlamaCppInfo()
llamaCppVersion = info?.version ?? null
} catch {}
// Listen for update events from main process
cleanupDataListener = window.electronAPI.onData((data: any) => {
switch (data.type) {
case 'update:checking':
updateStatus = 'checking'
updateError = null
break
case 'update:available':
updateStatus = 'available'
updateVersion = data.data?.version ?? null
break
case 'update:not-available':
updateStatus = 'up-to-date'
break
case 'update:download-progress':
updateStatus = 'downloading'
downloadPercent = Math.round(data.data?.percent ?? 0)
break
case 'update:downloaded':
updateStatus = 'downloaded'
downloadPercent = 100
break
case 'update:error':
updateStatus = 'error'
updateError = data.data?.message ?? 'Unknown error'
break
}
})
})
onDestroy(() => {
cleanupDataListener?.()
if (dismissTimer) clearTimeout(dismissTimer)
if (clickTimer) clearTimeout(clickTimer)
typewriterTimers.forEach(t => clearTimeout(t))
})
const openRelease = (repo: string, version: string, prefix = 'v') => {
window.electronAPI?.openInBrowser?.(`https://github.com/${repo}/releases/tag/${prefix}${version}`)
}
const openGithub = () => {
window.electronAPI?.openInBrowser?.('https://github.com/open-webui/desktop')
}
const handleCheck = async () => {
updateStatus = 'checking'
updateError = null
try {
await window.electronAPI.checkForUpdates()
} catch (e: any) {
updateStatus = 'error'
updateError = e?.message ?? 'Check failed'
}
}
const handleDownload = async () => {
updateStatus = 'downloading'
downloadPercent = 0
try {
await window.electronAPI.downloadUpdate()
} catch (e: any) {
updateStatus = 'error'
updateError = e?.message ?? 'Download failed'
}
}
const handleInstall = () => {
window.electronAPI.installUpdate()
}
const toggleChangelog = async () => {
changelogOpen = !changelogOpen
if (changelogOpen && changelogEntries.length === 0) {
changelogLoading = true
try {
const md = await window.electronAPI.getChangelog()
if (md) parseChangelog(md)
} finally {
changelogLoading = false
}
}
}
const parseChangelog = (md: string) => {
const entries: { version: string; date: string; body: string }[] = []
const sections = md.split(/^## /m).slice(1)
for (const section of sections) {
const headerMatch = section.match(/^\[([^\]]+)\](?:\s*-\s*(.+))?/)
if (!headerMatch) continue
const version = headerMatch[1]
const date = headerMatch[2]?.trim() ?? ''
const body = section.slice(section.indexOf('\n') + 1).trim()
if (version === 'Unreleased' && !body) continue
entries.push({ version, date, body })
}
changelogEntries = entries
}
const renderMarkdown = (md: string): string => {
return md
.replace(/^### (.+)$/gm, '<div class="text-[11px] opacity-50 font-semibold mt-3 mb-1">$1</div>')
.replace(/^- (.+)$/gm, '<div class="text-[11px] opacity-40 pl-2 leading-relaxed">• $1</div>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/`(.+?)`/g, '<code class="text-[10px] bg-white/[0.06] px-1 py-0.5 rounded">$1</code>')
}
</script>
<div class="flex flex-col divide-y divide-white/[0.04]">
<button
class="w-full py-4 flex items-center justify-between bg-transparent border-none cursor-default text-[#1d1d1f] dark:text-[#fafafa]"
onclick={handleVersionClick}
>
<div class="text-[13px] opacity-70">{$i18n.t('settings.about.desktopVersion')}</div>
<div class="text-[12px] opacity-30">{$appInfo?.version ?? $i18n.t('common.unknown')}</div>
</button>
{#if openWebuiVersion}
<button
class="w-full py-4 flex items-center justify-between bg-transparent border-none cursor-pointer group"
onclick={() => openRelease('open-webui/open-webui', openWebuiVersion!)}
>
<div class="text-[13px] opacity-70">{$i18n.t('settings.about.openWebuiVersion')}</div>
<div class="text-[12px] opacity-30 group-hover:opacity-50 transition">{openWebuiVersion}</div>
</button>
{/if}
{#if openTerminalVersion}
<button
class="w-full py-4 flex items-center justify-between bg-transparent border-none cursor-pointer group"
onclick={() => openRelease('open-webui/open-terminal', openTerminalVersion!)}
>
<div class="text-[13px] opacity-70">{$i18n.t('settings.about.openTerminalVersion')}</div>
<div class="text-[12px] opacity-30 group-hover:opacity-50 transition">{openTerminalVersion}</div>
</button>
{/if}
{#if llamaCppVersion}
<button
class="w-full py-4 flex items-center justify-between bg-transparent border-none cursor-pointer group"
onclick={() => openRelease('ggml-org/llama.cpp', llamaCppVersion!, '')}
>
<div class="text-[13px] opacity-70">{$i18n.t('settings.about.llamaCppVersion')}</div>
<div class="text-[12px] opacity-30 group-hover:opacity-50 transition">{llamaCppVersion}</div>
</button>
{/if}
<div class="py-4 flex items-center justify-between">
<div class="text-[13px] opacity-70">{$i18n.t('settings.about.platform')}</div>
<div class="text-[12px] opacity-30">{$appInfo?.platform ?? $i18n.t('common.unknown')}</div>
</div>
<!-- Update section -->
<div class="py-4">
<div class="flex items-center justify-between">
<div>
<div class="text-[13px] opacity-70">{$i18n.t('settings.about.softwareUpdate')}</div>
{#if updateStatus === 'up-to-date'}
<div class="text-[11px] opacity-25 mt-0.5">{$i18n.t('settings.about.upToDate')}</div>
{:else if updateStatus === 'available' && updateVersion}
<div class="text-[11px] opacity-40 mt-0.5">{$i18n.t('settings.about.versionAvailable', { version: updateVersion })}</div>
{:else if updateStatus === 'downloading'}
<div class="text-[11px] opacity-25 mt-0.5">{$i18n.t('settings.about.downloadingPercent', { percent: downloadPercent })}</div>
{:else if updateStatus === 'downloaded'}
<div class="text-[11px] opacity-40 mt-0.5">{$i18n.t('settings.about.updateReady')}</div>
{:else if updateStatus === 'error'}
<div class="text-[11px] text-red-400/60 mt-0.5">{updateError}</div>
{/if}
</div>
<div>
{#if updateStatus === 'idle' || updateStatus === 'up-to-date' || updateStatus === 'error'}
<button
class="text-[12px] opacity-40 hover:opacity-70 px-3 py-1.5 bg-black/[0.04] dark:bg-white/[0.06] transition border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-xl"
onclick={handleCheck}
>
{$i18n.t('settings.about.checkForUpdates')}
</button>
{:else if updateStatus === 'checking'}
<button
class="text-[12px] opacity-30 px-3 py-1.5 bg-black/[0.04] dark:bg-white/[0.06] border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-xl pointer-events-none flex items-center gap-1.5"
disabled
>
<svg class="w-3 h-3 animate-spin" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3" stroke-dasharray="31.4 31.4" stroke-linecap="round" />
</svg>
{$i18n.t('settings.about.checking')}
</button>
{:else if updateStatus === 'available'}
<button
class="text-[12px] opacity-50 hover:opacity-80 px-3 py-1.5 bg-black/[0.06] dark:bg-white/[0.08] transition border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-xl"
onclick={handleDownload}
>
{$i18n.t('settings.about.downloadUpdate')}
</button>
{:else if updateStatus === 'downloading'}
<div class="flex items-center gap-2">
<div class="w-24 h-1.5 bg-black/[0.06] dark:bg-white/[0.06] rounded-full overflow-hidden">
<div
class="h-full bg-black/[0.15] dark:bg-white/30 rounded-full transition-all duration-300"
style="width: {downloadPercent}%"
></div>
</div>
<span class="text-[11px] opacity-30">{downloadPercent}%</span>
</div>
{:else if updateStatus === 'downloaded'}
<button
class="text-[12px] opacity-50 hover:opacity-80 px-3 py-1.5 bg-black/[0.06] dark:bg-white/[0.08] transition border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-xl"
onclick={handleInstall}
>
{$i18n.t('settings.about.restartToUpdate')}
</button>
{/if}
</div>
</div>
</div>
<!-- Changelog section -->
<div class="py-4">
<button
class="text-[12px] opacity-40 hover:opacity-70 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] flex items-center gap-1.5"
onclick={toggleChangelog}
>
<svg
class="w-3 h-3 transition-transform {changelogOpen ? '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>
{$i18n.t('settings.about.whatsNew')}
</button>
{#if changelogOpen}
<div class="mt-3 max-h-64 overflow-y-auto pr-1">
{#if changelogLoading}
<div class="text-[11px] opacity-25">{$i18n.t('common.loading')}</div>
{:else if changelogEntries.length === 0}
<div class="text-[11px] opacity-25">{$i18n.t('settings.about.noChangelog')}</div>
{:else}
{#each changelogEntries as entry, i}
{#if i > 0}
<div class="border-t border-white/[0.04] my-3"></div>
{/if}
<div>
<div class="flex items-baseline gap-2">
<span class="text-[12px] opacity-60 font-medium">{entry.version}</span>
{#if entry.date}
<span class="text-[10px] opacity-20">{entry.date}</span>
{/if}
</div>
{#if entry.body}
<div class="mt-1">
{@html renderMarkdown(entry.body)}
</div>
{/if}
</div>
{/each}
{/if}
</div>
{/if}
</div>
<div class="py-4">
<button
class="text-[12px] opacity-40 hover:opacity-70 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa]"
onclick={openGithub}
>
{$i18n.t('settings.about.viewOnGithub')}
</button>
</div>
</div>
<div class="text-[10px] opacity-15 mt-4 leading-relaxed">{$i18n.t('settings.about.copyright')}<br />{$i18n.t('settings.about.createdBy')}</div>
<!-- Easter Egg: Matrix Rain Overlay -->
{#if easterEggActive}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="matrix-overlay" onclick={dismissEasterEgg}>
{#if showTypewriter}
<div class="matrix-typewriter">
<span>{typewriterText}</span><span class="cursor"></span>
</div>
{/if}
{#if showReveal}
<div class="matrix-reveal">
<img src={logoImage} alt="Open WebUI" class="matrix-logo-img" />
</div>
{/if}
</div>
{/if}
<style>
.matrix-overlay {
position: fixed;
inset: 0;
z-index: 9999;
background: #000;
cursor: pointer;
animation: matrixFadeIn 1.5s ease-out;
}
.matrix-typewriter {
position: absolute;
top: 80px;
left: 60px;
pointer-events: none;
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', Menlo, monospace;
font-size: 15px;
color: #15b800;
letter-spacing: 1px;
}
.matrix-typewriter .cursor {
animation: blink 0.8s step-end infinite;
opacity: 0.8;
}
.matrix-reveal {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
pointer-events: none;
animation: ghostReveal 3s ease-out both;
}
.matrix-logo-img {
width: 100px;
height: 100px;
object-fit: contain;
filter: drop-shadow(0 0 20px rgba(57, 200, 20, 0.3)) drop-shadow(0 0 40px rgba(57, 200, 20, 0.15))
brightness(0.8) sepia(1) saturate(3) hue-rotate(70deg);
animation: ghostPulse 4s ease-in-out infinite, glitch 8s ease-in-out infinite;
}
@keyframes matrixFadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes ghostReveal {
0% {
opacity: 0;
transform: scale(0.95);
filter: blur(8px);
}
60% {
opacity: 0.6;
filter: blur(2px);
}
100% {
opacity: 1;
transform: scale(1);
filter: blur(0);
}
}
@keyframes ghostPulse {
0%, 100% {
filter: drop-shadow(0 0 20px rgba(57, 200, 20, 0.3)) drop-shadow(0 0 40px rgba(57, 200, 20, 0.15))
brightness(0.8) sepia(1) saturate(3) hue-rotate(70deg);
opacity: 0.8;
}
50% {
filter: drop-shadow(0 0 30px rgba(57, 200, 20, 0.5)) drop-shadow(0 0 60px rgba(57, 200, 20, 0.25))
brightness(0.9) sepia(1) saturate(3) hue-rotate(70deg);
opacity: 1;
}
}
@keyframes glitch {
0%, 94%, 100% {
transform: translate(0);
}
95% {
transform: translate(-2px, 1px);
}
96% {
transform: translate(2px, -1px);
}
97% {
transform: translate(0);
}
}
@keyframes blink {
0%, 100% { opacity: 0.8; }
50% { opacity: 0; }
}
@keyframes typewriterFade {
from { opacity: 0; }
to { opacity: 1; }
}
</style>
@@ -0,0 +1,53 @@
<script lang="ts">
import { connections, config } from '../../../stores'
import i18n from '../../../i18n'
const remove = async (id: string) => {
await window.electronAPI.removeConnection(id)
config.set(await window.electronAPI.getConfig())
}
</script>
<div class="flex flex-col divide-y divide-white/[0.04]">
{#each $connections as conn}
<div class="py-3 flex items-center justify-between">
<div class="flex items-center gap-2.5 min-w-0">
<svg
class="w-[14px] h-[14px] shrink-0 opacity-30"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
{#if conn.type === 'local'}
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25A2.25 2.25 0 015.25 3h13.5A2.25 2.25 0 0121 5.25z"
/>
{:else}
<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"
/>
{/if}
</svg>
<div class="min-w-0">
<div class="text-[13px] opacity-70 truncate">{conn.name}</div>
<div class="text-[11px] opacity-25 truncate mt-0.5">{conn.url}</div>
</div>
</div>
<button
class="text-[11px] opacity-30 hover:opacity-60 px-2 py-1 bg-transparent transition border-none text-[#1d1d1f] dark:text-[#fafafa] shrink-0"
onclick={() => remove(conn.id)}
>
{$i18n.t('common.remove')}
</button>
</div>
{/each}
{#if ($connections ?? []).length === 0}
<div class="py-6 text-[12px] opacity-20 text-center">{$i18n.t('settings.connections.noConnections')}</div>
{/if}
</div>
@@ -0,0 +1,827 @@
<script lang="ts">
import { onMount } from 'svelte'
import { connections, config } from '../../../stores'
import i18n, { getLanguages, changeLanguage } from '../../../i18n'
import Switch from '../../common/Switch.svelte'
let launchAtLogin = $state(false)
let runInBackground = $state(true)
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 }[]>([])
// Language state
let languages = $state<{ code: string; title: string }[]>([])
let selectedLanguage = $state('en-US')
onMount(async () => {
launchAtLogin = await window.electronAPI.getLaunchAtLogin()
const cfg = await window.electronAPI.getConfig()
runInBackground = cfg?.runInBackground ?? true
const vars = cfg?.envVars ?? {}
envEntries = Object.entries(vars).map(([key, value]) => ({ key, value: value as string }))
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'
})
const applyThemeClass = (t: string) => {
let resolved = t
if (t === 'system') {
resolved = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
document.documentElement.classList.remove('light', 'dark')
document.documentElement.classList.add(resolved)
}
const applyTheme = async (newTheme: string) => {
theme = newTheme
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) => {
await window.electronAPI.setDefaultConnection(id)
config.set(await window.electronAPI.getConfig())
}
const saveEnvVars = async () => {
const envVars: Record<string, string> = {}
for (const entry of envEntries) {
const k = entry.key.trim()
if (k) envVars[k] = entry.value
}
await window.electronAPI.setConfig({ envVars })
config.set(await window.electronAPI.getConfig())
}
const addEnvVar = () => {
envEntries = [...envEntries, { key: '', value: '' }]
}
const removeEnvVar = (index: number) => {
envEntries = envEntries.filter((_, i) => i !== index)
saveEnvVars()
}
// Shortcut recorder
let shortcutValue = $state('')
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) {
shortcutValue = $config.globalShortcut ?? ''
}
})
$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')
if (e.altKey) parts.push('Alt')
if (e.shiftKey) parts.push('Shift')
// Ignore bare modifier presses
const ignore = ['Control', 'Meta', 'Alt', 'Shift']
if (ignore.includes(e.key)) return null
// 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',
Backquote: '`',
Minus: '-',
Equal: '=',
BracketLeft: '[',
BracketRight: ']',
Backslash: '\\',
Semicolon: ';',
Quote: "'",
Comma: ',',
Period: '.',
Slash: '/'
}
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('+')
}
const displayShortcut = (accel: string): string => {
if (!accel) return ''
const isMac = navigator.platform.includes('Mac')
return accel
.replace(/CommandOrControl/g, isMac ? '⌘' : 'Ctrl')
.replace(/Alt/g, isMac ? '⌥' : 'Alt')
.replace(/Shift/g, isMac ? '⇧' : 'Shift')
.replace(/\+/g, ' + ')
}
const handleShortcutKeydown = async (e: KeyboardEvent) => {
e.preventDefault()
e.stopPropagation()
if (e.key === 'Escape') {
recording = false
return
}
if (e.key === 'Backspace' || e.key === 'Delete') {
shortcutValue = ''
recording = false
await window.electronAPI.setConfig({ globalShortcut: '' })
config.set(await window.electronAPI.getConfig())
return
}
const accel = keyToElectron(e)
if (accel) {
shortcutValue = accel
recording = false
await window.electronAPI.setConfig({ globalShortcut: accel })
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]">
<div class="py-4 flex items-center justify-between">
<div>
<div class="text-[13px] opacity-70">{$i18n.t('settings.general.language')}</div>
<div class="text-[11px] opacity-25 mt-0.5">{$i18n.t('settings.general.languageDesc')}</div>
</div>
<select
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"
onchange={async (e) => {
const lang = (e.target as HTMLSelectElement).value
selectedLanguage = lang
localStorage.setItem('locale', lang)
changeLanguage(lang)
await window.electronAPI.setConfig({ language: lang })
config.set(await window.electronAPI.getConfig())
}}
>
{#each languages as lang}
<option value={lang.code} selected={selectedLanguage === lang.code}>{lang.title}</option>
{/each}
</select>
</div>
<div class="py-4 flex items-center justify-between">
<div>
<div class="text-[13px] opacity-70">{$i18n.t('settings.general.appearance')}</div>
<div class="text-[11px] opacity-25 mt-0.5">{$i18n.t('settings.general.appearanceDesc')}</div>
</div>
<div class="grid grid-cols-3 items-center gap-0.5 rounded-2xl bg-black/[0.04] dark:bg-white/[0.06] p-1 text-[11px]">
<button
class="flex h-6 w-16 items-center justify-center rounded-xl border-none transition {theme === 'system' ? 'bg-black/[0.08] dark:bg-white/[0.12] text-[#1d1d1f] dark:text-[#fafafa]' : 'bg-transparent text-[#1d1d1f] dark:text-[#fafafa] opacity-40 hover:opacity-70'}"
onclick={() => applyTheme('system')}
>
{$i18n.t('common.auto')}
</button>
<button
class="flex h-6 w-16 items-center justify-center rounded-xl border-none transition {theme === 'light' ? 'bg-black/[0.08] dark:bg-white/[0.12] text-[#1d1d1f] dark:text-[#fafafa]' : 'bg-transparent text-[#1d1d1f] dark:text-[#fafafa] opacity-40 hover:opacity-70'}"
onclick={() => applyTheme('light')}
aria-label={$i18n.t('settings.general.light')}
>
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386-1.591 1.591M21 12h-2.25m-.386 6.364-1.591-1.591M12 18.75V21m-4.773-4.227-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0Z" />
</svg>
</button>
<button
class="flex h-6 w-16 items-center justify-center rounded-xl border-none transition {theme === 'dark' ? 'bg-black/[0.08] dark:bg-white/[0.12] text-[#1d1d1f] dark:text-[#fafafa]' : 'bg-transparent text-[#1d1d1f] dark:text-[#fafafa] opacity-40 hover:opacity-70'}"
onclick={() => applyTheme('dark')}
aria-label={$i18n.t('settings.general.dark')}
>
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.72 9.72 0 0 1 18 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 0 0 3 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 0 0 9.002-5.998Z" />
</svg>
</button>
</div>
</div>
<div class="py-4 flex items-center justify-between">
<div>
<div class="text-[13px] opacity-70">{$i18n.t('settings.general.defaultConnection')}</div>
<div class="text-[11px] opacity-25 mt-0.5">{$i18n.t('settings.general.defaultConnectionDesc')}</div>
</div>
<select
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"
onchange={(e) => setDefault((e.target as HTMLSelectElement).value)}
>
<option value="">{$i18n.t('common.none')}</option>
{#each $connections as conn}
<option value={conn.id} selected={$config?.defaultConnectionId === conn.id}
>{conn.name}</option
>
{/each}
</select>
</div>
<div class="py-4 flex items-center justify-between">
<div>
<div class="text-[13px] opacity-70">{$i18n.t('settings.general.launchAtLogin')}</div>
<div class="text-[11px] opacity-25 mt-0.5">{$i18n.t('settings.general.launchAtLoginDesc')}</div>
</div>
<Switch
checked={launchAtLogin}
label={$i18n.t('settings.general.toggleLaunchAtLogin')}
onchange={async (value) => {
launchAtLogin = value
await window.electronAPI.setLaunchAtLogin(launchAtLogin)
}}
/>
</div>
<div class="py-4 flex items-center justify-between">
<div>
<div class="text-[13px] opacity-70">{$i18n.t('settings.general.runInBackground')}</div>
<div class="text-[11px] opacity-25 mt-0.5">{$i18n.t('settings.general.runInBackgroundDesc')}</div>
</div>
<Switch
checked={runInBackground}
label={$i18n.t('settings.general.toggleRunInBackground')}
onchange={async (value) => {
runInBackground = value
await window.electronAPI.setConfig({ runInBackground })
config.set(await window.electronAPI.getConfig())
}}
/>
</div>
<div class="py-4 flex items-center justify-between">
<div>
<div class="text-[13px] opacity-70">{$i18n.t('settings.general.globalShortcut')}</div>
<div class="text-[11px] opacity-25 mt-0.5">
{#if recording}
{$i18n.t('settings.general.globalShortcutRecording')}
{:else}
{$i18n.t('settings.general.globalShortcutDesc')}
{/if}
</div>
</div>
<div class="flex items-center gap-1.5">
<button
bind:this={shortcutInputEl}
class="text-[12px] px-3 py-1.5 border-none outline-none rounded-xl transition min-w-[80px] text-center
{recording
? '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={() => {
recording = true
shortcutInputEl?.focus()
}}
onkeydown={(e) => {
if (recording) handleShortcutKeydown(e)
}}
onblur={() => {
recording = false
}}
>
{#if recording}
<span class="text-[11px]">{$i18n.t('settings.general.pressShortcut')}</span>
{:else if shortcutValue}
{displayShortcut(shortcutValue)}
{:else}
<span class="opacity-40">{$i18n.t('common.disabled')}</span>
{/if}
</button>
{#if shortcutValue && !recording}
<button
class="opacity-20 hover:opacity-50 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] p-0.5 shrink-0"
onclick={async () => {
shortcutValue = ''
await window.electronAPI.setConfig({ globalShortcut: '' })
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">{$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
class="flex items-center gap-1.5 bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] p-0 cursor-pointer"
onclick={() => { advancedOpen = !advancedOpen }}
>
<svg
class="w-3 h-3 opacity-30 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-[13px] opacity-50">{$i18n.t('common.advanced')}</span>
</button>
{#if advancedOpen}
<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">
<div>
<div class="text-[13px] opacity-70">{$i18n.t('settings.general.environmentVariables')}</div>
<div class="text-[11px] opacity-25 mt-0.5">{$i18n.t('settings.general.environmentVariablesDesc')}</div>
</div>
<button
class="text-[11px] opacity-30 hover:opacity-60 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa]"
onclick={addEnvVar}
>
{$i18n.t('common.add')}
</button>
</div>
{#if envEntries.length > 0}
<div class="flex flex-col gap-2">
{#each envEntries as entry, i}
<div class="flex items-center gap-2">
<input
type="text"
placeholder={$i18n.t('settings.general.keyPlaceholder')}
class="bg-black/[0.04] dark:bg-white/[0.06] text-[12px] text-[#1d1d1f] dark:text-[#fafafa] px-2.5 py-1.5 border-none outline-none rounded-lg opacity-60 flex-1 min-w-0 font-mono"
value={entry.key}
oninput={(e) => { envEntries[i].key = (e.target as HTMLInputElement).value }}
onblur={saveEnvVars}
/>
<span class="text-[11px] opacity-20">=</span>
<input
type="text"
placeholder="value"
class="bg-black/[0.04] dark:bg-white/[0.06] text-[12px] text-[#1d1d1f] dark:text-[#fafafa] px-2.5 py-1.5 border-none outline-none rounded-lg opacity-60 flex-[2] min-w-0 font-mono"
value={entry.value}
oninput={(e) => { envEntries[i].value = (e.target as HTMLInputElement).value }}
onblur={saveEnvVars}
/>
<button
class="opacity-20 hover:opacity-50 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] p-0.5 shrink-0"
onclick={() => removeEnvVar(i)}
>
<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>
</div>
{/each}
</div>
{:else}
<div class="text-[11px] opacity-15">{$i18n.t('settings.general.noEnvVars')}</div>
{/if}
</div>
<div class="py-4 flex items-center justify-between">
<div>
<div class="text-[13px] opacity-70">{$i18n.t('settings.general.factoryReset')}</div>
<div class="text-[11px] opacity-25 mt-0.5">
{$i18n.t('settings.general.factoryResetDesc')}
</div>
</div>
<button
class="text-[12px] opacity-40 hover:opacity-70 px-3 py-1.5 bg-black/[0.04] dark:bg-white/[0.06] transition border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-xl flex items-center gap-1.5 {resetting ? 'pointer-events-none opacity-30' : ''}"
disabled={resetting}
onclick={async () => {
if (
confirm(
$i18n.t('settings.general.factoryResetConfirm')
)
) {
resetting = true
await window.electronAPI.resetApp()
window.location.reload()
}
}}
>
{#if resetting}
<svg class="w-3 h-3 animate-spin" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3" stroke-dasharray="31.4 31.4" stroke-linecap="round" />
</svg>
{$i18n.t('common.resetting')}
{:else}
{$i18n.t('common.reset')}
{/if}
</button>
</div>
</div>
{/if}
</div>
</div>
@@ -0,0 +1,532 @@
<script lang="ts">
import { onMount } from 'svelte'
import { config } from '../../../stores'
import i18n from '../../../i18n'
import Switch from '../../common/Switch.svelte'
let lsInfo = $state<{ url?: string; status?: string; pid?: number; binaryPath?: string } | null>(null)
let stopping = $state(false)
let starting = $state(false)
let restarting = $state(false)
let settingUp = $state(false)
let loaded = $state(false)
let setupStatus = $state('')
let uninstalling = $state(false)
let installing = $state(false)
type UpdateStatus = 'idle' | 'checking' | 'available' | 'updating' | 'up-to-date' | 'error'
let updateStatus = $state<UpdateStatus>('idle')
let updateInfo = $state<{ currentVersion: string | null; latestVersion: string | null; updateAvailable: boolean } | null>(null)
let updateError = $state<string | null>(null)
onMount(async () => {
lsInfo = await window.electronAPI.getLlamaCppInfo()
loaded = true
window.electronAPI.onData((data: any) => {
if (data.type === 'status:llamacpp') {
lsInfo = { ...lsInfo, status: data.data }
}
if (data.type === 'status:llamacpp-setup') {
setupStatus = data.data ?? ''
}
if (data.type === 'llamacpp:ready') {
lsInfo = { ...lsInfo, ...data.data, status: 'started' }
}
})
})
const isRunning = $derived(lsInfo?.status === 'started')
const updateConfig = async (key: string, value: any) => {
const current = $config ?? {}
const llamaCpp = { ...(current.llamaCpp ?? {}), [key]: value }
await window.electronAPI.setConfig({ llamaCpp })
config.set(await window.electronAPI.getConfig())
}
const platform = $derived((() => {
const info = navigator.userAgent
if (info.includes('Mac')) return 'darwin'
if (info.includes('Win')) return 'win32'
return 'linux'
})())
const variantOptions = $derived((() => {
const autoOption = { value: 'auto', label: $i18n.t('settings.inference.variantAuto') }
if (platform === 'darwin') return [autoOption, { value: 'cpu', label: $i18n.t('settings.inference.variantDefaultMetal') }]
if (platform === 'win32') return [
autoOption,
{ value: 'cpu', label: $i18n.t('settings.inference.variantCPU') },
{ value: 'cuda-12.4', label: 'CUDA 12.4' },
{ value: 'cuda-13.1', label: 'CUDA 13.1' },
{ value: 'vulkan', label: 'Vulkan' }
]
return [
autoOption,
{ value: 'cpu', label: $i18n.t('settings.inference.variantCPU') },
{ value: 'vulkan', label: 'Vulkan' },
{ value: 'rocm', label: 'ROCm' }
]
})())
const setupServer = async () => {
settingUp = true
setupStatus = ''
try {
await window.electronAPI.setupLlamaCpp()
lsInfo = await window.electronAPI.getLlamaCppInfo()
} catch (e) {
console.error('Failed to setup llama-server:', e)
}
settingUp = false
setupStatus = ''
}
const startServer = async () => {
starting = true
setupStatus = ''
try {
const result = await window.electronAPI.startLlamaCpp()
lsInfo = await window.electronAPI.getLlamaCppInfo()
} catch (e) {
console.error('Failed to start llama-server:', e)
}
starting = false
setupStatus = ''
}
const stopServer = async () => {
stopping = true
try {
await window.electronAPI.stopLlamaCpp()
lsInfo = await window.electronAPI.getLlamaCppInfo()
} catch (e) {
console.error('Failed to stop llama-server:', e)
}
stopping = false
}
const restartServer = async () => {
restarting = true
try {
await window.electronAPI.stopLlamaCpp()
await window.electronAPI.startLlamaCpp()
lsInfo = await window.electronAPI.getLlamaCppInfo()
} catch (e) {
console.error('Failed to restart llama-server:', e)
}
restarting = false
}
const checkUpdate = async () => {
updateStatus = 'checking'
updateError = null
try {
const res = await window.electronAPI.checkLlamaCppUpdate()
updateInfo = res
updateStatus = res.updateAvailable ? 'available' : 'up-to-date'
} catch (e: any) {
updateStatus = 'error'
updateError = e?.message ?? 'Check failed'
}
}
const doUpdate = async () => {
updateStatus = 'updating'
try {
lsInfo = await window.electronAPI.updateLlamaCpp()
updateStatus = 'idle'
updateInfo = null
} catch (e: any) {
updateStatus = 'error'
updateError = e?.message ?? 'Update failed'
}
}
const downloadModel = async () => {
if (!downloadRepo.trim() || !downloadFile.trim()) return
downloading = true
downloadProgress = 0
try {
await window.electronAPI.downloadHfModel(downloadRepo.trim(), downloadFile.trim())
} catch (e) {
console.error('Failed to download model:', e)
downloading = false
}
}
const removeModel = async (repo: string, filename: string) => {
deleting = `${repo}/${filename}`
try {
await window.electronAPI.deleteHfModel(repo, filename)
models = await window.electronAPI.listHfModels()
} catch (e) {
console.error('Failed to delete model:', e)
}
deleting = null
}
const installed = $derived(!!lsInfo?.binaryPath)
const formatSize = (bytes: number): string => {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`
}
</script>
{#if !loaded}
<div class="py-6 text-[12px] opacity-20 text-center">{$i18n.t('common.loading')}</div>
{:else if !installed}
<div class="py-4 flex items-center justify-between">
<div>
<div class="text-[13px] opacity-40 flex items-center gap-1.5">
{$i18n.t('settings.inference.notInstalled')}
<span class="text-[9px] opacity-30 uppercase tracking-wide">{$i18n.t('common.experimental')}</span>
</div>
<div class="text-[11px] opacity-20 mt-0.5">{$i18n.t('settings.inference.notInstalledDesc')}</div>
</div>
<button
class="text-[12px] opacity-40 hover:opacity-70 px-3 py-1.5 bg-black/[0.04] dark:bg-white/[0.06] transition border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-xl flex items-center gap-1.5 {installing ? 'pointer-events-none opacity-20' : ''}"
disabled={installing}
onclick={async () => {
installing = true
try {
await window.electronAPI.startLlamaCpp()
lsInfo = await window.electronAPI.getLlamaCppInfo()
} catch (e) {
console.error('Failed to install:', e)
}
installing = false
}}
>
{#if installing}
<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>
{$i18n.t('common.installing')}
{:else}
{$i18n.t('common.install')}
{/if}
</button>
</div>
<!-- Version -->
<div class="py-4 flex items-center justify-between border-t border-white/[0.04]">
<div>
<div class="text-[13px] opacity-70">{$i18n.t('settings.inference.version')}</div>
<div class="text-[11px] opacity-25 mt-0.5">{$i18n.t('settings.inference.versionDesc')}</div>
</div>
<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 w-24 text-right font-mono"
value={$config?.llamaCpp?.version ?? 'latest'}
onchange={(e) => updateConfig('version', (e.target as HTMLInputElement).value.trim() || 'latest')}
/>
</div>
<!-- Variant -->
<div class="py-4 flex items-center justify-between border-t border-white/[0.04]">
<div>
<div class="text-[13px] opacity-70">{$i18n.t('settings.inference.variant')}</div>
<div class="text-[11px] opacity-25 mt-0.5">{$i18n.t('settings.inference.variantDesc')}</div>
</div>
<select
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"
onchange={(e) => updateConfig('variant', (e.target as HTMLSelectElement).value)}
>
{#each variantOptions as opt}
<option value={opt.value} selected={($config?.llamaCpp?.variant ?? 'auto') === opt.value}>{opt.label}</option>
{/each}
</select>
</div>
{:else}
<div class="flex flex-col divide-y divide-white/[0.04]">
<!-- Server status & controls -->
<div class="py-4">
<div class="flex items-center justify-between mb-3">
<div>
<div class="text-[13px] opacity-70 flex items-center gap-1.5">
{$i18n.t('settings.inference.llamaServer')}
<span class="text-[9px] opacity-30 uppercase tracking-wide">{$i18n.t('common.experimental')}</span>
</div>
<div class="text-[11px] opacity-25 mt-0.5">
{$i18n.t('settings.inference.llamaServerDesc')}
</div>
</div>
<div class="flex items-center gap-1.5">
{#if isRunning}
<div class="w-1.5 h-1.5 rounded-full bg-emerald-400"></div>
<span class="text-[12px] opacity-50">{$i18n.t('common.running')}</span>
{:else if lsInfo?.status === 'starting' || lsInfo?.status === 'setting-up'}
<div class="w-1.5 h-1.5 rounded-full bg-amber-400/60 animate-pulse"></div>
<span class="text-[12px] opacity-30 capitalize">{lsInfo?.status === 'setting-up' ? $i18n.t('settings.inference.settingUp') : $i18n.t('common.starting')}</span>
{:else if lsInfo?.status === 'failed'}
<div class="w-1.5 h-1.5 rounded-full bg-red-400/70"></div>
<span class="text-[12px] opacity-30">{$i18n.t('common.failed')}</span>
{:else}
<div class="w-1.5 h-1.5 rounded-full bg-black/15 dark:bg-white/20"></div>
<span class="text-[12px] opacity-30">{$i18n.t('common.stopped')}</span>
{/if}
</div>
</div>
{#if setupStatus}
<div class="text-[11px] opacity-30 mb-3 font-mono truncate">{setupStatus}</div>
{/if}
<div class="flex items-center gap-2">
{#if isRunning}
<button
class="text-[12px] opacity-40 hover:opacity-70 px-3 py-1.5 bg-black/[0.04] dark:bg-white/[0.06] transition border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-xl flex items-center gap-1.5 {stopping ? 'pointer-events-none opacity-20' : ''}"
disabled={stopping}
onclick={stopServer}
>
{#if stopping}
<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>
{$i18n.t('common.stopping')}
{:else}
<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="M5.636 5.636a9 9 0 1012.728 0M12 3v9" />
</svg>
{$i18n.t('common.stop')}
{/if}
</button>
<button
class="text-[12px] opacity-40 hover:opacity-70 px-3 py-1.5 bg-black/[0.04] dark:bg-white/[0.06] transition border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-xl flex items-center gap-1.5 {restarting ? 'pointer-events-none opacity-20' : ''}"
disabled={restarting}
onclick={restartServer}
>
{#if restarting}
<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>
{$i18n.t('common.restarting')}
{:else}
<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="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M20.015 4.356v4.992m0 0h-4.992m4.993 0l-3.181-3.183a8.25 8.25 0 00-13.803 3.7" />
</svg>
{$i18n.t('common.restart')}
{/if}
</button>
{:else}
<button
class="text-[12px] opacity-40 hover:opacity-70 px-3 py-1.5 bg-black/[0.04] dark:bg-white/[0.06] transition border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-xl flex items-center gap-1.5 {starting ? 'pointer-events-none opacity-20' : ''}"
disabled={starting}
onclick={startServer}
>
{#if starting}
<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>
{$i18n.t('common.starting')}
{:else}
<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="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 010 1.972l-11.54 6.347a1.125 1.125 0 01-1.667-.986V5.653z" />
</svg>
{$i18n.t('common.start')}
{/if}
</button>
{/if}
<button
class="text-[12px] opacity-40 hover:opacity-70 px-3 py-1.5 bg-black/[0.04] dark:bg-white/[0.06] transition border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-xl flex items-center gap-1.5 {settingUp ? 'pointer-events-none opacity-20' : ''}"
disabled={settingUp}
onclick={setupServer}
>
{#if settingUp}
<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>
{$i18n.t('common.downloading')}
{:else}
<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="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
{lsInfo?.binaryPath ? $i18n.t('settings.inference.redownload') : $i18n.t('common.download')}
{/if}
</button>
</div>
</div>
<!-- Running Instance Info -->
{#if isRunning && lsInfo}
<div class="py-4">
<div class="text-[13px] opacity-70 mb-3">{$i18n.t('settings.inference.runningInstance')}</div>
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<span class="text-[11px] opacity-30">URL</span>
<button class="text-[12px] opacity-50 font-mono hover:opacity-80 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] p-0 underline decoration-dotted underline-offset-2 cursor-pointer" onclick={() => window.open(lsInfo.url)}>{lsInfo.url}</button>
</div>
<div class="flex items-center justify-between">
<span class="text-[11px] opacity-30">PID</span>
<span class="text-[12px] opacity-50 font-mono">{lsInfo.pid}</span>
</div>
{#if lsInfo.version}
<div class="flex items-center justify-between">
<span class="text-[11px] opacity-30">{$i18n.t('settings.inference.build')}</span>
<span class="text-[12px] opacity-50 font-mono">{lsInfo.version}</span>
</div>
{/if}
</div>
</div>
{/if}
<!-- Update Section -->
<div class="py-4">
<div class="flex items-center justify-between">
<div>
<div class="text-[13px] opacity-70">{$i18n.t('settings.about.softwareUpdate')}</div>
{#if updateStatus === 'up-to-date'}
<div class="text-[11px] opacity-25 mt-0.5">{$i18n.t('settings.inference.upToDate')}</div>
{:else if updateStatus === 'available' && updateInfo?.latestVersion}
<div class="text-[11px] opacity-40 mt-0.5">{$i18n.t('settings.inference.updateAvailable', { version: updateInfo.latestVersion })}</div>
{:else if updateStatus === 'updating'}
<div class="text-[11px] opacity-25 mt-0.5">{$i18n.t('settings.inference.updating')}</div>
{:else if updateStatus === 'error'}
<div class="text-[11px] text-red-400/60 mt-0.5">{updateError}</div>
{/if}
</div>
<div>
{#if updateStatus === 'idle' || updateStatus === 'up-to-date' || updateStatus === 'error'}
<button
class="text-[12px] opacity-40 hover:opacity-70 px-3 py-1.5 bg-black/[0.04] dark:bg-white/[0.06] transition border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-xl"
onclick={checkUpdate}
>
{$i18n.t('settings.inference.checkForUpdates')}
</button>
{:else if updateStatus === 'checking'}
<button
class="text-[12px] opacity-30 px-3 py-1.5 bg-black/[0.04] dark:bg-white/[0.06] border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-xl pointer-events-none flex items-center gap-1.5"
disabled
>
<svg class="w-3 h-3 animate-spin" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3" stroke-dasharray="31.4 31.4" stroke-linecap="round" />
</svg>
{$i18n.t('settings.inference.checking')}
</button>
{:else if updateStatus === 'available'}
<button
class="text-[12px] opacity-50 hover:opacity-80 px-3 py-1.5 bg-black/[0.06] dark:bg-white/[0.08] transition border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-xl"
onclick={doUpdate}
>
{$i18n.t('common.update')}
</button>
{:else if updateStatus === 'updating'}
<button
class="text-[12px] opacity-30 px-3 py-1.5 bg-black/[0.04] dark:bg-white/[0.06] border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-xl pointer-events-none flex items-center gap-1.5"
disabled
>
<div class="w-2.5 h-2.5 rounded-full border-2 border-black/20 dark:border-white/30 border-t-transparent animate-spin"></div>
{$i18n.t('common.updating')}
</button>
{/if}
</div>
</div>
</div>
<!-- Start on Launch -->
<div class="py-4 flex items-center justify-between">
<div>
<div class="text-[13px] opacity-70">{$i18n.t('settings.inference.startOnLaunch')}</div>
<div class="text-[11px] opacity-25 mt-0.5">{$i18n.t('settings.inference.startOnLaunchDesc')}</div>
</div>
<Switch
checked={$config?.llamaCpp?.enabled ?? false}
onchange={(value) => updateConfig('enabled', value)}
/>
</div>
<!-- Version -->
<div class="py-4 flex items-center justify-between">
<div>
<div class="text-[13px] opacity-70">{$i18n.t('settings.inference.version')}</div>
<div class="text-[11px] opacity-25 mt-0.5">{$i18n.t('settings.inference.versionDesc')}</div>
</div>
<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 w-24 text-right font-mono"
value={$config?.llamaCpp?.version ?? 'latest'}
onchange={(e) => updateConfig('version', (e.target as HTMLInputElement).value.trim() || 'latest')}
/>
</div>
<!-- Variant -->
<div class="py-4 flex items-center justify-between">
<div>
<div class="text-[13px] opacity-70">{$i18n.t('settings.inference.variant')}</div>
<div class="text-[11px] opacity-25 mt-0.5">{$i18n.t('settings.inference.variantDesc')}</div>
</div>
<select
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"
onchange={(e) => updateConfig('variant', (e.target as HTMLSelectElement).value)}
>
{#each variantOptions as opt}
<option value={opt.value} selected={($config?.llamaCpp?.variant ?? 'auto') === opt.value}>{opt.label}</option>
{/each}
</select>
</div>
<!-- Port -->
<div class="py-4 flex items-center justify-between">
<div>
<div class="text-[13px] opacity-70">{$i18n.t('settings.inference.port')}</div>
<div class="text-[11px] opacity-25 mt-0.5">{$i18n.t('settings.inference.portDesc')}</div>
</div>
<input
type="number"
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 w-20 text-right"
value={$config?.llamaCpp?.port ?? 18881}
onchange={(e) => updateConfig('port', parseInt((e.target as HTMLInputElement).value) || 18881)}
/>
</div>
<!-- Extra Arguments -->
<div class="py-4 flex items-center justify-between gap-4">
<div class="shrink-0">
<div class="text-[13px] opacity-70">{$i18n.t('settings.inference.extraArguments')}</div>
<div class="text-[11px] opacity-25 mt-0.5">{$i18n.t('settings.inference.extraArgumentsDesc')}</div>
</div>
<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 max-w-[280px] text-right font-mono"
placeholder={$i18n.t('settings.inference.extraArgumentsPlaceholder')}
value={($config?.llamaCpp?.extraArgs ?? []).join(' ')}
onchange={(e) => {
const val = (e.target as HTMLInputElement).value.trim()
updateConfig('extraArgs', val ? val.split(/\s+/) : [])
}}
/>
</div>
<!-- Uninstall -->
{#if lsInfo?.binaryPath}
<div class="py-4 flex items-center justify-between">
<div>
<div class="text-[13px] opacity-70">{$i18n.t('settings.inference.uninstall')}</div>
<div class="text-[11px] opacity-25 mt-0.5">{$i18n.t('settings.inference.uninstallDesc')}</div>
</div>
<button
class="text-[12px] opacity-40 hover:opacity-70 px-3 py-1.5 bg-black/[0.04] dark:bg-white/[0.06] transition border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-xl flex items-center gap-1.5 {uninstalling ? 'pointer-events-none opacity-20' : ''}"
disabled={uninstalling}
onclick={async () => {
if (confirm($i18n.t('settings.inference.uninstallConfirm'))) {
uninstalling = true
try {
await window.electronAPI.uninstallLlamaCpp()
lsInfo = await window.electronAPI.getLlamaCppInfo()
} catch (e) {
console.error('Failed to uninstall llama.cpp:', e)
}
uninstalling = false
}
}}
>
{#if uninstalling}
<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>
{$i18n.t('common.uninstalling')}
{:else}
{$i18n.t('common.uninstall')}
{/if}
</button>
</div>
{/if}
</div>
{/if}
@@ -0,0 +1,403 @@
<script lang="ts">
import { onMount } from 'svelte'
import i18n from '../../../i18n'
interface HfModel {
repo: string
filename: string
filepath: string
size: number
downloadedAt: string
}
interface HfRepoResult {
id: string
author: string
modelId: string
downloads: number
likes: number
}
interface HfFileInfo {
filename: string
size: number
}
// State
let models = $state<HfModel[]>([])
let loaded = $state(false)
let deleting = $state<string | null>(null)
let searchError = $state('')
let modelsDir = $state('')
// Search state
let searchQuery = $state('')
let searchResults = $state<HfRepoResult[]>([])
let searching = $state(false)
let searchTimer: ReturnType<typeof setTimeout> | null = null
// Repo browser state
let selectedRepo = $state<string | null>(null)
let repoFiles = $state<HfFileInfo[]>([])
let loadingFiles = $state(false)
// 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()
modelsDir = await window.electronAPI.getHfModelsDir() || ''
loaded = true
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') {
const updated = new Map(activeDownloads)
updated.set(key, { repo: d.repo, filename: d.filename, percent: d.percent ?? 0 })
activeDownloads = updated
}
if (d?.status === 'done') {
const updated = new Map(activeDownloads)
updated.delete(key)
activeDownloads = updated
window.electronAPI.listHfModels().then((m: HfModel[]) => { models = m })
}
if (d?.status === 'failed') {
const updated = new Map(activeDownloads)
updated.delete(key)
activeDownloads = updated
}
}
})
})
const onSearchInput = (e: Event) => {
const q = (e.target as HTMLInputElement).value
searchQuery = q
searchError = ''
if (searchTimer) clearTimeout(searchTimer)
if (!q.trim()) {
searchResults = []
searching = false
return
}
searching = true
searchTimer = setTimeout(async () => {
try {
searchResults = await window.electronAPI.searchHfModels(q.trim())
} catch (e: any) {
console.error('Search failed:', e)
searchError = e?.message ?? 'Search failed'
searchResults = []
}
searching = false
}, 400)
}
const selectRepo = async (repoId: string) => {
selectedRepo = repoId
loadingFiles = true
repoFiles = []
try {
repoFiles = await window.electronAPI.getHfRepoFiles(repoId)
} catch (e) {
console.error('Failed to load files:', e)
}
loadingFiles = false
}
const backToSearch = () => {
selectedRepo = null
repoFiles = []
}
const startDownload = async (repo: string, filename: string, size?: number) => {
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)
const cleaned = new Map(activeDownloads)
cleaned.delete(key)
activeDownloads = cleaned
}
}
const cancelDownload = async (repo: string, filename: string) => {
try {
await window.electronAPI.cancelHfDownload(repo, filename)
} catch (e) {
console.error('Failed to cancel download:', e)
}
const updated = new Map(activeDownloads)
updated.delete(dlKey(repo, filename))
activeDownloads = updated
}
const removeModel = async (repo: string, filename: string) => {
deleting = `${repo}/${filename}`
try {
await window.electronAPI.deleteHfModel(repo, filename)
models = await window.electronAPI.listHfModels()
} catch (e) {
console.error('Failed to delete model:', e)
}
deleting = null
}
const isDownloaded = (repo: string, filename: string): boolean => {
return models.some((m) => m.repo === repo && m.filename === filename)
}
const isDownloading = (repo: string, filename: string): boolean => {
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`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`
}
const formatDownloads = (n: number): string => {
if (n < 1000) return `${n}`
if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k`
return `${(n / 1_000_000).toFixed(1)}M`
}
</script>
{#if !loaded}
<div class="py-6 text-[12px] opacity-20 text-center">{$i18n.t('common.loading')}</div>
{:else}
<div class="flex flex-col divide-y divide-white/[0.04]">
<!-- Models directory -->
<div class="py-4 flex items-center justify-between gap-4">
<div class="shrink-0">
<div class="text-[13px] opacity-70">{$i18n.t('settings.models.modelsDirectory')}</div>
<div class="text-[11px] opacity-25 mt-0.5">{$i18n.t('settings.models.modelsHint')}</div>
</div>
<button class="text-[12px] font-mono hover:opacity-80 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] p-0 underline decoration-dotted underline-offset-2 cursor-pointer flex items-center gap-1.5 min-w-0 truncate" onclick={() => { if (modelsDir) window.electronAPI.openPath(modelsDir) }}>
<span class="truncate">{modelsDir || '…'}</span>
<svg class="w-3 h-3 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
</svg>
</button>
</div>
<!-- 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 || hasActiveDownloads}
<div class="flex flex-col">
<!-- 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>
<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>
<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>
{/each}
<!-- Completed downloads -->
{#each models as model}
<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-20 truncate mt-0.5">{model.repo} · {formatSize(model.size)}</div>
</div>
<button
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')}
>
{#if deleting === `${model.repo}/${model.filename}`}
<div class="w-3 h-3 rounded-full border-[1.5px] border-black/20 dark:border-white/30 border-t-transparent animate-spin"></div>
{:else}
<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="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
</svg>
{/if}
</button>
</div>
{/each}
</div>
{:else}
<div class="text-[11px] opacity-20 py-3">{$i18n.t('settings.models.noModels')}</div>
{/if}
</div>
<!-- Download from HF -->
<div class="py-4">
<div class="text-[12px] opacity-50 mb-2">
{#if selectedRepo}
<button
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 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>
<span class="truncate">{selectedRepo}</span>
</button>
{:else}
{$i18n.t('settings.models.downloadFromHF')}
{/if}
</div>
{#if selectedRepo}
<!-- Repo file browser -->
{#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>
<span class="text-[11px] opacity-30">{$i18n.t('settings.models.loadingFiles')}</span>
</div>
{: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">
{#each repoFiles as file}
{@const downloaded = isDownloaded(selectedRepo, file.filename)}
{@const dlActive = isDownloading(selectedRepo, file.filename)}
<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-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-25 shrink-0">{$i18n.t('settings.models.downloaded')}</span>
{:else if dlActive}
<div class="flex items-center gap-1.5 shrink-0">
<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-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)}
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">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
</button>
{/if}
</div>
{/each}
</div>
{/if}
{:else}
<!-- Search -->
<div class="relative mb-2">
<svg class="w-3.5 h-3.5 absolute left-2.5 top-1/2 -translate-y-1/2 opacity-25 pointer-events-none" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
</svg>
<input
type="text"
class="bg-black/[0.04] dark:bg-white/[0.06] text-[12px] text-[#1d1d1f] dark:text-[#fafafa] pl-8 pr-3 py-2 border-none outline-none rounded-xl opacity-70 w-full"
placeholder={$i18n.t('settings.models.searchPlaceholder')}
value={searchQuery}
oninput={onSearchInput}
/>
{#if searching}
<div class="w-3 h-3 rounded-full border-[1.5px] border-black/20 dark:border-white/30 border-t-transparent animate-spin absolute right-2.5 top-1/2 -translate-y-1/2"></div>
{/if}
</div>
{#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 max-h-[300px] overflow-y-auto">
{#each searchResults as repo}
<button
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">
<div class="text-[12px] opacity-60 truncate">{repo.id}</div>
<div class="text-[10px] opacity-25 flex items-center gap-2 mt-0.5">
<span class="flex items-center gap-0.5">
<svg class="w-2.5 h-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
{formatDownloads(repo.downloads)}
</span>
<span class="flex items-center gap-0.5">
<svg class="w-2.5 h-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12z" />
</svg>
{repo.likes}
</span>
</div>
</div>
<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>
{/each}
</div>
{:else if searchQuery.trim() && !searching}
<div class="text-[11px] opacity-20 text-center py-3">{$i18n.t('settings.models.noReposFound')}</div>
{:else if !searchQuery.trim()}
<div class="text-[11px] opacity-20 text-center py-3">{$i18n.t('settings.models.searchForModels')}</div>
{/if}
{/if}
</div>
</div>
{/if}
@@ -0,0 +1,363 @@
<script lang="ts">
import { onMount } from 'svelte'
import { config } from '../../../stores'
import i18n from '../../../i18n'
import Switch from '../../common/Switch.svelte'
let otInfo = $state<{ url?: string; apiKey?: string; status?: string; pid?: number } | null>(null)
let otApiKeyCopied = $state(false)
let version = $state<string | null>(null)
let stopping = $state(false)
let starting = $state(false)
let restarting = $state(false)
let updating = $state(false)
let loaded = $state(false)
let uninstalling = $state(false)
let installing = $state(false)
onMount(async () => {
otInfo = await window.electronAPI.getOpenTerminalInfo()
version = await window.electronAPI.getPackageVersion('open-terminal')
loaded = true
})
const installed = $derived(version !== null)
const isRunning = $derived(otInfo?.status === 'started')
const updateOtConfig = async (key: string, value: any) => {
const current = $config ?? {}
const openTerminal = { ...(current.openTerminal ?? {}), [key]: value }
await window.electronAPI.setConfig({ openTerminal })
config.set(await window.electronAPI.getConfig())
}
const stopTerminal = async () => {
stopping = true
try {
await window.electronAPI.stopOpenTerminal()
otInfo = await window.electronAPI.getOpenTerminalInfo()
} catch (e) {
console.error('Failed to stop Open Terminal:', e)
}
stopping = false
}
const startTerminal = async () => {
starting = true
try {
await window.electronAPI.startOpenTerminal()
otInfo = await window.electronAPI.getOpenTerminalInfo()
} catch (e) {
console.error('Failed to start Open Terminal:', e)
}
starting = false
}
const restartTerminal = async () => {
restarting = true
try {
await window.electronAPI.stopOpenTerminal()
await window.electronAPI.startOpenTerminal()
otInfo = await window.electronAPI.getOpenTerminalInfo()
} catch (e) {
console.error('Failed to restart Open Terminal:', e)
}
restarting = false
}
const updatePackage = async () => {
updating = true
try {
if (isRunning) {
await window.electronAPI.stopOpenTerminal()
}
// Reinstall to get latest
await window.electronAPI.startOpenTerminal()
otInfo = await window.electronAPI.getOpenTerminalInfo()
version = await window.electronAPI.getPackageVersion('open-terminal')
} catch (e) {
console.error('Failed to update Open Terminal:', e)
}
updating = false
}
const copyOtApiKey = async () => {
if (otInfo?.apiKey) {
await navigator.clipboard.writeText(otInfo.apiKey)
otApiKeyCopied = true
setTimeout(() => { otApiKeyCopied = false }, 2000)
}
}
</script>
{#if !loaded}
<div class="py-6 text-[12px] opacity-20 text-center">{$i18n.t('common.loading')}</div>
{:else if !installed}
<div class="py-4 flex items-center justify-between">
<div>
<div class="text-[13px] opacity-40">{$i18n.t('settings.terminal.notInstalled')}</div>
<div class="text-[11px] opacity-20 mt-0.5">{$i18n.t('settings.terminal.notInstalledDesc')}</div>
</div>
<button
class="text-[12px] opacity-40 hover:opacity-70 px-3 py-1.5 bg-black/[0.04] dark:bg-white/[0.06] transition border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-xl flex items-center gap-1.5 {installing ? 'pointer-events-none opacity-20' : ''}"
disabled={installing}
onclick={async () => {
installing = true
try {
await window.electronAPI.startOpenTerminal()
otInfo = await window.electronAPI.getOpenTerminalInfo()
version = await window.electronAPI.getPackageVersion('open-terminal')
} catch (e) {
console.error('Failed to install:', e)
}
installing = false
}}
>
{#if installing}
<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>
{$i18n.t('common.installing')}
{:else}
{$i18n.t('common.install')}
{/if}
</button>
</div>
{:else}
<div class="flex flex-col divide-y divide-white/[0.04]">
<!-- Server status & controls -->
<div class="py-4">
<div class="flex items-center justify-between mb-3">
<div>
<div class="text-[13px] opacity-70">{$i18n.t('settings.terminal.server')}</div>
<div class="text-[11px] opacity-25 mt-0.5">
{#if version}v{version} · {/if}{$i18n.t('settings.terminal.instance')}
</div>
</div>
<div class="flex items-center gap-1.5">
{#if isRunning}
<div class="w-1.5 h-1.5 rounded-full bg-emerald-400"></div>
<span class="text-[12px] opacity-50">{$i18n.t('common.running')}</span>
{:else if otInfo?.status === 'stopped' || !otInfo?.status}
<div class="w-1.5 h-1.5 rounded-full bg-black/15 dark:bg-white/20"></div>
<span class="text-[12px] opacity-30">{$i18n.t('common.stopped')}</span>
{:else}
<div class="w-1.5 h-1.5 rounded-full bg-amber-400/60"></div>
<span class="text-[12px] opacity-30 capitalize">{otInfo?.status}</span>
{/if}
</div>
</div>
<div class="flex items-center gap-2">
{#if isRunning}
<button
class="text-[12px] opacity-40 hover:opacity-70 px-3 py-1.5 bg-black/[0.04] dark:bg-white/[0.06] transition border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-xl flex items-center gap-1.5 {stopping ? 'pointer-events-none opacity-20' : ''}"
disabled={stopping}
onclick={stopTerminal}
>
{#if stopping}
<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>
{$i18n.t('common.stopping')}
{:else}
<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="M5.636 5.636a9 9 0 1012.728 0M12 3v9" />
</svg>
{$i18n.t('common.stop')}
{/if}
</button>
<button
class="text-[12px] opacity-40 hover:opacity-70 px-3 py-1.5 bg-black/[0.04] dark:bg-white/[0.06] transition border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-xl flex items-center gap-1.5 {restarting ? 'pointer-events-none opacity-20' : ''}"
disabled={restarting}
onclick={restartTerminal}
>
{#if restarting}
<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>
{$i18n.t('common.restarting')}
{:else}
<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="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M20.015 4.356v4.992m0 0h-4.992m4.993 0l-3.181-3.183a8.25 8.25 0 00-13.803 3.7" />
</svg>
{$i18n.t('common.restart')}
{/if}
</button>
{:else}
<button
class="text-[12px] opacity-40 hover:opacity-70 px-3 py-1.5 bg-black/[0.04] dark:bg-white/[0.06] transition border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-xl flex items-center gap-1.5 {starting ? 'pointer-events-none opacity-20' : ''}"
disabled={starting}
onclick={startTerminal}
>
{#if starting}
<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>
{$i18n.t('common.starting')}
{:else}
<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="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 010 1.972l-11.54 6.347a1.125 1.125 0 01-1.667-.986V5.653z" />
</svg>
{$i18n.t('common.start')}
{/if}
</button>
{/if}
<button
class="text-[12px] opacity-40 hover:opacity-70 px-3 py-1.5 bg-black/[0.04] dark:bg-white/[0.06] transition border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-xl flex items-center gap-1.5 {updating ? 'pointer-events-none opacity-20' : ''}"
disabled={updating}
onclick={updatePackage}
>
{#if updating}
<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>
{$i18n.t('common.updating')}
{:else}
<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="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
{$i18n.t('common.update')}
{/if}
</button>
</div>
</div>
<!-- Running Instance Info -->
{#if isRunning && otInfo}
<div class="py-4">
<div class="text-[13px] opacity-70 mb-3">{$i18n.t('settings.terminal.runningInstance')}</div>
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<span class="text-[11px] opacity-30">URL</span>
<button class="text-[12px] opacity-50 font-mono hover:opacity-80 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] p-0 underline decoration-dotted underline-offset-2 cursor-pointer" onclick={() => window.open(otInfo.url)}>{otInfo.url}</button>
</div>
{#if otInfo.pid}
<div class="flex items-center justify-between">
<span class="text-[11px] opacity-30">PID</span>
<span class="text-[12px] opacity-50 font-mono">{otInfo.pid}</span>
</div>
{/if}
</div>
</div>
{/if}
<div class="py-4 flex items-center justify-between">
<div>
<div class="text-[13px] opacity-70">{$i18n.t('settings.terminal.startOnLaunch')}</div>
<div class="text-[11px] opacity-25 mt-0.5">
{$i18n.t('settings.terminal.startOnLaunchDesc')}
</div>
</div>
<Switch
checked={$config?.openTerminal?.enabled ?? false}
label={$i18n.t('settings.terminal.toggleStartOnLaunch')}
onchange={(value) => updateOtConfig('enabled', value)}
/>
</div>
<div class="py-4 flex items-center justify-between">
<div>
<div class="text-[13px] opacity-70">{$i18n.t('settings.terminal.port')}</div>
<div class="text-[11px] opacity-25 mt-0.5">{$i18n.t('settings.terminal.portDesc')}</div>
</div>
<input
type="number"
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 w-20 text-right"
value={$config?.openTerminal?.port ?? 8000}
onchange={(e) =>
updateOtConfig('port', parseInt((e.target as HTMLInputElement).value) || 8000)}
/>
</div>
<div class="py-4 flex items-center justify-between gap-4">
<div class="shrink-0">
<div class="text-[13px] opacity-70">{$i18n.t('settings.terminal.workingDirectory')}</div>
<div class="text-[11px] opacity-25 mt-0.5">{$i18n.t('settings.terminal.workingDirectoryDesc')}</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={$i18n.t('settings.terminal.workingDirectoryPlaceholder')}
value={$config?.openTerminal?.cwd ?? ''}
onchange={(e) =>
updateOtConfig('cwd', (e.target as HTMLInputElement).value.trim())}
/>
<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) updateOtConfig('cwd', folder)
}}
>
{$i18n.t('common.browse')}
</button>
</div>
</div>
{#if isRunning && otInfo}
<div class="py-4">
<div class="text-[13px] opacity-70 mb-3">{$i18n.t('settings.terminal.runningInstance')}</div>
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<span class="text-[11px] opacity-30">URL</span>
<button class="text-[12px] opacity-50 font-mono hover:opacity-80 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] p-0 underline decoration-dotted underline-offset-2 cursor-pointer" onclick={() => window.open(otInfo.url)}>{otInfo.url}</button>
</div>
<div class="flex items-center justify-between">
<span class="text-[11px] opacity-30">API Key</span>
<div class="flex items-center gap-1.5">
<span class="text-[12px] opacity-50 font-mono">{otInfo.apiKey?.slice(0, 12)}</span>
<button
class="text-[10px] opacity-30 hover:opacity-60 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa]"
onclick={copyOtApiKey}
>
{otApiKeyCopied ? $i18n.t('common.copied') : $i18n.t('common.copy')}
</button>
</div>
</div>
</div>
</div>
{/if}
<!-- Version Pin -->
<div class="py-4 flex items-center justify-between">
<div>
<div class="text-[13px] opacity-70">{$i18n.t('settings.terminal.version')}</div>
<div class="text-[11px] opacity-25 mt-0.5">{$i18n.t('settings.terminal.versionDesc')}</div>
</div>
<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 w-28 text-right font-mono"
placeholder="latest"
value={$config?.openTerminal?.version ?? ''}
onchange={(e) => updateOtConfig('version', (e.target as HTMLInputElement).value.trim())}
/>
</div>
<!-- Uninstall -->
<div class="py-4 flex items-center justify-between">
<div>
<div class="text-[13px] opacity-70">{$i18n.t('settings.terminal.uninstall')}</div>
<div class="text-[11px] opacity-25 mt-0.5">{$i18n.t('settings.terminal.uninstallDesc')}</div>
</div>
<button
class="text-[12px] opacity-40 hover:opacity-70 px-3 py-1.5 bg-black/[0.04] dark:bg-white/[0.06] transition border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-xl flex items-center gap-1.5 {uninstalling ? 'pointer-events-none opacity-20' : ''}"
disabled={uninstalling}
onclick={async () => {
if (confirm($i18n.t('settings.terminal.uninstallConfirm'))) {
uninstalling = true
try {
if (isRunning) await window.electronAPI.stopOpenTerminal()
await window.electronAPI.uninstallPackage('open-terminal')
version = null
otInfo = null
} catch (e) {
console.error('Failed to uninstall:', e)
}
uninstalling = false
}
}}
>
{#if uninstalling}
<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>
{$i18n.t('common.uninstalling')}
{:else}
{$i18n.t('common.uninstall')}
{/if}
</button>
</div>
</div>
{/if}
@@ -0,0 +1,370 @@
<script lang="ts">
import { onMount } from 'svelte'
import { config, serverInfo } from '../../../stores'
import i18n from '../../../i18n'
import Switch from '../../common/Switch.svelte'
let serverStatus = $state<string | null>(null)
let updating = $state(false)
let stopping = $state(false)
let starting = $state(false)
let restarting = $state(false)
let version = $state<string | null>(null)
let serverPid = $state<number | null>(null)
let loaded = $state(false)
let defaultDataPath = $state('')
onMount(async () => {
const info = await window.electronAPI.getServerInfo()
serverStatus = info?.status ?? null
serverPid = info?.pid ?? null
version = await window.electronAPI.getPackageVersion('open-webui')
defaultDataPath = await window.electronAPI.getDefaultDataPath()
loaded = true
})
const installed = $derived(version !== null)
const isRunning = $derived(
serverStatus === 'running' || $serverInfo?.reachable === true
)
let uninstalling = $state(false)
let installing = $state(false)
const updateConfig = async (key: string, value: any) => {
const current = $config ?? {}
const localServer = { ...(current.localServer ?? {}), [key]: value }
await window.electronAPI.setConfig({ localServer })
config.set(await window.electronAPI.getConfig())
}
const stopServer = async () => {
stopping = true
try {
await window.electronAPI.stopServer()
serverStatus = 'stopped'
} catch (e) {
console.error('Failed to stop server:', e)
}
stopping = false
}
const startServer = async () => {
starting = true
try {
await window.electronAPI.startServer()
serverStatus = 'running'
} catch (e) {
console.error('Failed to start server:', e)
}
starting = false
}
const restartServer = async () => {
restarting = true
try {
await window.electronAPI.restartServer()
serverStatus = 'running'
} catch (e) {
console.error('Failed to restart server:', e)
}
restarting = false
}
const updatePackage = async () => {
updating = true
try {
if (isRunning) {
await window.electronAPI.stopServer()
serverStatus = 'stopped'
}
await window.electronAPI.installPackage()
await window.electronAPI.startServer()
serverStatus = 'running'
version = await window.electronAPI.getPackageVersion('open-webui')
} catch (e) {
console.error('Failed to update:', e)
}
updating = false
}
</script>
{#if !loaded}
<div class="py-6 text-[12px] opacity-20 text-center">{$i18n.t('common.loading')}</div>
{:else if !installed}
<div class="py-4 flex items-center justify-between">
<div>
<div class="text-[13px] opacity-40">{$i18n.t('settings.openwebui.notInstalled')}</div>
<div class="text-[11px] opacity-20 mt-0.5">{$i18n.t('settings.openwebui.notInstalledDesc')}</div>
</div>
<button
class="text-[12px] opacity-40 hover:opacity-70 px-3 py-1.5 bg-black/[0.04] dark:bg-white/[0.06] transition border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-xl flex items-center gap-1.5 {installing ? 'pointer-events-none opacity-20' : ''}"
disabled={installing}
onclick={async () => {
installing = true
try {
await window.electronAPI.installPackage()
version = await window.electronAPI.getPackageVersion('open-webui')
} catch (e) {
console.error('Failed to install:', e)
}
installing = false
}}
>
{#if installing}
<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>
{$i18n.t('common.installing')}
{:else}
{$i18n.t('common.install')}
{/if}
</button>
</div>
{:else}
<div class="flex flex-col divide-y divide-white/[0.04]">
<!-- Server status & controls -->
<div class="py-4">
<div class="flex items-center justify-between mb-3">
<div>
<div class="text-[13px] opacity-70">{$i18n.t('settings.openwebui.server')}</div>
<div class="text-[11px] opacity-25 mt-0.5">
{#if version}v{version} · {/if}{$i18n.t('settings.openwebui.localInstance')}
</div>
</div>
<div class="flex items-center gap-1.5">
{#if isRunning}
<div class="w-1.5 h-1.5 rounded-full bg-emerald-400"></div>
<span class="text-[12px] opacity-50">{$i18n.t('common.running')}</span>
{:else if serverStatus === 'stopped'}
<div class="w-1.5 h-1.5 rounded-full bg-black/15 dark:bg-white/20"></div>
<span class="text-[12px] opacity-30">{$i18n.t('common.stopped')}</span>
{:else}
<div class="w-1.5 h-1.5 rounded-full bg-amber-400/60"></div>
<span class="text-[12px] opacity-30 capitalize">{serverStatus ?? $i18n.t('common.unknown')}</span>
{/if}
</div>
</div>
<div class="flex items-center gap-2">
{#if isRunning}
<button
class="text-[12px] opacity-40 hover:opacity-70 px-3 py-1.5 bg-black/[0.04] dark:bg-white/[0.06] transition border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-xl flex items-center gap-1.5 {stopping ? 'pointer-events-none opacity-20' : ''}"
disabled={stopping}
onclick={stopServer}
>
{#if stopping}
<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>
{$i18n.t('common.stopping')}
{:else}
<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="M5.636 5.636a9 9 0 1012.728 0M12 3v9" />
</svg>
{$i18n.t('common.stop')}
{/if}
</button>
<button
class="text-[12px] opacity-40 hover:opacity-70 px-3 py-1.5 bg-black/[0.04] dark:bg-white/[0.06] transition border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-xl flex items-center gap-1.5 {restarting ? 'pointer-events-none opacity-20' : ''}"
disabled={restarting}
onclick={restartServer}
>
{#if restarting}
<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>
{$i18n.t('common.restarting')}
{:else}
<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="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M20.015 4.356v4.992m0 0h-4.992m4.993 0l-3.181-3.183a8.25 8.25 0 00-13.803 3.7" />
</svg>
{$i18n.t('common.restart')}
{/if}
</button>
{:else}
<button
class="text-[12px] opacity-40 hover:opacity-70 px-3 py-1.5 bg-black/[0.04] dark:bg-white/[0.06] transition border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-xl flex items-center gap-1.5 {starting ? 'pointer-events-none opacity-20' : ''}"
disabled={starting}
onclick={startServer}
>
{#if starting}
<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>
{$i18n.t('common.starting')}
{:else}
<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="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 010 1.972l-11.54 6.347a1.125 1.125 0 01-1.667-.986V5.653z" />
</svg>
{$i18n.t('common.start')}
{/if}
</button>
{/if}
<button
class="text-[12px] opacity-40 hover:opacity-70 px-3 py-1.5 bg-black/[0.04] dark:bg-white/[0.06] transition border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-xl flex items-center gap-1.5 {updating ? 'pointer-events-none opacity-20' : ''}"
disabled={updating}
onclick={updatePackage}
>
{#if updating}
<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>
{$i18n.t('common.updating')}
{:else}
<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="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
{$i18n.t('common.update')}
{/if}
</button>
</div>
</div>
<!-- Running Instance Info -->
{#if isRunning}
<div class="py-4">
<div class="text-[13px] opacity-70 mb-3">{$i18n.t('settings.openwebui.runningInstance')}</div>
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<span class="text-[11px] opacity-30">URL</span>
<button class="text-[12px] opacity-50 font-mono hover:opacity-80 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] p-0 underline decoration-dotted underline-offset-2 cursor-pointer" onclick={() => window.open($serverInfo?.url ?? 'http://127.0.0.1:8080')}>{$serverInfo?.url ?? 'http://127.0.0.1:8080'}</button>
</div>
{#if serverPid}
<div class="flex items-center justify-between">
<span class="text-[11px] opacity-30">PID</span>
<span class="text-[12px] opacity-50 font-mono">{serverPid}</span>
</div>
{/if}
</div>
</div>
{/if}
<div class="py-4 flex items-center justify-between">
<div>
<div class="text-[13px] opacity-70">{$i18n.t('settings.openwebui.startOnLaunch')}</div>
<div class="text-[11px] opacity-25 mt-0.5">
{$i18n.t('settings.openwebui.startOnLaunchDesc')}
</div>
</div>
<Switch
checked={$config?.localServer?.enabled !== false}
label={$i18n.t('settings.openwebui.toggleStartOnLaunch')}
onchange={(value) => updateConfig('enabled', value)}
/>
</div>
<div class="py-4 flex items-center justify-between">
<div>
<div class="text-[13px] opacity-70">{$i18n.t('settings.openwebui.serverPort')}</div>
<div class="text-[11px] opacity-25 mt-0.5">{$i18n.t('settings.openwebui.serverPortDesc')}</div>
</div>
<input
type="number"
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 w-20 text-right"
value={$config?.localServer?.port ?? 8080}
onchange={(e) =>
updateConfig('port', parseInt((e.target as HTMLInputElement).value) || 8080)}
/>
</div>
<div class="py-4 flex items-center justify-between">
<div>
<div class="text-[13px] opacity-70">{$i18n.t('settings.openwebui.serveOnLocalNetwork')}</div>
<div class="text-[11px] opacity-25 mt-0.5">
{$i18n.t('settings.openwebui.serveOnLocalNetworkDesc')}
</div>
</div>
<Switch
checked={$config?.localServer?.serveOnLocalNetwork ?? false}
label={$i18n.t('settings.openwebui.toggleServeOnLocalNetwork')}
onchange={(value) => updateConfig('serveOnLocalNetwork', value)}
/>
</div>
<div class="py-4 flex items-center justify-between">
<div>
<div class="text-[13px] opacity-70">{$i18n.t('settings.openwebui.autoUpdate')}</div>
<div class="text-[11px] opacity-25 mt-0.5">
{$i18n.t('settings.openwebui.autoUpdateDesc')}
</div>
</div>
<Switch
checked={$config?.localServer?.autoUpdate !== false}
label={$i18n.t('settings.openwebui.toggleAutoUpdate')}
onchange={(value) => updateConfig('autoUpdate', value)}
/>
</div>
<!-- Version Pin -->
<div class="py-4 flex items-center justify-between">
<div>
<div class="text-[13px] opacity-70">{$i18n.t('settings.openwebui.version')}</div>
<div class="text-[11px] opacity-25 mt-0.5">{$i18n.t('settings.openwebui.versionDesc')}</div>
</div>
<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 w-28 text-right font-mono"
placeholder="latest"
value={$config?.localServer?.version ?? ''}
onchange={(e) => updateConfig('version', (e.target as HTMLInputElement).value.trim())}
/>
</div>
<div class="py-4 flex items-center justify-between gap-4">
<div class="shrink-0">
<div class="text-[13px] opacity-70">{$i18n.t('settings.openwebui.dataLocation')}</div>
<div class="text-[11px] opacity-25 mt-0.5">{$i18n.t('settings.openwebui.dataLocationDesc')}</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={defaultDataPath || 'Default'}
value={$config?.dataDir ?? ''}
onchange={async (e) => {
const val = (e.target as HTMLInputElement).value.trim()
await window.electronAPI.setConfig({ dataDir: 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) {
await window.electronAPI.setConfig({ dataDir: folder })
config.set(await window.electronAPI.getConfig())
}
}}
>
{$i18n.t('common.browse')}
</button>
</div>
</div>
<!-- Uninstall -->
<div class="py-4 flex items-center justify-between">
<div>
<div class="text-[13px] opacity-70">{$i18n.t('settings.openwebui.uninstall')}</div>
<div class="text-[11px] opacity-25 mt-0.5">{$i18n.t('settings.openwebui.uninstallDesc')}</div>
</div>
<button
class="text-[12px] opacity-40 hover:opacity-70 px-3 py-1.5 bg-black/[0.04] dark:bg-white/[0.06] transition border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-xl flex items-center gap-1.5 {uninstalling ? 'pointer-events-none opacity-20' : ''}"
disabled={uninstalling}
onclick={async () => {
if (confirm($i18n.t('settings.openwebui.uninstallConfirm'))) {
uninstalling = true
try {
if (isRunning) await window.electronAPI.stopServer()
await window.electronAPI.uninstallPackage('open-webui')
version = null
} catch (e) {
console.error('Failed to uninstall:', e)
}
uninstalling = false
}
}}
>
{#if uninstalling}
<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>
{$i18n.t('common.uninstalling')}
{:else}
{$i18n.t('common.uninstall')}
{/if}
</button>
</div>
</div>
{/if}
@@ -0,0 +1,163 @@
<script lang="ts">
import { fly, fade } from 'svelte/transition'
import { onMount } from 'svelte'
import { connections, config, appState, appInfo } from '../../stores'
import i18n from '../../i18n'
import logoImage from '../../assets/images/splash.png'
let view = $state('list') // list | add
let url = $state('')
let name = $state('')
let connecting = $state(false)
let error = $state('')
let visible = $state(false)
onMount(async () => {
connections.set(await window.electronAPI.getConnections())
config.set(await window.electronAPI.getConfig())
setTimeout(() => { visible = true }, 50)
})
const add = async () => {
if (!url.trim()) return
let u = url.trim()
if (!u.startsWith('http')) u = 'https://' + u
error = ''
connecting = true
try {
const valid = await window.electronAPI.validateUrl(u)
if (!valid) { error = $i18n.t('setup.connectionManager.unreachable'); connecting = false; return }
await window.electronAPI.addConnection({
id: crypto.randomUUID(),
name: name.trim() || new URL(u).hostname,
type: 'remote',
url: u
})
config.set(await window.electronAPI.getConfig())
url = ''; name = ''; view = 'list'
} catch { error = $i18n.t('setup.connectionManager.failed') }
finally { connecting = false }
}
const connect = (id: string) => window.electronAPI.connectTo(id)
const setDefault = async (id: string) => {
await window.electronAPI.setDefaultConnection(id)
config.set(await window.electronAPI.getConfig())
}
const remove = async (id: string) => {
await window.electronAPI.removeConnection(id)
config.set(await window.electronAPI.getConfig())
if (($connections ?? []).length === 0) appState.set('setup')
}
</script>
{#if visible}
<div class="h-full flex flex-col bg-[#f5f5f7] dark:bg-[#0a0a0a] text-[#1d1d1f] dark:text-[#fafafa]" in:fade={{ duration: 250 }}>
<!-- Header -->
<div class="flex items-center justify-between {$appInfo?.platform === 'darwin' ? 'pl-[76px]' : 'pl-5'} pr-5 pt-3 pb-2 drag-region">
<div class="text-[13px] opacity-50">{$i18n.t('setup.connectionManager.connections')}</div>
<img src={logoImage} class="w-5 h-5 rounded-full dark:invert opacity-40" alt="logo" />
</div>
<div class="mx-5 border-b border-black/[0.06] dark:border-white/[0.06]"></div>
<!-- Content -->
<div class="flex-1 min-h-0 overflow-y-auto px-5 py-3">
{#if view === 'list'}
<div class="flex flex-col">
{#each $connections as conn, i (conn.id)}
<div
class="w-full py-3 cursor-pointer group flex items-center gap-3 transition-opacity hover:opacity-100 opacity-70 {i > 0 ? 'border-t border-black/[0.04] dark:border-white/[0.04]' : ''}"
role="button"
tabindex="0"
onclick={() => connect(conn.id)}
onkeydown={(e) => e.key === 'Enter' && connect(conn.id)}
in:fly={{ y: 4, duration: 150, delay: i * 30 }}
>
<div class="w-[6px] h-[6px] rounded-full shrink-0 {conn.type === 'local' ? 'bg-green-400/70' : 'bg-black/8 dark:bg-white/10'}"></div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="text-[13px] truncate">{conn.name}</span>
{#if $config?.defaultConnectionId === conn.id}
<span class="text-[10px] opacity-30">{$i18n.t('common.default')}</span>
{/if}
</div>
<span class="text-[11px] opacity-20 truncate block mt-px">{conn.url}</span>
</div>
<div class="shrink-0 flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
{#if $config?.defaultConnectionId !== conn.id}
<button
class="p-1.5 opacity-20 hover:opacity-60 text-[10px] transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa]"
onclick={(e) => { e.stopPropagation(); setDefault(conn.id) }}
>★</button>
{/if}
<button
class="p-1.5 opacity-20 hover:text-red-400 hover:opacity-80 text-[10px] transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa]"
onclick={(e) => { e.stopPropagation(); remove(conn.id) }}
>✕</button>
</div>
</div>
{:else}
<div class="flex-1 flex items-center justify-center py-16">
<span class="text-[13px] opacity-15">{$i18n.t('setup.connectionManager.noConnections')}</span>
</div>
{/each}
</div>
<!-- Add button -->
<button
class="mt-4 inline-flex items-center gap-2 text-[13px] opacity-40 hover:opacity-70 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa]"
onclick={() => (view = 'add')}
>
{$i18n.t('setup.connectionManager.addConnection')}
</button>
{:else if view === 'add'}
<div in:fade={{ duration: 150 }}>
<button
class="text-[12px] opacity-40 hover:opacity-70 transition mb-6 bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa]"
onclick={() => { view = 'list'; error = '' }}
>
{$i18n.t('common.back')}
</button>
<div class="text-2xl font-light tracking-tight mb-5">{$i18n.t('setup.connectionManager.addConnectionTitle')}</div>
<div class="flex flex-col gap-2.5">
<input
type="text"
bind:value={url}
placeholder={$i18n.t('setup.connectionManager.serverUrl')}
class="w-full px-4 py-2.5 bg-black/[0.04] dark:bg-white/[0.06] text-[13px] text-[#1d1d1f] dark:text-[#fafafa] placeholder:opacity-20 outline-none focus:bg-white/[0.1] transition no-drag border-none"
onkeydown={(e) => e.key === 'Enter' && add()}
/>
<input
type="text"
bind:value={name}
placeholder={$i18n.t('setup.connectionManager.labelOptional')}
class="w-full px-4 py-2.5 bg-black/[0.04] dark:bg-white/[0.06] text-[13px] text-[#1d1d1f] dark:text-[#fafafa] placeholder:opacity-20 outline-none focus:bg-white/[0.1] transition no-drag border-none"
/>
{#if error}
<span class="text-[11px] text-red-400 opacity-80">{error}</span>
{/if}
<button
class="w-fit mt-2 inline-flex items-center gap-2 bg-white px-8 py-2.5 text-black text-[13px] transition hover:bg-gray-100 disabled:opacity-30 border-none"
onclick={add}
disabled={connecting}
>
{connecting ? $i18n.t('setup.connectionManager.adding') : $i18n.t('setup.connectionManager.add')}
{#if !connecting}
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M17 8l4 4m0 0l-4 4m4-4H3" />
</svg>
{/if}
</button>
</div>
</div>
{/if}
</div>
</div>
{/if}
@@ -0,0 +1,124 @@
<script lang="ts">
import { onMount } from 'svelte'
import { fade } from 'svelte/transition'
import { serverInfo, appState } from '../../stores'
import i18n from '../../i18n'
import logoImage from '../../assets/images/splash.png'
let { phase = 'loading' } = $props()
let visible = $state(false)
let installError = $state('')
let videoElement: HTMLVideoElement
// Extract available GB from appState like 'insufficient-storage:2.3'
const availableGB = $derived(
$appState?.startsWith('insufficient-storage:')
? $appState.split(':')[1]
: null
)
// Extract error message from appState like 'install-failed:message'
const installFailedMsg = $derived(
$appState?.startsWith('install-failed:')
? $appState.substring('install-failed:'.length)
: null
)
const retryCheck = async () => {
installError = ''
const api = window?.electronAPI
if (!api) return
const MINIMUM_DISK_BYTES = 5 * 1024 * 1024 * 1024
const disk = await api.getDiskSpace()
if (disk?.free >= 0 && disk.free < MINIMUM_DISK_BYTES) {
const gb = (disk.free / (1024 * 1024 * 1024)).toFixed(1)
appState.set(`insufficient-storage:${gb}`)
return
}
// Enough space now — proceed with Python install
appState.set('initializing')
try {
await api.installPython()
appState.set('ready')
} catch (e: any) {
installError = e?.message || $i18n.t('error.somethingWentWrong')
}
}
onMount(() => {
setTimeout(() => { visible = true }, 100)
if (videoElement) {
videoElement.play().catch(() => {})
}
})
</script>
{#if visible}
<div class="h-full w-full relative overflow-hidden bg-[#f5f5f7] dark:bg-[#0a0a0a]" in:fade={{ duration: 500 }}>
<!-- Video background -->
<div class="absolute inset-0 overflow-hidden">
<video
bind:this={videoElement}
autoplay
muted
loop
playsinline
class="absolute top-1/2 left-1/2 h-auto min-h-full w-auto min-w-full -translate-x-1/2 -translate-y-1/2 object-cover opacity-30"
>
<source src="https://community.s3.openwebui.com/landing.mp4" type="video/mp4" />
</video>
</div>
<div class="relative z-10 h-full flex items-center justify-center">
<div class="flex flex-col items-center gap-5">
<img src={logoImage} class="size-14 rounded-full dark:invert" alt="logo" />
{#if availableGB}
<div class="flex flex-col items-center gap-3 text-center" in:fade={{ duration: 250 }}>
<div class="text-sm text-red-400 opacity-80">
{$i18n.t('error.notEnoughDiskSpace')}
</div>
<div class="text-[11px] text-[#1d1d1f] dark:text-[#fafafa] opacity-30 max-w-[260px] leading-relaxed">
{$i18n.t('error.diskSpaceDetail', { available: availableGB })}
</div>
<button
class="mt-2 inline-flex items-center gap-2 bg-black/[0.04] dark:bg-white/[0.06] px-6 py-2 text-[12px] opacity-60 hover:opacity-90 transition border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-lg cursor-pointer"
onclick={retryCheck}
>
{$i18n.t('common.retry')}
</button>
</div>
{:else if installError || installFailedMsg}
<div class="flex flex-col items-center gap-3 text-center" in:fade={{ duration: 250 }}>
<div class="text-sm text-red-400 opacity-80">
{$i18n.t('error.installFailedGeneric')}
</div>
<div class="text-[11px] text-[#1d1d1f] dark:text-[#fafafa] opacity-30 max-w-[280px] leading-relaxed">
{installError || installFailedMsg}
</div>
<button
class="mt-2 inline-flex items-center gap-2 bg-black/[0.04] dark:bg-white/[0.06] px-6 py-2 text-[12px] opacity-60 hover:opacity-90 transition border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-lg cursor-pointer"
onclick={retryCheck}
>
{$i18n.t('common.retry')}
</button>
</div>
{:else if phase === 'initializing'}
<div class="flex flex-col items-center gap-2 text-center">
<div class="text-sm text-[#1d1d1f] dark:text-[#fafafa] opacity-50">
{$i18n.t('setup.preparingEnvironment')}
</div>
{#if $serverInfo?.status}
<div class="text-[11px] text-[#1d1d1f] dark:text-[#fafafa] opacity-25 max-w-[220px] leading-relaxed">
{$serverInfo.status}
</div>
{/if}
</div>
{/if}
</div>
</div>
</div>
{/if}
@@ -0,0 +1,138 @@
<script lang="ts">
import { fade, fly } from 'svelte/transition'
import { onMount } from 'svelte'
import { config, serverInfo } from '../../stores'
import i18n from '../../i18n'
import logoImage from '../../assets/images/splash.png'
let { onBack, onComplete, autoStart = false } = $props()
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.setDefaultConnection('local')
config.set(await window.electronAPI.getConfig())
phase = 'done'
setTimeout(async () => {
await window.electronAPI.connectTo('local')
onComplete()
}, 800)
} catch (e) {
phase = 'error'
errorMsg = e?.message || $i18n.t('setup.install.somethingWentWrong')
}
}
const changeInstallDir = async () => {
const folder = await window.electronAPI.selectFolder()
if (folder) {
installDir = folder
}
}
</script>
<div class="flex flex-col" in:fade={{ duration: 200 }}>
<button
class="self-start text-[12px] opacity-40 hover:opacity-70 transition mb-6 bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] disabled:opacity-20"
onclick={onBack}
disabled={phase === 'working'}
>
{$i18n.t('common.back')}
</button>
{#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-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}
>
{$i18n.t('setup.install.continue')}
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M17 8l4 4m0 0l-4 4m4-4H3" />
</svg>
</button>
{:else if phase === 'working'}
<div class="flex flex-col items-center gap-5 py-10" in:fade={{ duration: 250 }}>
<img src={logoImage} class="size-12 rounded-full dark:invert" alt="logo" />
<div class="flex flex-col items-center gap-2 text-center">
<div class="text-sm opacity-60">{$i18n.t('setup.install.installing')}</div>
{#if $serverInfo?.status}
<div class="text-[11px] opacity-30 max-w-[220px] leading-relaxed" in:fade={{ duration: 200 }}>
{$serverInfo.status}
</div>
{:else}
<div class="text-[11px] opacity-20">
{$i18n.t('setup.install.mightTakeMinutes')}
</div>
{/if}
</div>
</div>
{:else if phase === 'done'}
<div class="flex flex-col items-center gap-4 py-10" in:fade={{ duration: 250 }}>
<img src={logoImage} class="size-12 rounded-full dark:invert" alt="logo" />
<div class="text-sm text-green-400 opacity-70">{$i18n.t('common.ready')}</div>
</div>
{:else if phase === 'error'}
<div class="flex flex-col items-center gap-4 py-10" in:fade={{ duration: 250 }}>
<div class="text-[12px] text-red-400 opacity-80">{errorMsg}</div>
<button
class="w-fit inline-flex items-center gap-2 bg-black/[0.04] dark:bg-white/[0.06] px-6 py-2 text-[12px] opacity-60 hover:opacity-90 transition border-none text-[#1d1d1f] dark:text-[#fafafa]"
onclick={() => (phase = 'ready')}
>
{$i18n.t('common.retry')}
</button>
</div>
{/if}
</div>
@@ -0,0 +1,129 @@
<script lang="ts">
import { onMount } from 'svelte'
import { fly, fade } from 'svelte/transition'
import { appState, config } from '../../stores'
import i18n from '../../i18n'
import LocalInstall from './LocalInstall.svelte'
import logoImage from '../../assets/images/splash.png'
let view = $state('main') // main | install
let url = $state('')
let connecting = $state(false)
let error = $state('')
let mounted = $state(false)
let videoElement: HTMLVideoElement
onMount(() => {
setTimeout(() => { mounted = true }, 100)
if (videoElement) {
videoElement.play().catch(() => {})
}
})
const connect = async () => {
if (!url.trim()) return
let u = url.trim()
if (!u.startsWith('http')) u = 'https://' + u
error = ''
connecting = true
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: connId,
name: new URL(u).hostname,
type: 'remote',
url: u
})
config.set(await window.electronAPI.getConfig())
await window.electronAPI.connectTo(connId)
appState.set('ready')
} catch {
error = $i18n.t('setup.connectionFailed')
} finally {
connecting = false
}
}
</script>
<div class="h-full w-full relative overflow-hidden bg-[#f5f5f7] dark:bg-[#0a0a0a] text-[#1d1d1f] dark:text-[#fafafa]">
<!-- Video background -->
<div class="absolute inset-0 overflow-hidden">
<video
bind:this={videoElement}
autoplay
muted
loop
playsinline
class="absolute top-1/2 left-1/2 h-auto min-h-full w-auto min-w-full -translate-x-1/2 -translate-y-1/2 object-cover opacity-30"
>
<source src="https://community.s3.openwebui.com/landing.mp4" type="video/mp4" />
</video>
</div>
<!-- Drag region -->
<div class="absolute top-0 left-0 right-0 h-8 drag-region z-10"></div>
<!-- Content -->
{#if mounted}
<div class="relative z-10 h-full flex flex-col justify-end px-8 pb-10">
{#if view === 'main'}
<div class="max-w-sm" in:fly={{ duration: 500, y: 10 }}>
<div class="mb-2 text-sm font-normal opacity-50">{$i18n.t('app.name')}</div>
<h1 class="text-3xl leading-tight font-light tracking-tight mb-6">
{$i18n.t('setup.newConnection')}
</h1>
<div class="flex flex-col gap-2.5">
<div class="flex gap-2">
<input
type="text"
bind:value={url}
placeholder={$i18n.t('setup.urlPlaceholder')}
class="flex-1 px-4 py-2.5 bg-black/[0.04] dark:bg-white/[0.06] text-[13px] text-[#1d1d1f] dark:text-[#fafafa] placeholder:opacity-20 outline-none focus:bg-white/[0.1] transition no-drag border-none"
onkeydown={(e) => e.key === 'Enter' && connect()}
/>
<button
class="inline-flex items-center gap-2 bg-white px-6 py-2.5 text-black text-[13px] transition hover:bg-gray-100 disabled:opacity-30 border-none shrink-0"
onclick={connect}
disabled={connecting || !url.trim()}
>
{connecting ? $i18n.t('common.connecting') : $i18n.t('common.connect')}
{#if !connecting}
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M17 8l4 4m0 0l-4 4m4-4H3" />
</svg>
{/if}
</button>
</div>
{#if error}
<p class="text-[11px] text-red-400 opacity-80">{error}</p>
{/if}
</div>
<div class="mt-6">
<button
class="text-sm opacity-40 hover:opacity-70 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa]"
onclick={() => (view = 'install')}
>
{$i18n.t('setup.orInstallLocally')}
</button>
</div>
</div>
{:else if view === 'install'}
<div class="max-w-sm">
<LocalInstall
onBack={() => (view = 'main')}
onComplete={() => appState.set('ready')}
/>
</div>
{/if}
</div>
{/if}
</div>
@@ -1,9 +0,0 @@
<script lang="ts">
const versions = window.electron.process.versions;
</script>
<ul class="versions">
<li class="electron-version">Electron v{versions.electron}</li>
<li class="chrome-version">Chromium v{versions.chrome}</li>
<li class="node-version">Node v{versions.node}</li>
</ul>
@@ -1,183 +0,0 @@
<script lang="ts">
import DOMPurify from "dompurify";
import { onMount, onDestroy, tick } from "svelte";
import * as FocusTrap from "focus-trap";
import { fade } from "svelte/transition";
import { flyAndScale } from "../../utils/transitions";
import { marked } from "marked";
let {
title,
message,
cancelLabel = "Cancel",
confirmLabel = "Confirm",
onConfirm = () => {},
input,
inputPlaceholder,
inputValue,
show = $bindable(false),
} = $props();
$effect(() => {
if (show) {
init();
}
});
$effect(() => {
if (mounted) {
if (show && modalElement) {
document.body.appendChild(modalElement);
focusTrap = FocusTrap.createFocusTrap(modalElement);
focusTrap.activate();
window.addEventListener("keydown", handleKeyDown);
document.body.style.overflow = "hidden";
} else if (modalElement) {
focusTrap.deactivate();
window.removeEventListener("keydown", handleKeyDown);
document.body.removeChild(modalElement);
document.body.style.overflow = "unset";
}
}
});
let modalElement = null;
let mounted = false;
let focusTrap: FocusTrap.FocusTrap | null = null;
const init = () => {
inputValue = "";
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
console.log("Escape");
show = false;
}
if (event.key === "Enter") {
console.log("Enter");
confirmHandler();
}
};
const confirmHandler = async () => {
show = false;
await tick();
await onConfirm();
};
onMount(() => {
mounted = true;
});
onDestroy(() => {
show = false;
if (focusTrap) {
focusTrap.deactivate();
}
if (modalElement) {
document.body.removeChild(modalElement);
}
});
</script>
{#if show}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
bind:this={modalElement}
class=" fixed top-0 right-0 left-0 bottom-0 bg-black/60 w-full h-screen max-h-[100dvh] flex justify-center z-99999999 overflow-hidden overscroll-contain"
in:fade={{ duration: 10 }}
on:mousedown={() => {
show = false;
}}
>
<div
class=" m-auto rounded-2xl max-w-full w-[32rem] mx-2 bg-gray-50 dark:bg-gray-950 max-h-[100dvh] shadow-3xl"
in:flyAndScale
on:mousedown={(e) => {
e.stopPropagation();
}}
>
<div class="px-[1.75rem] py-6 flex flex-col">
<div class=" text-lg font-semibold dark:text-gray-200 mb-2.5">
{#if title !== ""}
{title}
{:else}
{"Confirm your action"}
{/if}
</div>
<slot>
<div class=" text-sm text-gray-500 flex-1">
{#if message !== ""}
{@const html = DOMPurify.sanitize(
marked.parse(message)
)}
{@html html}
{:else}
{"This action cannot be undone. Do you wish to continue?"}
{/if}
{#if input}
<textarea
bind:value={inputValue}
placeholder={inputPlaceholder
? inputPlaceholder
: "Enter your message"}
class="w-full mt-2 rounded-lg px-4 py-2 text-sm dark:text-gray-300 dark:bg-gray-900 outline-hidden resize-none"
rows="3"
required
/>
{/if}
</div>
</slot>
<div class="mt-6 flex justify-between gap-1.5">
<button
class="bg-gray-100 hover:bg-gray-200 text-gray-800 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-white font-medium w-full py-2.5 rounded-lg transition"
on:click={() => {
show = false;
}}
type="button"
>
{cancelLabel}
</button>
<button
class="bg-gray-900 hover:bg-gray-850 text-gray-100 dark:bg-gray-100 dark:hover:bg-white dark:text-gray-800 font-medium w-full py-2.5 rounded-lg transition"
on:click={() => {
confirmHandler();
}}
type="button"
>
{confirmLabel}
</button>
</div>
</div>
</div>
</div>
{/if}
<style>
.modal-content {
animation: scaleUp 0.1s ease-out forwards;
}
@keyframes scaleUp {
from {
transform: scale(0.985);
opacity: 0;
}
to {
transform: scale(1);
opacity: 1;
}
}
</style>
@@ -0,0 +1,108 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte'
import { Terminal } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import '@xterm/xterm/css/xterm.css'
interface Props {
connect: (callback: (data: string) => void) => void
disconnect: () => void
readonly?: boolean
onWrite?: (data: string) => void
onResize?: (cols: number, rows: number) => void
}
let {
connect,
disconnect,
readonly = false,
onWrite,
onResize
}: Props = $props()
let containerEl: HTMLDivElement | undefined = $state()
let term: Terminal | null = null
let fitAddon: FitAddon | null = null
let resizeObserver: ResizeObserver | null = null
const initTerminal = () => {
if (!containerEl || term) return
term = new Terminal({
cursorBlink: false,
disableStdin: readonly,
fontSize: 11,
fontFamily: "'SF Mono', 'Fira Code', 'Cascadia Code', Menlo, Monaco, monospace",
lineHeight: 1.5,
scrollback: 10000,
theme: {
background: '#0a0a0a',
foreground: '#d4d4d4',
cursor: readonly ? '#d4d4d4' : 'transparent',
selectionBackground: readonly ? '#264f78' : '#ffffff30'
},
convertEol: true
})
fitAddon = new FitAddon()
term.loadAddon(fitAddon)
term.open(containerEl)
requestAnimationFrame(() => fitAddon?.fit())
resizeObserver = new ResizeObserver(() => {
try {
fitAddon?.fit()
if (term && onResize) {
onResize(term.cols, term.rows)
}
} catch {}
})
resizeObserver.observe(containerEl)
if (!readonly && onWrite) {
term.onData((data: string) => {
onWrite!(data)
})
}
connect((data: string) => {
term?.write(data)
})
if (term && onResize) {
onResize(term.cols, term.rows)
}
}
const destroyTerminal = () => {
resizeObserver?.disconnect()
resizeObserver = null
disconnect()
term?.dispose()
term = null
fitAddon = null
}
export const getBufferText = (): string | null => {
if (!term) return null
const buf = term.buffer.active
const lines: string[] = []
for (let i = 0; i < buf.length; i++) {
lines.push(buf.getLine(i)?.translateToString(true) ?? '')
}
return lines.join('\n').trimEnd()
}
onMount(() => {
initTerminal()
})
onDestroy(() => {
destroyTerminal()
})
</script>
<div
class="absolute inset-0 px-3 py-2 bg-[#0a0a0a]"
bind:this={containerEl}
></div>

Some files were not shown because too many files have changed in this diff Show More