mirror of
https://github.com/open-webui/desktop.git
synced 2026-07-01 20:54:03 -04:00
Compare commits
75 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0931b46d66 | |||
| 601137c5de | |||
| a097a83c46 | |||
| f8c20275cd | |||
| fa64bc02b5 | |||
| 20f0aaf40c | |||
| c64e946b38 | |||
| 1902f791cb | |||
| c2f128aec0 | |||
| 64c399738e | |||
| 842c1481f6 | |||
| e7eef2da86 | |||
| aab2a687cc | |||
| 9e204e78b7 | |||
| ef53d6fb21 | |||
| 21e8a36e0d | |||
| fcb32f93ab | |||
| 48be8d0386 | |||
| 4cab91de4e | |||
| 564a89baa8 | |||
| 25b9e195c2 | |||
| 61db9dc10f | |||
| 27a3075c3a | |||
| 8c990befbe | |||
| da84e49970 | |||
| e0af7f3d32 | |||
| 4f653f5fcd | |||
| 3a76f985ab | |||
| 06808cb284 | |||
| ed26423f90 | |||
| 5e95e918c7 | |||
| 84c93aaeb6 | |||
| 37f0891840 | |||
| eb3c569078 | |||
| 3350da65ec | |||
| 953327b9ef | |||
| 1a56df0c6e | |||
| 44c40eabd6 | |||
| d475bde04a | |||
| 5cbe7553d3 | |||
| 7160392959 | |||
| a889d0e607 | |||
| ef66b1b21a | |||
| 6c669f1389 | |||
| fe398bc65d | |||
| f38c95befe | |||
| 4db0faff97 | |||
| 13dfb0f779 | |||
| 201b08826e | |||
| e12bc93d71 | |||
| be5661116f | |||
| 0680b56e1c | |||
| 20d7f145c7 | |||
| 0edc4d7532 | |||
| 36eacde7e9 | |||
| 03f6abae75 | |||
| 08616e701d | |||
| 34ce8c9d4f | |||
| c551bf0cb6 | |||
| 335f72ae4c | |||
| 563d87349c | |||
| b9ae57d008 | |||
| 8585558492 | |||
| 08356674e1 | |||
| b4ae19abf0 | |||
| 5aa13c813e | |||
| 14258c4b36 | |||
| a2f6de45f5 | |||
| 3b3349d3b5 | |||
| b099a4a6fa | |||
| 4920e90bef | |||
| 6852b4f83e | |||
| a29f1fe1f1 | |||
| 4076e12976 | |||
| e9f8c89cc9 |
+270
-49
@@ -6,8 +6,35 @@ on:
|
||||
- release
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build and Package
|
||||
compile:
|
||||
name: Compile (Typecheck + Vite Build)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Typecheck and Build
|
||||
run: npm run build
|
||||
|
||||
- name: Upload Compiled Output
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: compiled-output
|
||||
path: out/
|
||||
retention-days: 1
|
||||
|
||||
package:
|
||||
name: Package (${{ matrix.os }}-${{ matrix.arch }})
|
||||
needs: compile
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -15,8 +42,12 @@ jobs:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
arch: x64
|
||||
- os: ubuntu-24.04-arm
|
||||
arch: arm64
|
||||
- os: windows-latest
|
||||
arch: x64
|
||||
- os: windows-11-arm
|
||||
arch: arm64
|
||||
- os: macos-latest
|
||||
arch: x64
|
||||
- os: macos-latest
|
||||
@@ -29,14 +60,28 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "npm"
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Download Compiled Output
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: compiled-output
|
||||
path: out/
|
||||
|
||||
# ── Flatpak setup (Linux x64 only) ──
|
||||
- name: Cache Flatpak SDKs
|
||||
if: runner.os == 'Linux' && matrix.arch == 'x64'
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.local/share/flatpak
|
||||
key: flatpak-sdk-23.08-electron2-${{ runner.os }}
|
||||
|
||||
- name: Install Flatpak build tools
|
||||
id: flatpak
|
||||
if: ${{ matrix.os == 'ubuntu-latest' }}
|
||||
if: runner.os == 'Linux' && matrix.arch == 'x64'
|
||||
continue-on-error: true
|
||||
run: |
|
||||
sudo apt-get update
|
||||
@@ -45,9 +90,10 @@ jobs:
|
||||
flatpak install --user -y flathub org.freedesktop.Platform//23.08 org.freedesktop.Sdk//23.08
|
||||
flatpak install --user -y flathub org.electronjs.Electron2.BaseApp//23.08
|
||||
|
||||
# ── Apple codesigning (macOS only) ──
|
||||
- name: Install Apple codesigning certificate
|
||||
id: apple_cert
|
||||
if: ${{ matrix.os == 'macos-latest' }}
|
||||
if: matrix.os == 'macos-latest'
|
||||
continue-on-error: true
|
||||
env:
|
||||
BUILD_CERTIFICATE_BASE64: ${{ secrets.BUILD_CERTIFICATE_BASE64 }}
|
||||
@@ -65,41 +111,61 @@ jobs:
|
||||
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
|
||||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||
security import $CERTIFICATE_PATH -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
|
||||
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||
security list-keychain -d user -s $KEYCHAIN_PATH
|
||||
|
||||
# Build commands — use electron-builder directly to pass arch flags
|
||||
- name: Create Windows Builds
|
||||
if: ${{ matrix.os == 'windows-latest' }}
|
||||
run: npm run build && npx electron-builder --win --${{ matrix.arch }}
|
||||
# ── Platform packaging ──
|
||||
- name: Package for Windows
|
||||
id: win_build
|
||||
if: runner.os == 'Windows'
|
||||
continue-on-error: true
|
||||
run: npx electron-builder --win --${{ matrix.arch }} --publish never
|
||||
|
||||
- name: Create macOS Builds (signed)
|
||||
if: ${{ matrix.os == 'macos-latest' && steps.apple_cert.outcome == 'success' }}
|
||||
- name: Package for Windows (unsigned fallback)
|
||||
if: runner.os == 'Windows' && steps.win_build.outcome == 'failure'
|
||||
env:
|
||||
WIN_CSC_LINK: ''
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: 'false'
|
||||
run: |
|
||||
rm -rf dist/
|
||||
npx electron-builder --win --${{ matrix.arch }} --publish never
|
||||
|
||||
- name: Package for macOS (signed + notarized)
|
||||
id: mac_build
|
||||
if: matrix.os == 'macos-latest' && steps.apple_cert.outcome == 'success'
|
||||
continue-on-error: true
|
||||
env:
|
||||
CSC_LINK: ${{ secrets.BUILD_CERTIFICATE_BASE64 }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.P12_PASSWORD }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: npm run build && npx electron-builder --mac --${{ matrix.arch }}
|
||||
run: npx electron-builder --mac --${{ matrix.arch }} -c.mac.notarize=true --publish never
|
||||
|
||||
- name: Create macOS Builds (unsigned fallback)
|
||||
if: ${{ matrix.os == 'macos-latest' && steps.apple_cert.outcome != 'success' }}
|
||||
- name: Package for macOS (unsigned fallback)
|
||||
if: matrix.os == 'macos-latest' && (steps.apple_cert.outcome != 'success' || steps.mac_build.outcome == 'failure')
|
||||
env:
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: 'false'
|
||||
run: npm run build && npx electron-builder --mac --${{ matrix.arch }}
|
||||
|
||||
- name: Create Linux Builds
|
||||
if: ${{ matrix.os == 'ubuntu-latest' }}
|
||||
run: |
|
||||
npm run build
|
||||
if [ "${{ steps.flatpak.outcome }}" == "success" ]; then
|
||||
npx electron-builder --linux --${{ matrix.arch }}
|
||||
rm -rf dist/
|
||||
npx electron-builder --mac --${{ matrix.arch }} --publish never
|
||||
|
||||
- name: Package for Linux
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
if [ "${{ matrix.arch }}" == "arm64" ]; then
|
||||
# ARM64: deb + AppImage only (flatpak BaseApp not available for arm64)
|
||||
npx electron-builder --linux AppImage deb --${{ matrix.arch }} --publish never
|
||||
elif [ "${{ steps.flatpak.outcome }}" == "success" ]; then
|
||||
npx electron-builder --linux --${{ matrix.arch }} --publish never
|
||||
else
|
||||
echo "Flatpak not available, building without flatpak"
|
||||
npx electron-builder --linux AppImage deb snap --${{ matrix.arch }}
|
||||
npx electron-builder --linux AppImage deb snap --${{ matrix.arch }} --publish never
|
||||
fi
|
||||
|
||||
# Sign Windows executable after build
|
||||
# ── Windows code signing ──
|
||||
- name: Azure Trusted Signing (Windows Only)
|
||||
if: ${{ matrix.os == 'windows-latest' }}
|
||||
if: runner.os == 'Windows'
|
||||
continue-on-error: true
|
||||
uses: azure/trusted-signing-action@v0.5.1
|
||||
with:
|
||||
@@ -112,12 +178,7 @@ jobs:
|
||||
files-folder: dist
|
||||
files-folder-filter: exe
|
||||
|
||||
- name: List files for debugging
|
||||
shell: bash
|
||||
run: |
|
||||
echo "Files in dist directory:"
|
||||
ls -la dist/ || echo "dist directory not found"
|
||||
|
||||
# ── Upload release artifacts ──
|
||||
- name: Upload Artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
@@ -138,14 +199,24 @@ jobs:
|
||||
if-no-files-found: warn
|
||||
|
||||
release:
|
||||
needs: build
|
||||
if: always() && github.event_name == 'push' && github.ref == 'refs/heads/release'
|
||||
name: Create GitHub Release
|
||||
needs: package
|
||||
if: >-
|
||||
github.event_name == 'push' &&
|
||||
github.ref == 'refs/heads/release' &&
|
||||
!cancelled() &&
|
||||
needs.package.result != 'failure'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: |
|
||||
package.json
|
||||
CHANGELOG.md
|
||||
sparse-checkout-cone-mode: false
|
||||
|
||||
- name: Get version from package.json
|
||||
id: version
|
||||
@@ -171,16 +242,162 @@ jobs:
|
||||
|
||||
- name: Download Artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: '*-*'
|
||||
merge-multiple: false
|
||||
|
||||
- name: List downloaded artifacts
|
||||
- name: Install js-yaml for manifest merging
|
||||
run: npm install --no-save js-yaml
|
||||
|
||||
- name: Merge macOS latest-mac.yml (x64 + arm64)
|
||||
run: |
|
||||
echo "Downloaded artifacts:"
|
||||
find . -type f | grep -E "\.(exe|zip|dmg|pkg|deb|rpm|AppImage|flatpak|tar\.gz|yml|blockmap)$" || echo "No package files found"
|
||||
ls -laR
|
||||
# Each macOS arch build produces its own latest-mac.yml with only
|
||||
# that arch's entry. Merge them so electron-updater works for both.
|
||||
X64_YML="macos-latest-x64/latest-mac.yml"
|
||||
ARM_YML="macos-latest-arm64/latest-mac.yml"
|
||||
|
||||
if [ -f "$X64_YML" ] && [ -f "$ARM_YML" ]; then
|
||||
echo "Merging latest-mac.yml from both architectures"
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const yaml = require('js-yaml');
|
||||
const x64 = yaml.load(fs.readFileSync('$X64_YML', 'utf8'));
|
||||
const arm = yaml.load(fs.readFileSync('$ARM_YML', 'utf8'));
|
||||
const all = [...(x64.files || []), ...(arm.files || [])];
|
||||
const map = new Map();
|
||||
for (const f of all) map.set(f.url, f);
|
||||
x64.files = [...map.values()];
|
||||
const out = yaml.dump(x64, { lineWidth: -1 });
|
||||
fs.writeFileSync('$X64_YML', out);
|
||||
console.log(out);
|
||||
"
|
||||
rm -f "$ARM_YML"
|
||||
else
|
||||
echo "Skipping merge — need both $X64_YML and $ARM_YML"
|
||||
ls -la macos-latest-*/latest-mac.yml 2>/dev/null || true
|
||||
fi
|
||||
|
||||
- name: Merge Linux latest-linux.yml (x64 + arm64)
|
||||
run: |
|
||||
X64_YML="ubuntu-latest-x64/latest-linux.yml"
|
||||
# electron-builder names the arm64 Linux manifest "latest-linux-arm64.yml"
|
||||
ARM_YML="ubuntu-24.04-arm-arm64/latest-linux-arm64.yml"
|
||||
|
||||
if [ -f "$X64_YML" ] && [ -f "$ARM_YML" ]; then
|
||||
echo "Merging latest-linux.yml from both architectures"
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const yaml = require('js-yaml');
|
||||
const x64 = yaml.load(fs.readFileSync('$X64_YML', 'utf8'));
|
||||
const arm = yaml.load(fs.readFileSync('$ARM_YML', 'utf8'));
|
||||
const all = [...(x64.files || []), ...(arm.files || [])];
|
||||
const map = new Map();
|
||||
for (const f of all) map.set(f.url, f);
|
||||
x64.files = [...map.values()];
|
||||
const out = yaml.dump(x64, { lineWidth: -1 });
|
||||
fs.writeFileSync('$X64_YML', out);
|
||||
console.log(out);
|
||||
"
|
||||
rm -f "$ARM_YML"
|
||||
else
|
||||
echo "Skipping merge — need both $X64_YML and $ARM_YML"
|
||||
ls -la ubuntu-*-*/latest-linux*.yml 2>/dev/null || true
|
||||
fi
|
||||
|
||||
- name: Merge Windows latest.yml (x64 + arm64)
|
||||
run: |
|
||||
X64_YML="windows-latest-x64/latest.yml"
|
||||
ARM_YML="windows-11-arm-arm64/latest.yml"
|
||||
|
||||
if [ -f "$X64_YML" ] && [ -f "$ARM_YML" ]; then
|
||||
echo "Merging latest.yml from both architectures"
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const yaml = require('js-yaml');
|
||||
const x64 = yaml.load(fs.readFileSync('$X64_YML', 'utf8'));
|
||||
const arm = yaml.load(fs.readFileSync('$ARM_YML', 'utf8'));
|
||||
const all = [...(x64.files || []), ...(arm.files || [])];
|
||||
const map = new Map();
|
||||
for (const f of all) map.set(f.url, f);
|
||||
x64.files = [...map.values()];
|
||||
const out = yaml.dump(x64, { lineWidth: -1 });
|
||||
fs.writeFileSync('$X64_YML', out);
|
||||
console.log(out);
|
||||
"
|
||||
rm -f "$ARM_YML"
|
||||
else
|
||||
echo "Skipping merge — need both $X64_YML and $ARM_YML"
|
||||
ls -la windows-*-*/latest.yml 2>/dev/null || true
|
||||
fi
|
||||
|
||||
- name: Reconcile update manifest hashes
|
||||
run: |
|
||||
# Recompute SHA512 from actual artifact files to ensure manifests
|
||||
# match the real binaries (fixes signed/unsigned hash mismatches)
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const crypto = require('crypto');
|
||||
const yaml = require('js-yaml');
|
||||
const path = require('path');
|
||||
|
||||
const manifests = [
|
||||
{ prefix: 'macos-latest-', file: 'latest-mac.yml' },
|
||||
{ prefix: 'ubuntu-', file: 'latest-linux.yml' },
|
||||
{ prefix: 'windows-', file: 'latest.yml' },
|
||||
];
|
||||
|
||||
const allDirs = fs.readdirSync('.').filter(d => {
|
||||
try { return fs.statSync(d).isDirectory(); } catch { return false; }
|
||||
});
|
||||
|
||||
for (const { prefix, file } of manifests) {
|
||||
const dirs = allDirs.filter(d => d.startsWith(prefix));
|
||||
let ymlPath = null;
|
||||
for (const dir of dirs) {
|
||||
const p = path.join(dir, file);
|
||||
if (fs.existsSync(p)) { ymlPath = p; break; }
|
||||
}
|
||||
if (!ymlPath) { console.log('No ' + file + ' found, skipping'); continue; }
|
||||
|
||||
const manifest = yaml.load(fs.readFileSync(ymlPath, 'utf8'));
|
||||
let fixed = 0;
|
||||
|
||||
for (const entry of manifest.files) {
|
||||
for (const dir of dirs) {
|
||||
const filePath = path.join(dir, entry.url);
|
||||
if (fs.existsSync(filePath)) {
|
||||
const buf = fs.readFileSync(filePath);
|
||||
const hash = crypto.createHash('sha512').update(buf).digest('base64');
|
||||
if (hash !== entry.sha512) {
|
||||
console.log('[' + file + '] Fix: ' + entry.url);
|
||||
entry.sha512 = hash;
|
||||
entry.size = buf.length;
|
||||
fixed++;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (manifest.path) {
|
||||
for (const dir of dirs) {
|
||||
const filePath = path.join(dir, manifest.path);
|
||||
if (fs.existsSync(filePath)) {
|
||||
const buf = fs.readFileSync(filePath);
|
||||
manifest.sha512 = crypto.createHash('sha512').update(buf).digest('base64');
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const out = yaml.dump(manifest, { lineWidth: -1 });
|
||||
fs.writeFileSync(ymlPath, out);
|
||||
console.log('[' + file + '] ' + (fixed ? 'Fixed ' + fixed + ' entries' : 'All hashes OK'));
|
||||
}
|
||||
"
|
||||
|
||||
- name: Create Release
|
||||
id: create_release
|
||||
uses: softprops/action-gh-release@v1
|
||||
uses: softprops/action-gh-release@v2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
@@ -190,14 +407,18 @@ jobs:
|
||||
draft: false
|
||||
prerelease: false
|
||||
files: |
|
||||
**/*.zip
|
||||
**/*.exe
|
||||
**/*.dmg
|
||||
**/*.pkg
|
||||
**/*.deb
|
||||
**/*.rpm
|
||||
**/*.AppImage
|
||||
**/*.flatpak
|
||||
**/*.tar.gz
|
||||
**/*.blockmap
|
||||
**/latest*.yml
|
||||
windows-*-*/*.exe
|
||||
windows-*-*/*.blockmap
|
||||
windows-*-*/latest*.yml
|
||||
macos-latest-*/*.dmg
|
||||
macos-latest-*/*.zip
|
||||
macos-latest-*/*.pkg
|
||||
macos-latest-*/*.blockmap
|
||||
macos-latest-*/latest*.yml
|
||||
ubuntu-*-*/*.deb
|
||||
ubuntu-*-*/*.rpm
|
||||
ubuntu-*-*/*.AppImage
|
||||
ubuntu-*-*/*.snap
|
||||
ubuntu-*-*/*.flatpak
|
||||
ubuntu-*-*/*.tar.gz
|
||||
ubuntu-*-*/latest*.yml
|
||||
|
||||
@@ -6,3 +6,4 @@ out
|
||||
.DS_Store
|
||||
.eslintcache
|
||||
*.log*
|
||||
*.tsbuildinfo
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
electron_mirror=https://npmmirror.com/mirrors/electron/
|
||||
electron_builder_binaries_mirror=https://npmmirror.com/mirrors/electron-builder-binaries/
|
||||
|
||||
|
||||
+200
-16
@@ -5,23 +5,207 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.0.20] - 2026-05-07
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Blank Webview on Linux.** Replaced the `--in-process-gpu` Chromium flag with SwiftShader software rendering (`--use-gl=angle --use-angle=swiftshader`). The in-process GPU flag broke `<webview>` guest compositing entirely, leaving connection views blank on all Linux configurations. SwiftShader keeps the GPU process out-of-process (required for webview compositing) while avoiding driver-level crashes (#178).
|
||||
|
||||
## [0.0.19] - 2026-05-06
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Spotlight Pulls to First Desktop on macOS.** Spotlight no longer switches Spaces when triggered from a non-primary desktop. The window is now visible on all workspaces, and `app.focus({ steal: true })` — which activated the entire app and caused the Space switch — has been replaced with targeted window-level focus (#179).
|
||||
- **Gray Screen When Connecting to Server on Linux.** Replaced the `--disable-gpu` Chromium flag with `--in-process-gpu`, which keeps the display compositor alive so `<webview>` guest surfaces actually paint instead of showing a gray rectangle. The previous flag fixed GPU process crashes but broke webview rendering on Debian and Ubuntu (#178).
|
||||
- **Open Terminal Fails Silently Without Python.** Open Terminal now automatically installs Python when it's missing instead of throwing an opaque error. Progress status is surfaced in the UI during installation.
|
||||
- **Corrupt Auto-Update Manifests.** Fixed the release workflow to deduplicate artifact entries during manifest merging, preventing SHA512 checksum mismatches that caused updates to fail silently.
|
||||
|
||||
## [0.0.18] - 2026-05-05
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Downloaded Models Not Recognized by llama.cpp.** Models downloaded from Hugging Face are now stored directly under the `models/` directory instead of a nested `models/huggingface/` subdirectory, so llama-server's model scanner discovers them without manual symlinks. Existing models in the old location are automatically migrated on startup (#177).
|
||||
|
||||
## [0.0.17] - 2026-05-03
|
||||
|
||||
### Added
|
||||
|
||||
- **Webview Context Menu.** Right-clicking inside the webview now shows a native context menu with Cut, Copy, Paste, Undo/Redo, spell-check suggestions, and "Open Link in Browser" — enabling system autofill and password manager integration on login pages (#161).
|
||||
|
||||
### Changed
|
||||
|
||||
- **Windows OpenSSL Compatibility.** The bundled Python's directory is now prepended to `PATH` on Windows so its own OpenSSL DLLs are loaded before any conflicting system-wide installations (Git for Windows, Anaconda, Strawberry Perl, etc.), preventing the `OPENSSL_Uplink: no OPENSSL_Applink` crash on startup (#167).
|
||||
- **Links Open in Default Browser on Windows.** Added `allowpopups` to the webview so that `target="_blank"` link clicks correctly propagate to the main process handler and open in the default browser instead of being silently blocked (#165, #170).
|
||||
- **Linux System Requirements.** Documentation now specifies glibc 2.28+ as a minimum requirement for Linux installations.
|
||||
|
||||
## [0.0.16] - 2026-05-02
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Links Open in Default Browser.** Clicking links in chat responses now opens them in the user's default browser instead of navigating within the app or spawning a new Electron window (#165).
|
||||
|
||||
## [0.0.15] - 2026-04-28
|
||||
|
||||
### Added
|
||||
|
||||
- **ARM64 Support for Linux and Windows.** Native ARM64 builds are now produced for Linux (.deb, AppImage) and Windows (NSIS installer), enabling support for Raspberry Pi, NVIDIA DGX Spark, Snapdragon laptops, and other ARM64 devices (#140).
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Grey/Blank Screen on Linux.** Disabled GPU compositing entirely on Linux to prevent shared memory allocation crashes that caused a grey or blank screen on systems with restricted `/dev/shm` or `/tmp` permissions.
|
||||
- **Spotlight Dismiss Behavior.** Pressing Escape or the toggle shortcut to dismiss Spotlight no longer erroneously brings the main application window to the foreground (#158).
|
||||
|
||||
## [0.0.14] - 2026-04-28
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Grey/Blank Webview on Linux.** Disabled GPU compositing on Linux to prevent silent compositor failures that produce a grey rectangle instead of rendered content on systems with problematic Intel/NVIDIA drivers or certain Wayland compositors (#119).
|
||||
- **Renderer Crash Recovery.** The main window now automatically reloads when the renderer process dies unexpectedly, preventing a permanent blank/grey screen.
|
||||
- **Webview Crash Diagnostics.** Added logging for guest webview renderer crashes to aid debugging connectivity and rendering issues.
|
||||
- **macOS Notarization.** Resolved Apple notarization failure caused by an expired Developer Program agreement, restoring signed and notarized macOS builds.
|
||||
|
||||
## [0.0.13] - 2026-04-27
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Copy Button on Linux (GNOME/Wayland/Flatpak).** Fixed the "Copy" button in the Open WebUI interface not actually writing to the system clipboard on Linux. The webview session was missing the `clipboard-sanitized-write` permission required by Electron for `navigator.clipboard.writeText()` to work.
|
||||
|
||||
## [0.0.12] - 2026-04-25
|
||||
|
||||
### Added
|
||||
|
||||
- **Toggleable Clipboard Auto-Paste for Spotlight.** Spotlight's automatic clipboard pasting is now optional and can be toggled in Settings, so the input bar starts empty when preferred.
|
||||
- **Persistent Window Size and Position.** The app now remembers your window dimensions, position, and maximized state across restarts, with safe fallback when a saved display is disconnected.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Linux .deb Crash.** Fixed app failing to launch on Linux with `Failed to load native module: pty.node` by enabling native module rebuilds and unpacking node-pty from the asar archive during packaging.
|
||||
- **Grey Screen on Connection Failure.** The webview now shows an error overlay with retry and open-in-browser options instead of a blank grey screen when a connection fails to load or crashes.
|
||||
- **Global Shortcuts on Wayland/Flatpak.** Global shortcuts now work on Wayland desktops via `xdg-desktop-portal`, with clear user-facing notifications when a shortcut cannot be registered.
|
||||
|
||||
## [0.0.11] - 2026-04-24
|
||||
|
||||
### Fixed
|
||||
|
||||
- **macOS Launch Crash.** Fixed app failing to launch with "different Team IDs" error by adding the missing `disable-library-validation` entitlement to the build signing configuration.
|
||||
- **Self-Signed SSL Connections.** The app now trusts all SSL certificates, allowing connections to Open WebUI instances behind self-signed or untrusted certificates without errors.
|
||||
|
||||
## [0.0.10] - 2026-04-24
|
||||
|
||||
### Added
|
||||
|
||||
- **Concurrent Model Downloads.** Multiple Hugging Face models can now be downloaded simultaneously, each with independent progress tracking and per-file cancel buttons.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Models Settings UI.** Cleaner layout with inline progress bars, hover-reveal download buttons, and breadcrumb-style repo navigation.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **GPU Process Crash Recovery.** The app now automatically detects GPU process crashes (common with certain NVIDIA/Intel drivers on Windows) and relaunches with the GPU sandbox disabled, instead of closing immediately. No manual shortcut edits required.
|
||||
|
||||
## [0.0.9] - 2026-04-20
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Open Terminal API Key Persistence.** The Open Terminal API key is now saved in config.json and reused across restarts instead of being regenerated on every startup, which was breaking existing integrations.
|
||||
|
||||
## [0.0.8] - 2026-04-11
|
||||
|
||||
### Added
|
||||
|
||||
- **Voice Input.** System-wide push-to-talk voice transcription. Press the shortcut from any app to record audio, which is automatically transcribed and sent to your active chat.
|
||||
- **Voice Input Settings.** Configurable global hotkey and enable/disable toggle in Settings, with a default of Shift+Cmd+Space (macOS) or Shift+Ctrl+Space (Windows/Linux).
|
||||
- **Audio Feedback.** Bundled start and stop chime sounds play when recording begins and ends.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Shortcut Recorder on macOS.** Shortcut inputs now use physical key codes instead of character values, fixing Alt key combinations producing unicode characters like √ instead of V.
|
||||
|
||||
## [0.0.7] - 2026-04-11
|
||||
|
||||
### Fixed
|
||||
|
||||
- **macOS Auto-Update.** Auto-update now works correctly on macOS. Previously, the updater tried to download a zip file with a versioned filename that did not exist in the release.
|
||||
|
||||
## [0.0.6] - 2026-04-10
|
||||
|
||||
### Added
|
||||
|
||||
- **Spotlight Screenshot Capture.** Drag anywhere on the Spotlight overlay to select a region of your screen. Screenshots appear as inline thumbnails and are sent alongside your message.
|
||||
- **Multiple Screenshots.** Attach several screenshots in a single Spotlight query. Each one can be individually removed before sending.
|
||||
- **Click-to-Dismiss Spotlight.** Clicking the background outside the input bar dismisses Spotlight, in addition to pressing Escape.
|
||||
- **Screen Recording Permission Prompt (macOS).** If screen capture permission hasn't been granted, a notification guides you to the correct System Settings page.
|
||||
- **Screenshot Hint.** A "Drag anywhere to capture a screenshot" hint appears when Spotlight opens.
|
||||
- **Offline Mode for llama.cpp.** Previously downloaded llama.cpp binaries are automatically detected on startup, so local models work without an internet connection.
|
||||
- **Auto-Connect on Startup.** The app pre-connects to your default connection when launched, so Spotlight queries work immediately.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Fullscreen Spotlight Overlay.** Spotlight now opens as a fullscreen transparent overlay on your active display rather than a small floating window, enabling screenshot capture and multi-display support.
|
||||
- **Faster Remote Connections.** Switching to a remote server is now instant with no loading delay.
|
||||
- **Smarter Loading Indicator.** The loading spinner only appears when the local server is actually starting, instead of showing on every connection switch.
|
||||
- **Clearer Sidebar Selection.** Active connections are more visually distinct with bolder text and stronger highlights. Inactive connections are subtler for better contrast.
|
||||
- **Safer llama.cpp Updates.** The app verifies internet connectivity before removing the current installation, preventing accidental data loss when updating offline.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Tray Menu Connections.** Clicking a connection from the system tray menu now correctly opens it in the app.
|
||||
- **Dark Mode Context Menus.** Sidebar right-click menus no longer appear incorrectly highlighted in dark mode.
|
||||
- **Local Server Always Accessible.** The local connection in the sidebar is no longer grayed out when the server isn't running. Clicking it will start the server.
|
||||
- **Open Terminal Install Errors.** If automatic installation of Open Terminal fails, you now see a clear error message instead of a silent failure.
|
||||
- **Network Timeout Handling.** Requests for llama.cpp releases now time out after 10 seconds instead of hanging indefinitely on slow networks.
|
||||
|
||||
## [0.0.5] - 2026-04-07
|
||||
|
||||
### Added
|
||||
|
||||
- **Two-Way Theme Sync.** Theme changes in Open WebUI are now mirrored to the desktop app and vice versa, so your light/dark preference stays consistent everywhere.
|
||||
- **Seamless Spotlight Queries.** Spotlight prompts now appear directly in your already-open chat without triggering a full page reload.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Auto-Default Connection.** Selecting a connection now automatically saves it as your default for Spotlight and app startup.
|
||||
- **Smooth Connection Switching.** Switching between already-open connections no longer causes unnecessary page reloads.
|
||||
- **Connection Switch Race Condition.** Clicking a remote connection while the local server is still starting no longer gets overridden when the local server finishes loading.
|
||||
|
||||
## [0.0.3] - 2026-04-06
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Spotlight Focus.** Spotlight now reliably appears after interacting with the main window. Previously could fail to show on macOS.
|
||||
- **Spotlight Search Passthrough.** Searches submitted from Spotlight now correctly load in already-open connections instead of being silently ignored.
|
||||
|
||||
## [0.0.2] - 2026-04-06
|
||||
|
||||
### Added
|
||||
|
||||
- **Spotlight Input Bar.** Lightweight quick-chat bar (⇧⌘I) for submitting queries without opening the full app.
|
||||
- **Spotlight Shortcut.** Dedicated configurable shortcut for Spotlight, independent from the global app shortcut.
|
||||
- **Draggable Spotlight.** Spotlight bar can be dragged to any position on screen.
|
||||
- **Persistent Spotlight Position.** Spotlight position is saved and restored across app restarts.
|
||||
- **Spotlight Settings.** Shortcut recorder in Settings → General for the Spotlight shortcut.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **System Theme Sync.** The app now responds to OS dark/light mode changes in real-time when set to "Auto". Previously only checked once at startup.
|
||||
|
||||
## [0.0.1] - 2026-03-20
|
||||
|
||||
### Added
|
||||
|
||||
- **Local Server Management** — Install, start, stop, and restart Open WebUI directly from the desktop app
|
||||
- **Connection Manager** — Connect to multiple Open WebUI servers with sidebar quick-switch
|
||||
- **Status Bar** — Real-time status indicators for Open WebUI, Open Terminal, and llama.cpp services
|
||||
- **Log Panel** — Live PTY-backed terminal log viewer for all services with copy, refresh, and resize
|
||||
- **Open Terminal Integration** — Built-in terminal server for AI-powered shell access
|
||||
- **llama.cpp Integration** — Local inference runtime with model management and Hugging Face downloads
|
||||
- **Settings** — General, Open WebUI, Terminal, Inference, Models, Connections, and About panels
|
||||
- **Global Shortcut** — Configurable system-wide hotkey to bring the app to the foreground
|
||||
- **Auto-Update** — Built-in software update checker with download and install
|
||||
- **Tray Support** — System tray icon with quick actions and optional background execution
|
||||
- **Factory Reset** — One-click removal of all installed components, data, and connections
|
||||
- **Disk Space Check** — Pre-install validation requiring at least 5 GB of free storage
|
||||
- **Internationalization** — i18n support with English, Japanese, Chinese (Simplified & Traditional) translations
|
||||
- **Changelog Viewer** — In-app changelog accessible from the About settings page
|
||||
- **Singleton Enforcement** — Prevents multiple instances of managed services from running simultaneously
|
||||
- **Cross-Platform** — macOS, Windows, and Linux support
|
||||
- **Local Server Management.** Install, start, stop, and restart Open WebUI directly from the desktop app.
|
||||
- **Connection Manager.** Connect to multiple Open WebUI servers with sidebar quick-switch.
|
||||
- **Status Bar.** Real-time status indicators for Open WebUI, Open Terminal, and llama.cpp services.
|
||||
- **Log Viewer.** Live terminal log viewer for all services with copy, refresh, and resize.
|
||||
- **Open Terminal Integration.** Built-in terminal server for AI-powered shell access.
|
||||
- **llama.cpp Integration.** Local inference engine with model management and Hugging Face downloads.
|
||||
- **Settings.** General, Open WebUI, Terminal, Inference, Models, Connections, and About panels.
|
||||
- **Global Shortcut.** Configurable system-wide hotkey to bring the app to the foreground.
|
||||
- **Auto-Update.** Built-in update checker with one-click download and install.
|
||||
- **Tray Support.** System tray icon with quick actions and optional background mode.
|
||||
- **Factory Reset.** One-click removal of all installed components, data, and connections.
|
||||
- **Disk Space Check.** Pre-install check requiring at least 5 GB of free storage.
|
||||
- **Internationalization.** English, Japanese, Chinese (Simplified & Traditional) translations.
|
||||
- **In-App Changelog.** Accessible from the About settings page.
|
||||
- **Cross-Platform.** macOS, Windows, and Linux support.
|
||||
|
||||
@@ -1,54 +1,80 @@
|
||||
# Open WebUI Desktop 🌐
|
||||
# Open WebUI Desktop
|
||||
|
||||

|
||||
[](https://github.com/open-webui/desktop/releases)
|
||||
[](https://github.com/open-webui/desktop/releases)
|
||||
[](https://discord.gg/open-webui)
|
||||
[](LICENSE)
|
||||
|
||||
**Open WebUI Desktop** is a cross-platform desktop application for [Open WebUI](https://github.com/open-webui/open-webui). It brings the _full-featured Open WebUI experience_ directly to your device, effectively transforming it into a powerful server—without the complexities of manual setup.
|
||||

|
||||
|
||||
Your AI, right on your desktop. [Open WebUI](https://github.com/open-webui/open-webui) as a native app. Run models locally or connect to any server. No Docker, no terminal, no setup. Download, launch, chat.
|
||||
|
||||
> [!WARNING]
|
||||
> This project is currently in **alpha** and under active development. 🛠️ Expect frequent updates and potential changes as we refine the application.
|
||||
> **Early Alpha.** Things move fast and stuff might break. [Report bugs](https://github.com/open-webui/desktop/issues) or [come hang out on Discord](https://discord.gg/open-webui).
|
||||
|
||||
## Download 📥
|
||||
## Download
|
||||
|
||||
| Platform | Installer |
|
||||
|----------|-----------|
|
||||
| macOS (Apple Silicon) | [**Download .dmg**](https://github.com/open-webui/desktop/releases/latest/download/open-webui-arm64.dmg) |
|
||||
| macOS (Intel) | [**Download .dmg**](https://github.com/open-webui/desktop/releases/latest/download/open-webui-x64.dmg) |
|
||||
| Windows x64 | [**Download .exe**](https://github.com/open-webui/desktop/releases/latest/download/open-webui-x64-setup.exe) |
|
||||
| Windows ARM64 | [**Download .exe**](https://github.com/open-webui/desktop/releases/latest/download/open-webui-arm64-setup.exe) |
|
||||
| Linux x64 (AppImage) | [**Download .AppImage**](https://github.com/open-webui/desktop/releases/latest/download/open-webui_x64.AppImage) |
|
||||
| Linux x64 (Debian/Ubuntu) | [**Download .deb**](https://github.com/open-webui/desktop/releases/latest/download/open-webui_amd64.deb) |
|
||||
| Linux x64 (Snap) | [**Download .snap**](https://github.com/open-webui/desktop/releases/latest/download/open-webui_amd64.snap) |
|
||||
| Linux x64 (Flatpak) | [**Download .flatpak**](https://github.com/open-webui/desktop/releases/latest/download/open-webui.flatpak) |
|
||||
| Linux ARM64 (AppImage) | [**Download .AppImage**](https://github.com/open-webui/desktop/releases/latest/download/open-webui_arm64.AppImage) |
|
||||
| Linux ARM64 (Debian/Ubuntu) | [**Download .deb**](https://github.com/open-webui/desktop/releases/latest/download/open-webui_arm64.deb) |
|
||||
|
||||
Internet required on first launch. After that, everything works offline. [All releases →](https://github.com/open-webui/desktop/releases)
|
||||
|
||||
## How It Works
|
||||
|
||||
🖥️ **Run locally.** The app runs Open WebUI on your machine. You can optionally enable the built-in llama.cpp engine to download and run models offline. Nothing leaves your computer.
|
||||
|
||||
☁️ **Connect remotely.** Point the app at any Open WebUI server. Switch between multiple connections from the sidebar.
|
||||
|
||||
Use both at the same time.
|
||||
|
||||
## Highlights
|
||||
|
||||
- ⚡ **Spotlight.** Hit `Shift+Cmd+I` (macOS) or `Shift+Ctrl+I` (Windows/Linux) to summon a floating chat bar over whatever you're doing. Drag to screenshot anything on screen.
|
||||
- 🎙️ **Voice input.** System-wide push-to-talk. Press the shortcut from any app to record, and your speech is transcribed and sent to your chat automatically.
|
||||
- 🧠 **Local inference.** Optionally run models entirely on your hardware via the built-in llama.cpp engine. Your data never leaves your machine.
|
||||
- 🎯 **One-click setup.** Launch and connect to a server in seconds. Local models can be enabled from the settings.
|
||||
- 🔌 **Multiple connections.** Juggle servers and switch between them instantly.
|
||||
- 🔄 **Auto-updates.** New releases land in the background.
|
||||
- 📡 **Offline-ready.** No internet needed after initial setup.
|
||||
- 💻 **Cross-platform.** macOS, Windows, and Linux.
|
||||
|
||||
## System Requirements
|
||||
|
||||
| | Local Models | Remote Only |
|
||||
|--|-------------|-------------|
|
||||
| **Disk** | 5 GB+ | ~500 MB |
|
||||
| **RAM** | 16 GB+ | 4 GB |
|
||||
| **OS** | macOS 12+, Windows 10+, modern Linux (glibc 2.28+) | Same |
|
||||
|
||||
> [!NOTE]
|
||||
> An internet connection is required for initial setup, but afterwards the application can be used completely offline.
|
||||
> Local models need serious RAM (7B ≈ 8 GB, 13B ≈ 16 GB). Lighter machine? Connect to a remote server instead.
|
||||
|
||||
Get the latest alpha release from our [releases page](https://github.com/open-webui/desktop/releases).
|
||||
## Privacy
|
||||
|
||||
## Features
|
||||
No telemetry. No tracking. No phone-home. Your conversations stay on your machine. Period.
|
||||
|
||||
- **One-Click Installation**: Quickly and effortlessly install and set up Open WebUI with all its dependencies. This feature is fully functional and ready to make your setup a breeze.
|
||||
- **Cross-Platform Support**: Compatible with Windows, macOS, and Linux to ensure broad accessibility.
|
||||
- **Offline Capability**: After initial setup, use the application completely offline for enhanced privacy and reliability.
|
||||
## Community
|
||||
|
||||
## Project Setup
|
||||
- 💬 [Discord](https://discord.gg/open-webui) - Come hang out
|
||||
- 🐛 [Issues](https://github.com/open-webui/desktop/issues) - Report bugs or request features
|
||||
- 🌐 [Open WebUI](https://github.com/open-webui/open-webui) - The main project
|
||||
- 📖 [Docs](https://docs.openwebui.com) - Full documentation
|
||||
|
||||
### Install
|
||||
## Contributing
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### Development
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Build
|
||||
|
||||
```bash
|
||||
# For windows
|
||||
npm run build:win
|
||||
# For macOS
|
||||
npm run build:mac
|
||||
# For Linux
|
||||
npm run build:linux
|
||||
```
|
||||
|
||||
## License 📜
|
||||
|
||||
This project is licensed under the **AGPL-3.0**. For details, see [LICENSE](LICENSE).
|
||||
|
||||
## Stay Tuned! 🌟
|
||||
|
||||
We're actively developing Open WebUI. Follow [Open WebUI](https://github.com/open-webui/open-webui) for updates, and join the [community on Discord](https://discord.gg/5rJgQTnV4s) to stay involved.
|
||||
See [CHANGELOG.md](CHANGELOG.md) for release history. Licensed under [AGPL-3.0](LICENSE).
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<component type="desktop-application">
|
||||
<id>com.openwebui.open-webui</id>
|
||||
|
||||
<metadata_license>CC0-1.0</metadata_license>
|
||||
<project_license>AGPL-3.0-or-later</project_license>
|
||||
|
||||
<name>Open WebUI</name>
|
||||
<summary>The freedom AI stack</summary>
|
||||
|
||||
<developer id="com.timbaek">
|
||||
<name>Timothy J. Baek</name>
|
||||
</developer>
|
||||
|
||||
<description>
|
||||
<p>
|
||||
Open WebUI is an extensible, feature-rich, and user-friendly self-hosted AI platform designed to operate entirely offline.
|
||||
</p>
|
||||
</description>
|
||||
|
||||
<launchable type="desktop-id">com.openwebui.open-webui.desktop</launchable>
|
||||
|
||||
<content_rating type="oars-1.1">
|
||||
<content_attribute id="social-chat">intense</content_attribute>
|
||||
</content_rating>
|
||||
|
||||
<url type="bugtracker">https://github.com/open-webui/desktop/issues</url>
|
||||
<url type="homepage">https://openwebui.com</url>
|
||||
<url type="donation">https://github.com/sponsors/tjbck</url>
|
||||
<url type="vcs-browser">https://github.com/open-webui/desktop</url>
|
||||
|
||||
<screenshots>
|
||||
<screenshot type="default">
|
||||
<image>https://raw.githubusercontent.com/open-webui/desktop/61f55e6fd6814b959b16a4704b03262e02186f48/demo.png</image>
|
||||
</screenshot>
|
||||
</screenshots>
|
||||
|
||||
<releases>
|
||||
<release version="0.0.8" date="2026-04-11">
|
||||
<url type="details">https://github.com/open-webui/desktop/releases/tag/v0.0.8</url>
|
||||
</release>
|
||||
<release version="0.0.6" date="2026-04-10">
|
||||
<url type="details">https://github.com/open-webui/desktop/releases/tag/v0.0.6</url>
|
||||
</release>
|
||||
</releases>
|
||||
</component>
|
||||
@@ -8,5 +8,33 @@
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.debugger</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.server</key>
|
||||
<true/>
|
||||
<key>com.apple.security.files.user-selected.read-only</key>
|
||||
<true/>
|
||||
<key>com.apple.security.inherit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.automation.apple-events</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.audio-input</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.bluetooth</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.camera</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.print</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.microphone</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.usb</key>
|
||||
<true/>
|
||||
<key>com.apple.security.personal-information.location</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
+18
-8
@@ -15,27 +15,32 @@ extraResources:
|
||||
to: CHANGELOG.md
|
||||
asarUnpack:
|
||||
- resources/**
|
||||
- node_modules/node-pty/**
|
||||
win:
|
||||
executableName: open-webui
|
||||
nsis:
|
||||
artifactName: ${name}-${version}-setup.${ext}
|
||||
artifactName: ${name}-${arch}-setup.${ext}
|
||||
shortcutName: ${productName}
|
||||
uninstallDisplayName: ${productName}
|
||||
createDesktopShortcut: always
|
||||
mac:
|
||||
target:
|
||||
- dmg
|
||||
- zip
|
||||
- target: dmg
|
||||
- target: zip
|
||||
arch:
|
||||
- x64
|
||||
- arm64
|
||||
artifactName: ${name}-${arch}-mac.${ext}
|
||||
entitlements: build/entitlements.mac.plist
|
||||
entitlementsInherit: build/entitlements.mac.plist
|
||||
extendInfo:
|
||||
- NSCameraUsageDescription: Application requests access to the device's camera.
|
||||
- NSMicrophoneUsageDescription: Application requests access to the device's microphone.
|
||||
- NSDocumentsFolderUsageDescription: Application requests access to the user's Documents folder.
|
||||
- NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder.
|
||||
notarize: true
|
||||
dmg:
|
||||
background: build/dmg-background.png
|
||||
artifactName: ${name}-${version}.${ext}
|
||||
artifactName: ${name}-${arch}.${ext}
|
||||
title: ${productName}
|
||||
contents:
|
||||
- x: 225
|
||||
@@ -53,15 +58,19 @@ linux:
|
||||
- flatpak
|
||||
maintainer: openwebui.com
|
||||
category: Utility
|
||||
deb:
|
||||
artifactName: ${name}_${arch}.${ext}
|
||||
snap:
|
||||
artifactName: ${name}_${arch}.${ext}
|
||||
appImage:
|
||||
artifactName: ${name}-${version}.${ext}
|
||||
artifactName: ${name}_${arch}.${ext}
|
||||
flatpak:
|
||||
base: org.electronjs.Electron2.BaseApp
|
||||
baseVersion: '23.08'
|
||||
runtime: org.freedesktop.Platform
|
||||
runtimeVersion: '23.08'
|
||||
sdk: org.freedesktop.Sdk
|
||||
artifactName: ${name}-${version}.flatpak
|
||||
artifactName: ${name}.flatpak
|
||||
finishArgs:
|
||||
- --share=ipc
|
||||
- --socket=x11
|
||||
@@ -71,7 +80,8 @@ flatpak:
|
||||
- --device=dri
|
||||
- --filesystem=home
|
||||
- --talk-name=org.freedesktop.Notifications
|
||||
npmRebuild: false
|
||||
- --talk-name=org.freedesktop.portal.Desktop
|
||||
npmRebuild: true
|
||||
publish:
|
||||
provider: github
|
||||
owner: open-webui
|
||||
|
||||
+12
-1
@@ -14,12 +14,23 @@ export default defineConfig({
|
||||
rollupOptions: {
|
||||
input: {
|
||||
index: resolve(__dirname, 'src/preload/index.ts'),
|
||||
'content-preload': resolve(__dirname, 'src/preload/content-preload.ts')
|
||||
'content-preload': resolve(__dirname, 'src/preload/content-preload.ts'),
|
||||
'spotlight-preload': resolve(__dirname, 'src/preload/spotlight-preload.ts'),
|
||||
'voice-input-preload': resolve(__dirname, 'src/preload/voice-input-preload.ts')
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
renderer: {
|
||||
build: {
|
||||
rollupOptions: {
|
||||
input: {
|
||||
index: resolve(__dirname, 'src/renderer/index.html'),
|
||||
spotlight: resolve(__dirname, 'src/renderer/spotlight.html'),
|
||||
'voice-input': resolve(__dirname, 'src/renderer/voice-input.html')
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: [tailwindcss(), svelte()]
|
||||
}
|
||||
})
|
||||
|
||||
Generated
+4
-4
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "open-webui-desktop",
|
||||
"version": "0.1.0",
|
||||
"name": "open-webui",
|
||||
"version": "0.0.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "open-webui-desktop",
|
||||
"version": "0.1.0",
|
||||
"name": "open-webui",
|
||||
"version": "0.0.3",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "open-webui",
|
||||
"version": "0.0.1",
|
||||
"version": "0.0.20",
|
||||
"license": "AGPL-3.0",
|
||||
"description": "Open WebUI Desktop",
|
||||
"main": "./out/main/index.js",
|
||||
|
||||
Binary file not shown.
Binary file not shown.
+1143
-58
File diff suppressed because it is too large
Load Diff
@@ -5,14 +5,14 @@
|
||||
* Downloads files from HF repos, manages a local model cache,
|
||||
* and provides listing/deletion of cached models.
|
||||
*
|
||||
* Cache dir: <userData>/models/huggingface/<repo-slug>/<filename>
|
||||
* Cache dir: <userData>/models/<repo-slug>/<filename>
|
||||
*/
|
||||
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
import log from 'electron-log'
|
||||
|
||||
import { getUserDataPath, downloadFileWithProgress } from './index'
|
||||
import { getInstallDir, downloadFileWithProgress } from './index'
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────
|
||||
|
||||
@@ -33,10 +33,37 @@ export interface HfDownloadProgress {
|
||||
// ─── Paths ──────────────────────────────────────────────
|
||||
|
||||
const getHfCacheDir = (): string => {
|
||||
const dir = path.join(getUserDataPath(), 'models', 'huggingface')
|
||||
const dir = path.join(getInstallDir(), 'models')
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
|
||||
// Migrate models from legacy models/huggingface/<slug>/ to models/<slug>/
|
||||
const legacyDir = path.join(dir, 'huggingface')
|
||||
if (fs.existsSync(legacyDir)) {
|
||||
try {
|
||||
const entries = fs.readdirSync(legacyDir, { withFileTypes: true })
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
const src = path.join(legacyDir, entry.name)
|
||||
const dest = path.join(dir, entry.name)
|
||||
if (!fs.existsSync(dest)) {
|
||||
fs.renameSync(src, dest)
|
||||
log.info(`[huggingface] Migrated ${entry.name} from legacy cache`)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Remove legacy dir if empty (manifest.json may remain)
|
||||
const remaining = fs.readdirSync(legacyDir)
|
||||
if (remaining.length === 0) {
|
||||
fs.rmdirSync(legacyDir)
|
||||
log.info('[huggingface] Removed empty legacy huggingface/ directory')
|
||||
}
|
||||
} catch (e) {
|
||||
log.warn('[huggingface] Failed to migrate legacy cache:', e)
|
||||
}
|
||||
}
|
||||
|
||||
return dir
|
||||
}
|
||||
|
||||
@@ -62,15 +89,28 @@ const writeManifest = (models: HfModel[]): void => {
|
||||
|
||||
// ─── Public API ─────────────────────────────────────────
|
||||
|
||||
let activeDownloadAbort: AbortController | null = null
|
||||
const activeDownloads = new Map<string, AbortController>()
|
||||
|
||||
const downloadKey = (repo: string, filename: string): string => `${repo}/${filename}`
|
||||
|
||||
/**
|
||||
* Cancel the current download in progress.
|
||||
* Cancel a specific download in progress.
|
||||
* If no repo/filename given, cancels ALL active downloads.
|
||||
*/
|
||||
export const cancelDownload = (): void => {
|
||||
if (activeDownloadAbort) {
|
||||
activeDownloadAbort.abort()
|
||||
activeDownloadAbort = null
|
||||
export const cancelDownload = (repo?: string, filename?: string): void => {
|
||||
if (repo && filename) {
|
||||
const key = downloadKey(repo, filename)
|
||||
const ctrl = activeDownloads.get(key)
|
||||
if (ctrl) {
|
||||
ctrl.abort()
|
||||
activeDownloads.delete(key)
|
||||
}
|
||||
} else {
|
||||
// Cancel all
|
||||
for (const ctrl of activeDownloads.values()) {
|
||||
ctrl.abort()
|
||||
}
|
||||
activeDownloads.clear()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,7 +127,7 @@ export const listModels = (): HfModel[] => {
|
||||
* Get the cache directory path (so runtimes can reference it).
|
||||
*/
|
||||
export const getModelsDir = (): string => {
|
||||
const dir = path.join(getUserDataPath(), 'models')
|
||||
const dir = path.join(getInstallDir(), 'models')
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
@@ -136,8 +176,13 @@ export const downloadModel = async (
|
||||
headers['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
|
||||
activeDownloadAbort = new AbortController()
|
||||
const { signal } = activeDownloadAbort
|
||||
const key = downloadKey(repo, filename)
|
||||
// Cancel any existing download for the same file
|
||||
activeDownloads.get(key)?.abort()
|
||||
|
||||
const abortController = new AbortController()
|
||||
activeDownloads.set(key, abortController)
|
||||
const { signal } = abortController
|
||||
|
||||
// Use fetch for streaming download with progress
|
||||
const response = await fetch(downloadUrl, {
|
||||
@@ -183,7 +228,7 @@ export const downloadModel = async (
|
||||
writeStream.end()
|
||||
// Clean up partial download
|
||||
try { fs.unlinkSync(tmpPath) } catch {}
|
||||
activeDownloadAbort = null
|
||||
activeDownloads.delete(downloadKey(repo, filename))
|
||||
throw err
|
||||
} finally {
|
||||
writeStream.end()
|
||||
@@ -192,7 +237,7 @@ export const downloadModel = async (
|
||||
|
||||
// Rename tmp to final
|
||||
fs.renameSync(tmpPath, destPath)
|
||||
activeDownloadAbort = null
|
||||
activeDownloads.delete(downloadKey(repo, filename))
|
||||
|
||||
// Update manifest
|
||||
const manifest = readManifest()
|
||||
|
||||
+105
-47
@@ -8,7 +8,7 @@ import crypto from 'crypto'
|
||||
|
||||
import * as tar from 'tar'
|
||||
|
||||
import { app, shell, Notification } from 'electron'
|
||||
import { app, shell, Notification, net as electronNet } from 'electron'
|
||||
import { execFileSync, exec, spawn, execSync, execFile } from 'child_process'
|
||||
|
||||
import log from 'electron-log'
|
||||
@@ -51,6 +51,31 @@ export const getUserDataPath = (): string => {
|
||||
return path.normalize(userDataDir)
|
||||
}
|
||||
|
||||
/**
|
||||
* Root directory for heavyweight data (Python, models, llama.cpp).
|
||||
* Reads `installDir` from config.json synchronously so it's available
|
||||
* before any async init. Falls back to `getUserDataPath()`.
|
||||
*/
|
||||
export const getInstallDir = (): string => {
|
||||
const configPath = path.join(getUserDataPath(), 'config.json')
|
||||
let customDir = ''
|
||||
try {
|
||||
if (fs.existsSync(configPath)) {
|
||||
const data = JSON.parse(fs.readFileSync(configPath, 'utf8'))
|
||||
customDir = data.installDir || ''
|
||||
}
|
||||
} catch {}
|
||||
const installDir = customDir || getUserDataPath()
|
||||
if (!fs.existsSync(installDir)) {
|
||||
try {
|
||||
fs.mkdirSync(installDir, { recursive: true })
|
||||
} catch (error) {
|
||||
log.error(error)
|
||||
}
|
||||
}
|
||||
return path.normalize(installDir)
|
||||
}
|
||||
|
||||
export const getOpenWebUIDataPath = (): string => {
|
||||
// Check config for custom data directory
|
||||
const configPath = path.join(getUserDataPath(), 'config.json')
|
||||
@@ -61,7 +86,7 @@ export const getOpenWebUIDataPath = (): string => {
|
||||
customDir = data.dataDir || ''
|
||||
}
|
||||
} catch {}
|
||||
const openWebUIDataDir = customDir || path.join(getUserDataPath(), 'data')
|
||||
const openWebUIDataDir = customDir || path.join(getInstallDir(), 'data')
|
||||
if (!fs.existsSync(openWebUIDataDir)) {
|
||||
try {
|
||||
fs.mkdirSync(openWebUIDataDir, { recursive: true })
|
||||
@@ -194,15 +219,15 @@ export const getPythonDownloadPath = (): string => {
|
||||
}
|
||||
|
||||
export const getPythonInstallationDir = (): string => {
|
||||
const installDir = path.join(app.getPath('userData'), 'python')
|
||||
if (!fs.existsSync(installDir)) {
|
||||
const pythonDir = path.join(getInstallDir(), 'python')
|
||||
if (!fs.existsSync(pythonDir)) {
|
||||
try {
|
||||
fs.mkdirSync(installDir, { recursive: true })
|
||||
fs.mkdirSync(pythonDir, { recursive: true })
|
||||
} catch (error) {
|
||||
log.error(error)
|
||||
}
|
||||
}
|
||||
return path.normalize(installDir)
|
||||
return path.normalize(pythonDir)
|
||||
}
|
||||
|
||||
const downloadPython = async (onProgress = null) => {
|
||||
@@ -266,8 +291,8 @@ export const installPython = async (installationDir?: string, onStatus?: (status
|
||||
|
||||
try {
|
||||
onStatus?.('Extracting Python…')
|
||||
const userDataPath = getUserDataPath()
|
||||
await tar.x({ cwd: userDataPath, file: pythonDownloadPath })
|
||||
const installBase = getInstallDir()
|
||||
await tar.x({ cwd: installBase, file: pythonDownloadPath })
|
||||
} catch (error) {
|
||||
log.error(error)
|
||||
// Remove possibly-corrupted download so next retry re-downloads
|
||||
@@ -293,10 +318,7 @@ export const installPython = async (installationDir?: string, onStatus?: (status
|
||||
['-m', 'pip', 'install', 'uv'],
|
||||
{
|
||||
encoding: 'utf-8',
|
||||
env: {
|
||||
...process.env,
|
||||
...(process.platform === 'win32' ? { PYTHONIOENCODING: 'utf-8' } : {})
|
||||
}
|
||||
env: pythonEnv()
|
||||
},
|
||||
(error) => {
|
||||
if (error) reject(error)
|
||||
@@ -325,6 +347,38 @@ export const getPythonPath = (installationDir?: string) => {
|
||||
return path.normalize(getPythonExecutablePath(installationDir || getPythonInstallationDir()))
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a process environment suitable for running the bundled Python.
|
||||
*
|
||||
* On Windows the standalone Python distribution ships its own OpenSSL DLLs
|
||||
* (`libssl-3-x64.dll`, `libcrypto-3-x64.dll`) next to `python.exe`. If a
|
||||
* different OpenSSL installation (Git for Windows, Anaconda, Strawberry Perl,
|
||||
* etc.) appears earlier on the system `PATH`, Python picks up those mismatched
|
||||
* DLLs at load-time, which causes the fatal error:
|
||||
*
|
||||
* OPENSSL_Uplink(..., 08): no OPENSSL_Applink
|
||||
*
|
||||
* To prevent this we prepend the Python installation directory to `PATH` so
|
||||
* Windows finds the correct DLLs first. On non-Windows platforms this is a
|
||||
* harmless no-op.
|
||||
*
|
||||
* Any additional env overrides (e.g. `configEnvVars`) can be spread after
|
||||
* calling this helper.
|
||||
*/
|
||||
const pythonEnv = (extra: Record<string, string> = {}): Record<string, string> => {
|
||||
const base: Record<string, string> = { ...process.env }
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
// python.exe lives at the root of the installation directory on Windows
|
||||
const pythonDir = getPythonInstallationDir()
|
||||
const currentPath = process.env['PATH'] || process.env['Path'] || ''
|
||||
base['PATH'] = `${pythonDir};${currentPath}`
|
||||
base['PYTHONIOENCODING'] = 'utf-8'
|
||||
}
|
||||
|
||||
return { ...base, ...extra }
|
||||
}
|
||||
|
||||
export const isPythonInstalled = (installationDir?: string) => {
|
||||
const pythonPath = getPythonPath(installationDir)
|
||||
if (!fs.existsSync(pythonPath)) {
|
||||
@@ -333,10 +387,7 @@ export const isPythonInstalled = (installationDir?: string) => {
|
||||
try {
|
||||
const pythonVersion = execFileSync(pythonPath, ['--version'], {
|
||||
encoding: 'utf-8',
|
||||
env: {
|
||||
...process.env,
|
||||
...(process.platform === 'win32' ? { PYTHONIOENCODING: 'utf-8' } : {})
|
||||
}
|
||||
env: pythonEnv()
|
||||
})
|
||||
log.info('Installed Python Version:', pythonVersion.trim())
|
||||
return true
|
||||
@@ -350,10 +401,7 @@ export const isUvInstalled = (installationDir?: string) => {
|
||||
try {
|
||||
const result = execFileSync(pythonPath, ['-m', 'uv', '--version'], {
|
||||
encoding: 'utf-8',
|
||||
env: {
|
||||
...process.env,
|
||||
...(process.platform === 'win32' ? { PYTHONIOENCODING: 'utf-8' } : {})
|
||||
}
|
||||
env: pythonEnv()
|
||||
})
|
||||
log.info('Installed uv Version:', result.trim())
|
||||
return true
|
||||
@@ -403,10 +451,7 @@ export const installPackage = (packageName: string, version?: string, onStatus?:
|
||||
...(version ? [`${packageName}==${version}`] : [packageName, '-U'])
|
||||
],
|
||||
{
|
||||
env: {
|
||||
...process.env,
|
||||
...(process.platform === 'win32' ? { PYTHONIOENCODING: 'utf-8' } : {})
|
||||
}
|
||||
env: pythonEnv()
|
||||
}
|
||||
)
|
||||
|
||||
@@ -461,10 +506,7 @@ export const isPackageInstalled = (packageName: string): boolean => {
|
||||
try {
|
||||
const info = execFileSync(pythonPath, ['-m', 'uv', 'pip', 'show', packageName], {
|
||||
encoding: 'utf-8',
|
||||
env: {
|
||||
...process.env,
|
||||
...(process.platform === 'win32' ? { PYTHONIOENCODING: 'utf-8' } : {})
|
||||
}
|
||||
env: pythonEnv()
|
||||
})
|
||||
return info.includes(`Name: ${packageName}`)
|
||||
} catch {
|
||||
@@ -478,10 +520,7 @@ export const getPackageVersion = (packageName: string): string | null => {
|
||||
try {
|
||||
const info = execFileSync(pythonPath, ['-m', 'uv', 'pip', 'show', packageName], {
|
||||
encoding: 'utf-8',
|
||||
env: {
|
||||
...process.env,
|
||||
...(process.platform === 'win32' ? { PYTHONIOENCODING: 'utf-8' } : {})
|
||||
}
|
||||
env: pythonEnv()
|
||||
})
|
||||
const match = info.match(/^Version:\s*(.+)$/m)
|
||||
return match ? match[1].trim() : null
|
||||
@@ -496,10 +535,7 @@ export const uninstallPackage = (packageName: string): boolean => {
|
||||
try {
|
||||
execFileSync(pythonPath, ['-m', 'uv', 'pip', 'uninstall', packageName], {
|
||||
encoding: 'utf-8',
|
||||
env: {
|
||||
...process.env,
|
||||
...(process.platform === 'win32' ? { PYTHONIOENCODING: 'utf-8' } : {})
|
||||
}
|
||||
env: pythonEnv()
|
||||
})
|
||||
log.info(`Uninstalled package: ${packageName}`)
|
||||
return true
|
||||
@@ -563,14 +599,12 @@ export const startServer = async (
|
||||
name: 'xterm-256color',
|
||||
cols: 200,
|
||||
rows: 50,
|
||||
env: {
|
||||
...process.env,
|
||||
env: pythonEnv({
|
||||
...(configEnvVars ?? {}),
|
||||
DATA_DIR: dataDir,
|
||||
WEBUI_SECRET_KEY: secretKey,
|
||||
PYTHONUNBUFFERED: '1',
|
||||
...(process.platform === 'win32' ? { PYTHONIOENCODING: 'utf-8' } : {})
|
||||
}
|
||||
PYTHONUNBUFFERED: '1'
|
||||
})
|
||||
})
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
@@ -730,7 +764,7 @@ export const checkUrlAndOpen = async (url: string, callback: Function = async ()
|
||||
|
||||
const checkUrl = async (): Promise<boolean> => {
|
||||
try {
|
||||
const response = await fetch(url, { method: 'HEAD' })
|
||||
const response = await electronNet.fetch(url, { method: 'HEAD' })
|
||||
return response.ok
|
||||
} catch {
|
||||
return false
|
||||
@@ -758,7 +792,7 @@ export const checkUrlAndOpen = async (url: string, callback: Function = async ()
|
||||
|
||||
export const validateRemoteUrl = async (url: string): Promise<boolean> => {
|
||||
try {
|
||||
const response = await fetch(url, { method: 'HEAD', signal: AbortSignal.timeout(5000) })
|
||||
const response = await electronNet.fetch(url, { method: 'HEAD', signal: AbortSignal.timeout(5000) })
|
||||
return response.ok
|
||||
} catch {
|
||||
return false
|
||||
@@ -780,6 +814,8 @@ export interface AppConfig {
|
||||
connections: Connection[]
|
||||
runInBackground: boolean
|
||||
globalShortcut: string
|
||||
spotlightShortcut: string
|
||||
installDir: string
|
||||
dataDir: string
|
||||
localServer: {
|
||||
port: number
|
||||
@@ -790,6 +826,7 @@ export interface AppConfig {
|
||||
enabled: boolean
|
||||
port: number
|
||||
cwd: string
|
||||
apiKey: string
|
||||
}
|
||||
llamaCpp: {
|
||||
enabled: boolean
|
||||
@@ -799,6 +836,15 @@ export interface AppConfig {
|
||||
extraArgs: string[]
|
||||
}
|
||||
envVars: Record<string, string>
|
||||
showSidebar: boolean
|
||||
spotlightPosition: { x: number; y: number } | null
|
||||
spotlightClipboardPaste: boolean
|
||||
voiceInputShortcut: string
|
||||
voiceInputEnabled: boolean
|
||||
callShortcut: string
|
||||
callEnabled: boolean
|
||||
windowBounds: { x: number; y: number; width: number; height: number } | null
|
||||
windowMaximized: boolean
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: AppConfig = {
|
||||
@@ -807,6 +853,8 @@ const DEFAULT_CONFIG: AppConfig = {
|
||||
connections: [],
|
||||
runInBackground: true,
|
||||
globalShortcut: 'Alt+CommandOrControl+O',
|
||||
spotlightShortcut: 'Shift+CommandOrControl+I',
|
||||
installDir: '',
|
||||
dataDir: '',
|
||||
localServer: {
|
||||
port: 8080,
|
||||
@@ -815,7 +863,8 @@ const DEFAULT_CONFIG: AppConfig = {
|
||||
},
|
||||
openTerminal: {
|
||||
enabled: false,
|
||||
cwd: ''
|
||||
cwd: '',
|
||||
apiKey: ''
|
||||
},
|
||||
llamaCpp: {
|
||||
enabled: false,
|
||||
@@ -823,7 +872,16 @@ const DEFAULT_CONFIG: AppConfig = {
|
||||
variant: 'cpu',
|
||||
extraArgs: []
|
||||
},
|
||||
envVars: {}
|
||||
envVars: {},
|
||||
showSidebar: false,
|
||||
spotlightPosition: null,
|
||||
spotlightClipboardPaste: true,
|
||||
voiceInputShortcut: 'Shift+CommandOrControl+Space',
|
||||
voiceInputEnabled: true,
|
||||
callShortcut: 'Shift+CommandOrControl+C',
|
||||
callEnabled: true,
|
||||
windowBounds: null,
|
||||
windowMaximized: false
|
||||
}
|
||||
|
||||
export const getConfig = async (): Promise<AppConfig> => {
|
||||
@@ -900,7 +958,7 @@ export const resetApp = async (): Promise<void> => {
|
||||
}
|
||||
|
||||
// Remove llama.cpp binaries
|
||||
const llamaCppPath = path.join(getUserDataPath(), 'llama.cpp')
|
||||
const llamaCppPath = path.join(getInstallDir(), 'llama.cpp')
|
||||
if (fs.existsSync(llamaCppPath)) {
|
||||
try {
|
||||
fs.rmSync(llamaCppPath, { recursive: true, force: true })
|
||||
@@ -911,7 +969,7 @@ export const resetApp = async (): Promise<void> => {
|
||||
}
|
||||
|
||||
// Remove downloaded models (huggingface + any user-added models)
|
||||
const modelsPath = path.join(getUserDataPath(), 'models')
|
||||
const modelsPath = path.join(getInstallDir(), 'models')
|
||||
if (fs.existsSync(modelsPath)) {
|
||||
try {
|
||||
fs.rmSync(modelsPath, { recursive: true, force: true })
|
||||
|
||||
+103
-12
@@ -10,7 +10,8 @@ import log from 'electron-log'
|
||||
|
||||
import {
|
||||
getConfig,
|
||||
getUserDataPath,
|
||||
setConfig,
|
||||
getInstallDir,
|
||||
portInUse,
|
||||
downloadFileWithProgress
|
||||
} from './index'
|
||||
@@ -32,11 +33,32 @@ let binaryPath: string | null = null
|
||||
// ─── Public Getters ─────────────────────────────────────
|
||||
|
||||
export const getLlamaCppInfo = () => {
|
||||
// Lazily discover a cached binary on cold boot so the UI never falsely
|
||||
// reports "not installed" when the files are actually on disk.
|
||||
if (!binaryPath) {
|
||||
const cacheBase = path.join(getInstallDir(), 'llama.cpp')
|
||||
try {
|
||||
if (fs.existsSync(cacheBase)) {
|
||||
const dirs = fs.readdirSync(cacheBase, { withFileTypes: true })
|
||||
.filter((d) => d.isDirectory())
|
||||
for (const d of dirs) {
|
||||
const found = findBinary(path.join(cacheBase, d.name))
|
||||
if (found) {
|
||||
binaryPath = found
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore — best-effort discovery
|
||||
}
|
||||
}
|
||||
|
||||
// Extract version tag from binaryPath — the tag is the directory name
|
||||
// directly under the llama.cpp cache dir, e.g. …/llama.cpp/<tag>/bin/llama-server
|
||||
let version: string | null = null
|
||||
if (binaryPath) {
|
||||
const cacheBase = path.join(getUserDataPath(), 'llama.cpp')
|
||||
const cacheBase = path.join(getInstallDir(), 'llama.cpp')
|
||||
const relative = path.relative(cacheBase, binaryPath)
|
||||
const tag = relative.split(path.sep)[0]
|
||||
if (tag) version = tag
|
||||
@@ -193,11 +215,45 @@ export const setupLlamaCpp = async (
|
||||
const version = llamaConfig.version || 'latest'
|
||||
const variant = resolveVariant(llamaConfig.variant)
|
||||
|
||||
const cacheBase = path.join(getUserDataPath(), 'llama.cpp')
|
||||
const cacheBase = path.join(getInstallDir(), 'llama.cpp')
|
||||
if (!fs.existsSync(cacheBase)) {
|
||||
fs.mkdirSync(cacheBase, { recursive: true })
|
||||
}
|
||||
|
||||
// ── Check for existing cached binary before any network request ──
|
||||
// This allows llama.cpp to start offline when previously installed.
|
||||
if (version !== 'latest') {
|
||||
// Pinned version — check its specific directory
|
||||
const pinnedDir = path.join(cacheBase, version)
|
||||
const pinnedBinary = fs.existsSync(pinnedDir) ? findBinary(pinnedDir) : null
|
||||
if (pinnedBinary) {
|
||||
log.info(`Using cached llama-server binary (pinned ${version}): ${pinnedBinary}`)
|
||||
binaryPath = pinnedBinary
|
||||
onStatus?.('Ready')
|
||||
return pinnedBinary
|
||||
}
|
||||
} else {
|
||||
// 'latest' — scan all cached version directories for a usable binary
|
||||
try {
|
||||
const cachedVersions = fs.readdirSync(cacheBase, { withFileTypes: true })
|
||||
.filter((d) => d.isDirectory())
|
||||
.map((d) => d.name)
|
||||
|
||||
for (const cachedTag of cachedVersions) {
|
||||
const cachedBinary = findBinary(path.join(cacheBase, cachedTag))
|
||||
if (cachedBinary) {
|
||||
log.info(`Found cached llama-server binary (${cachedTag}): ${cachedBinary}`)
|
||||
// Still try to fetch release info to see if there's a newer version,
|
||||
// but if the network is unavailable, use the cached binary.
|
||||
binaryPath = cachedBinary
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Cache directory scan failed — proceed to network fetch
|
||||
}
|
||||
}
|
||||
|
||||
onStatus?.('Fetching release info…')
|
||||
const apiUrl =
|
||||
version === 'latest'
|
||||
@@ -207,14 +263,25 @@ export const setupLlamaCpp = async (
|
||||
let releaseData: any
|
||||
try {
|
||||
const response = await fetch(apiUrl, {
|
||||
headers: { Accept: 'application/vnd.github.v3+json' }
|
||||
headers: { Accept: 'application/vnd.github.v3+json' },
|
||||
signal: AbortSignal.timeout(10000)
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(`GitHub API returned ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
releaseData = await response.json()
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to fetch release info: ${error?.message ?? error}`)
|
||||
// Network unavailable — fall back to cached binary if we found one
|
||||
if (binaryPath) {
|
||||
log.info('Network unavailable, using cached llama-server binary:', binaryPath)
|
||||
onStatus?.('Ready (offline)')
|
||||
return binaryPath
|
||||
}
|
||||
throw new Error(
|
||||
`Failed to fetch release info (no internet?) and no cached llama.cpp binary found. ` +
|
||||
`Please connect to the internet for the initial llama.cpp installation. ` +
|
||||
`Original error: ${error?.message ?? error}`
|
||||
)
|
||||
}
|
||||
|
||||
const tag = releaseData.tag_name
|
||||
@@ -334,13 +401,37 @@ export const checkLlamaCppUpdate = async (): Promise<{ currentVersion: string |
|
||||
export const updateLlamaCpp = async (
|
||||
onStatus?: (status: string) => void
|
||||
): Promise<{ url?: string; status?: string; pid?: number; binaryPath?: string; version?: string | null }> => {
|
||||
// 1. Stop if running
|
||||
// 1. Verify network is available BEFORE destructive operations —
|
||||
// don't delete the old binary if we can't download a replacement.
|
||||
onStatus?.('Checking for updates…')
|
||||
let releaseTag: string
|
||||
try {
|
||||
const response = await fetch(
|
||||
'https://api.github.com/repos/ggml-org/llama.cpp/releases/latest',
|
||||
{
|
||||
headers: { Accept: 'application/vnd.github.v3+json' },
|
||||
signal: AbortSignal.timeout(10000)
|
||||
}
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(`GitHub API returned ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
const data = await response.json()
|
||||
releaseTag = data.tag_name
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Cannot update llama.cpp: unable to reach GitHub. ` +
|
||||
`Please check your internet connection. (${error?.message ?? error})`
|
||||
)
|
||||
}
|
||||
|
||||
// 2. Stop if running
|
||||
await stopLlamaCpp()
|
||||
|
||||
// 2. Clear old cache directory
|
||||
// 3. Clear old cache directory (safe — we verified network above)
|
||||
const currentInfo = getLlamaCppInfo()
|
||||
if (currentInfo.version) {
|
||||
const cacheDir = path.join(getUserDataPath(), 'llama.cpp', currentInfo.version)
|
||||
const cacheDir = path.join(getInstallDir(), 'llama.cpp', currentInfo.version)
|
||||
if (fs.existsSync(cacheDir)) {
|
||||
onStatus?.('Removing old version…')
|
||||
try {
|
||||
@@ -351,11 +442,11 @@ export const updateLlamaCpp = async (
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Temporarily enforce 'latest' in config so it fetches the newest
|
||||
// 4. Temporarily enforce 'latest' in config so it fetches the newest
|
||||
const config = await getConfig()
|
||||
await setConfig({ llamaCpp: { ...config.llamaCpp, version: 'latest' } }) // Assuming setConfig is available, if not we'll modify it
|
||||
await setConfig({ llamaCpp: { ...config.llamaCpp, version: 'latest' } })
|
||||
|
||||
// 4. Download new release
|
||||
// 5. Download new release
|
||||
onStatus?.('Downloading update…')
|
||||
await setupLlamaCpp(onStatus)
|
||||
|
||||
@@ -512,7 +603,7 @@ export const validateLlamaCppProcess = (): boolean => {
|
||||
export const uninstallLlamaCpp = async (): Promise<void> => {
|
||||
await stopLlamaCpp()
|
||||
|
||||
const cacheBase = path.join(getUserDataPath(), 'llama.cpp')
|
||||
const cacheBase = path.join(getInstallDir(), 'llama.cpp')
|
||||
if (fs.existsSync(cacheBase)) {
|
||||
fs.rmSync(cacheBase, { recursive: true, force: true })
|
||||
log.info('Removed llama.cpp directory:', cacheBase)
|
||||
|
||||
@@ -6,9 +6,11 @@ import * as pty from 'node-pty'
|
||||
import {
|
||||
getPythonPath,
|
||||
getConfig,
|
||||
setConfig,
|
||||
installPackage,
|
||||
isPackageInstalled,
|
||||
isPythonInstalled,
|
||||
installPython,
|
||||
portInUse
|
||||
} from './index'
|
||||
import { ServiceLock, isProcessAlive } from './service-lock'
|
||||
@@ -37,7 +39,8 @@ export const getOpenTerminalPty = (): pty.IPty | null => ptyProcess
|
||||
export const getOpenTerminalLog = (): string[] => logBuffer
|
||||
|
||||
export const startOpenTerminal = async (
|
||||
port: number | null = null
|
||||
port: number | null = null,
|
||||
onStatus?: (status: string) => void
|
||||
): Promise<{ url: string; apiKey: string; pid: number }> => {
|
||||
if (!lock.acquire()) {
|
||||
return { url, apiKey, pid }
|
||||
@@ -45,11 +48,35 @@ export const startOpenTerminal = async (
|
||||
|
||||
await stopOpenTerminal()
|
||||
|
||||
if (!isPythonInstalled()) throw new Error('Python is not installed')
|
||||
if (!isPythonInstalled()) {
|
||||
log.info('Python not installed — installing automatically for Open Terminal…')
|
||||
onStatus?.('Installing Python…')
|
||||
try {
|
||||
const ok = await installPython(undefined, onStatus)
|
||||
if (!ok) throw new Error('Python installation returned false')
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`Python is required for Open Terminal but installation failed: ${err?.message ?? err}`
|
||||
)
|
||||
}
|
||||
if (!isPythonInstalled()) {
|
||||
throw new Error(
|
||||
'Python was installed but could not be verified. Please restart the app and try again.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (!isPackageInstalled('open-terminal')) {
|
||||
log.info('open-terminal not installed, installing...')
|
||||
const ok = await installPackage('open-terminal')
|
||||
if (!ok) throw new Error('Failed to install open-terminal')
|
||||
log.info('open-terminal not installed, attempting install...')
|
||||
onStatus?.('Installing Open Terminal package…')
|
||||
try {
|
||||
await installPackage('open-terminal')
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`Open Terminal is not installed and auto-install failed. ` +
|
||||
`Please connect to the internet and try again. (${err?.message ?? err})`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const pythonPath = getPythonPath()
|
||||
@@ -57,8 +84,14 @@ export const startOpenTerminal = async (
|
||||
const config = await getConfig()
|
||||
const configEnvVars = config.envVars ?? {}
|
||||
|
||||
// Auto-generate API key
|
||||
const generatedKey = crypto.randomBytes(24).toString('base64url')
|
||||
// Use persisted API key or generate and save a new one
|
||||
let generatedKey = config.openTerminal?.apiKey
|
||||
if (!generatedKey) {
|
||||
generatedKey = crypto.randomBytes(24).toString('base64url')
|
||||
await setConfig({
|
||||
openTerminal: { ...config.openTerminal, apiKey: generatedKey }
|
||||
})
|
||||
}
|
||||
|
||||
// Find available port
|
||||
let desiredPort = port || 39284
|
||||
|
||||
@@ -15,6 +15,14 @@ ipcRenderer.on('desktop:event', (_event, data) => {
|
||||
eventCallbacks.forEach((cb) => cb(data))
|
||||
})
|
||||
|
||||
// ─── Theme Sync: Open WebUI → Desktop ───────────────────
|
||||
// Open WebUI calls window.applyTheme() after every theme change.
|
||||
// We inject this hook so the desktop shell can mirror the theme.
|
||||
contextBridge.exposeInMainWorld('applyTheme', () => {
|
||||
const theme = localStorage.getItem('theme') ?? 'system'
|
||||
ipcRenderer.sendToHost('webview:event', { type: 'theme:update', data: { theme } })
|
||||
})
|
||||
|
||||
// Expose to the Open WebUI page via contextBridge (secure, unforgeable)
|
||||
contextBridge.exposeInMainWorld('electronAPI', {
|
||||
// Push events: desktop → Open WebUI
|
||||
|
||||
+15
-41
@@ -1,20 +1,8 @@
|
||||
import { ipcRenderer, contextBridge } from 'electron'
|
||||
import { electronAPI } from '@electron-toolkit/preload'
|
||||
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
ipcRenderer.on('main:data', (_event, data) => {
|
||||
window.postMessage(
|
||||
{
|
||||
...data,
|
||||
type: `electron:${data.type}`
|
||||
},
|
||||
window.location.origin
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── PTY MessagePort ────────────────────────────────────
|
||||
// The MessagePort stays in the preload (cannot cross contextBridge).
|
||||
// MessagePorts stay in the preload (cannot cross contextBridge).
|
||||
// We expose simple functions so the renderer never touches the port.
|
||||
let activePtyPort: MessagePort | null = null
|
||||
let ptyOutputCallback: ((data: string) => void) | null = null
|
||||
@@ -22,17 +10,10 @@ let ptyOutputCallback: ((data: string) => void) | null = null
|
||||
ipcRenderer.on('pty:port', (event, _data) => {
|
||||
const [port] = event.ports
|
||||
if (!port) return
|
||||
|
||||
// Clean up previous port
|
||||
if (activePtyPort) {
|
||||
activePtyPort.close()
|
||||
}
|
||||
if (activePtyPort) activePtyPort.close()
|
||||
activePtyPort = port
|
||||
|
||||
port.onmessage = (ev: MessageEvent) => {
|
||||
if (ev.data?.type === 'output' && ptyOutputCallback) {
|
||||
ptyOutputCallback(ev.data.data)
|
||||
}
|
||||
if (ev.data?.type === 'output' && ptyOutputCallback) ptyOutputCallback(ev.data.data)
|
||||
}
|
||||
port.start()
|
||||
})
|
||||
@@ -44,37 +25,25 @@ let otPtyOutputCallback: ((data: string) => void) | null = null
|
||||
ipcRenderer.on('open-terminal:pty:port', (event, _data) => {
|
||||
const [port] = event.ports
|
||||
if (!port) return
|
||||
|
||||
if (activeOtPtyPort) {
|
||||
activeOtPtyPort.close()
|
||||
}
|
||||
if (activeOtPtyPort) activeOtPtyPort.close()
|
||||
activeOtPtyPort = port
|
||||
|
||||
port.onmessage = (ev: MessageEvent) => {
|
||||
if (ev.data?.type === 'output' && otPtyOutputCallback) {
|
||||
otPtyOutputCallback(ev.data.data)
|
||||
}
|
||||
if (ev.data?.type === 'output' && otPtyOutputCallback) otPtyOutputCallback(ev.data.data)
|
||||
}
|
||||
port.start()
|
||||
})
|
||||
|
||||
// ─── llama.cpp PTY MessagePort ───────────────────────
|
||||
// ─── llama.cpp PTY MessagePort ──────────────────────────
|
||||
let activeLsCppPtyPort: MessagePort | null = null
|
||||
let lsCppPtyOutputCallback: ((data: string) => void) | null = null
|
||||
|
||||
ipcRenderer.on('llamacpp:pty:port', (event, _data) => {
|
||||
const [port] = event.ports
|
||||
if (!port) return
|
||||
|
||||
if (activeLsCppPtyPort) {
|
||||
activeLsCppPtyPort.close()
|
||||
}
|
||||
if (activeLsCppPtyPort) activeLsCppPtyPort.close()
|
||||
activeLsCppPtyPort = port
|
||||
|
||||
port.onmessage = (ev: MessageEvent) => {
|
||||
if (ev.data?.type === 'output' && lsCppPtyOutputCallback) {
|
||||
lsCppPtyOutputCallback(ev.data.data)
|
||||
}
|
||||
if (ev.data?.type === 'output' && lsCppPtyOutputCallback) lsCppPtyOutputCallback(ev.data.data)
|
||||
}
|
||||
port.start()
|
||||
})
|
||||
@@ -91,6 +60,7 @@ const api = {
|
||||
getVersion: () => ipcRenderer.invoke('get:version'),
|
||||
resetApp: () => ipcRenderer.invoke('app:reset'),
|
||||
getDefaultDataPath: () => ipcRenderer.invoke('app:defaultDataPath'),
|
||||
getInstallDir: () => ipcRenderer.invoke('app:installDir'),
|
||||
getContentPreloadPath: () => ipcRenderer.invoke('app:contentPreloadPath'),
|
||||
getDiskSpace: () => ipcRenderer.invoke('system:diskSpace'),
|
||||
getLaunchAtLogin: () => ipcRenderer.invoke('app:launchAtLogin:get'),
|
||||
@@ -185,7 +155,8 @@ const api = {
|
||||
ipcRenderer.invoke('huggingface:models:download', repo, filename, token, expectedSize),
|
||||
deleteHfModel: (repo: string, filename: string) =>
|
||||
ipcRenderer.invoke('huggingface:models:delete', repo, filename),
|
||||
cancelHfDownload: () => ipcRenderer.invoke('huggingface:models:cancel'),
|
||||
cancelHfDownload: (repo?: string, filename?: string) =>
|
||||
ipcRenderer.invoke('huggingface:models:cancel', repo, filename),
|
||||
searchHfModels: (query: string, token?: string) =>
|
||||
ipcRenderer.invoke('huggingface:search', query, token),
|
||||
getHfRepoFiles: (repo: string, token?: string) =>
|
||||
@@ -211,7 +182,10 @@ const api = {
|
||||
installUpdate: () => ipcRenderer.invoke('updater:install'),
|
||||
|
||||
// Changelog
|
||||
getChangelog: () => ipcRenderer.invoke('app:changelog')
|
||||
getChangelog: () => ipcRenderer.invoke('app:changelog'),
|
||||
|
||||
// Auth token relay from webview
|
||||
setAuthToken: (token: string) => ipcRenderer.invoke('app:setAuthToken', token)
|
||||
}
|
||||
|
||||
if (process.contextIsolated) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { ipcRenderer, contextBridge } from 'electron'
|
||||
|
||||
const api = {
|
||||
// Main process tells us to start/stop recording
|
||||
onRecordingState: (
|
||||
callback: (data: { recording: boolean }) => void
|
||||
): void => {
|
||||
ipcRenderer.on('voiceInput:state', (_event, data) => {
|
||||
callback(data)
|
||||
})
|
||||
},
|
||||
|
||||
// Request microphone permission (macOS system-level)
|
||||
checkMicPermission: (): Promise<string> => {
|
||||
return ipcRenderer.invoke('voiceInput:micPermission')
|
||||
},
|
||||
|
||||
// Send recorded audio to main process for transcription
|
||||
transcribe: (audioBuffer: ArrayBuffer, token?: string): Promise<any> => {
|
||||
return ipcRenderer.invoke('voiceInput:transcribe', audioBuffer, token)
|
||||
},
|
||||
|
||||
// Notify main process that transcription completed
|
||||
done: (text: string): void => {
|
||||
ipcRenderer.invoke('voiceInput:done', text)
|
||||
},
|
||||
|
||||
// Close/hide the voice input window
|
||||
close: (): void => {
|
||||
ipcRenderer.invoke('voiceInput:close')
|
||||
},
|
||||
|
||||
// Report an error
|
||||
error: (message: string): void => {
|
||||
ipcRenderer.invoke('voiceInput:error', message)
|
||||
}
|
||||
}
|
||||
|
||||
if (process.contextIsolated) {
|
||||
try {
|
||||
contextBridge.exposeInMainWorld('voiceInputAPI', api)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
} else {
|
||||
// @ts-ignore
|
||||
window.voiceInputAPI = api
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Electron</title>
|
||||
<title>Open WebUI</title>
|
||||
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Open WebUI – Spotlight</title>
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/spotlight-main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
+32
-26
@@ -1,10 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import { onMount, onDestroy } from 'svelte'
|
||||
import { fade } from 'svelte/transition'
|
||||
import { appInfo, config, connections, serverInfo, appState } from './lib/stores'
|
||||
|
||||
import Main from './lib/components/Main.svelte'
|
||||
|
||||
let themeMediaQuery: MediaQueryList
|
||||
let themeChangeHandler: ((e: MediaQueryListEvent) => void) | null = null
|
||||
|
||||
const applyResolvedTheme = (theme: string) => {
|
||||
let resolved = theme
|
||||
if (theme === 'system') {
|
||||
resolved = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
}
|
||||
document.documentElement.classList.remove('light', 'dark')
|
||||
document.documentElement.classList.add(resolved)
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
const api = window?.electronAPI
|
||||
if (!api) return
|
||||
@@ -15,11 +27,17 @@
|
||||
|
||||
// Apply saved theme
|
||||
const savedTheme = (await api.getConfig())?.theme ?? 'system'
|
||||
let resolved = savedTheme
|
||||
if (savedTheme === 'system') {
|
||||
resolved = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
applyResolvedTheme(savedTheme)
|
||||
|
||||
// Listen for OS theme changes so "system" mode reacts in real-time
|
||||
themeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
themeChangeHandler = () => {
|
||||
const currentTheme = $config?.theme ?? 'system'
|
||||
if (currentTheme === 'system') {
|
||||
applyResolvedTheme('system')
|
||||
}
|
||||
}
|
||||
document.documentElement.classList.add(resolved)
|
||||
themeMediaQuery.addEventListener('change', themeChangeHandler)
|
||||
|
||||
api.onData((data: any) => {
|
||||
if (data.type === 'status:server') {
|
||||
@@ -30,32 +48,20 @@
|
||||
}
|
||||
})
|
||||
|
||||
// Install python in the background — don't block UI
|
||||
const pythonReady = await api.getPythonStatus()
|
||||
if (!pythonReady) {
|
||||
// Check disk space before installing
|
||||
const MINIMUM_DISK_BYTES = 5 * 1024 * 1024 * 1024 // 5 GB
|
||||
const disk = await api.getDiskSpace()
|
||||
if (disk?.free >= 0 && disk.free < MINIMUM_DISK_BYTES) {
|
||||
const availableGB = (disk.free / (1024 * 1024 * 1024)).toFixed(1)
|
||||
appState.set(`insufficient-storage:${availableGB}`)
|
||||
return
|
||||
}
|
||||
|
||||
appState.set('initializing')
|
||||
api.installPython().then(async () => {
|
||||
appState.set('ready')
|
||||
}).catch((e: any) => {
|
||||
appState.set(`install-failed:${e?.message || 'Python installation failed. Please try again.'}`)
|
||||
})
|
||||
} else {
|
||||
appState.set('ready')
|
||||
}
|
||||
// Don't auto-install anything — the user must explicitly choose
|
||||
// "Get Started" (local install) which handles Python/uv as a prerequisite.
|
||||
appState.set('ready')
|
||||
|
||||
setInterval(async () => {
|
||||
serverInfo.set(await api.getServerInfo())
|
||||
}, 3000)
|
||||
})
|
||||
|
||||
onDestroy(() => {
|
||||
if (themeMediaQuery && themeChangeHandler) {
|
||||
themeMediaQuery.removeEventListener('change', themeChangeHandler)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<main class="w-full h-full bg-[#f5f5f7] dark:bg-[#0a0a0a]">
|
||||
|
||||
@@ -0,0 +1,526 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import logoImage from '../lib/assets/images/splash.png'
|
||||
|
||||
let inputEl = $state<HTMLInputElement | null>(null)
|
||||
let query = $state('')
|
||||
let images = $state<string[]>([])
|
||||
let errorMsg = $state('')
|
||||
let showHint = $state(true)
|
||||
|
||||
// Bar position within the fullscreen window
|
||||
let barX = $state(0)
|
||||
let barY = $state(160)
|
||||
let screenW = $state(1920)
|
||||
let screenH = $state(1080)
|
||||
|
||||
const BAR_W = 748
|
||||
|
||||
const api = window.spotlightAPI
|
||||
|
||||
// ─── Error Toast ───
|
||||
let errorTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const showError = (msg: string, duration = 4000) => {
|
||||
errorMsg = msg
|
||||
if (errorTimer) clearTimeout(errorTimer)
|
||||
errorTimer = setTimeout(() => { errorMsg = '' }, duration)
|
||||
}
|
||||
|
||||
// ─── Submit ───
|
||||
const submit = () => {
|
||||
const q = query.trim()
|
||||
if (!q && images.length === 0) return
|
||||
if (!api) return
|
||||
// Svelte 5 $state creates Proxy objects that Electron IPC can't serialize.
|
||||
// Spread into a plain array of plain strings for structured clone.
|
||||
const plainImages = images.length > 0 ? [...images].map((s) => s.slice(0)) : undefined
|
||||
api.submitQuery(q, plainImages)
|
||||
query = ''
|
||||
images = []
|
||||
}
|
||||
|
||||
// ─── Bar Dragging ───
|
||||
let barDragging = $state(false)
|
||||
let barDragStart = { mx: 0, my: 0, bx: 0, by: 0 }
|
||||
|
||||
const onBarMouseDown = (e: MouseEvent) => {
|
||||
// Only drag from the bar background, not from input or buttons
|
||||
const target = e.target as HTMLElement
|
||||
if (target.tagName === 'INPUT' || target.closest('button') || target.closest('.preview')) return
|
||||
e.preventDefault()
|
||||
barDragging = true
|
||||
barDragStart = { mx: e.clientX, my: e.clientY, bx: barX, by: barY }
|
||||
}
|
||||
|
||||
// ─── Region Selection ───
|
||||
let selecting = $state(false)
|
||||
let selStart = { x: 0, y: 0 }
|
||||
let selRect = $state({ x: 0, y: 0, w: 0, h: 0 })
|
||||
let didDrag = false
|
||||
const DRAG_THRESHOLD = 8
|
||||
|
||||
const onWrapperMouseDown = (e: MouseEvent) => {
|
||||
// Ignore if it's on the bar or preview
|
||||
if ((e.target as HTMLElement).closest('.bar') || (e.target as HTMLElement).closest('.preview')) return
|
||||
e.preventDefault()
|
||||
selStart = { x: e.clientX, y: e.clientY }
|
||||
selecting = true
|
||||
didDrag = false
|
||||
selRect = { x: e.clientX, y: e.clientY, w: 0, h: 0 }
|
||||
}
|
||||
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
if (barDragging) {
|
||||
const dx = e.clientX - barDragStart.mx
|
||||
const dy = e.clientY - barDragStart.my
|
||||
barX = Math.max(0, Math.min(screenW - BAR_W, barDragStart.bx + dx))
|
||||
barY = Math.max(0, Math.min(screenH - 60, barDragStart.by + dy))
|
||||
return
|
||||
}
|
||||
if (selecting) {
|
||||
const dx = e.clientX - selStart.x
|
||||
const dy = e.clientY - selStart.y
|
||||
if (!didDrag && Math.abs(dx) + Math.abs(dy) > DRAG_THRESHOLD) {
|
||||
didDrag = true
|
||||
}
|
||||
selRect = {
|
||||
x: Math.min(e.clientX, selStart.x),
|
||||
y: Math.min(e.clientY, selStart.y),
|
||||
w: Math.abs(dx),
|
||||
h: Math.abs(dy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const onMouseUp = async (e: MouseEvent) => {
|
||||
if (barDragging) {
|
||||
barDragging = false
|
||||
api?.savePosition({ x: barX, y: barY })
|
||||
return
|
||||
}
|
||||
if (selecting) {
|
||||
selecting = false
|
||||
showHint = false
|
||||
if (didDrag && selRect.w > 10 && selRect.h > 10) {
|
||||
// Capture the selected region
|
||||
const result = await api?.captureRegion({
|
||||
x: selRect.x,
|
||||
y: selRect.y,
|
||||
width: selRect.w,
|
||||
height: selRect.h
|
||||
})
|
||||
if (result === 'no-permission') {
|
||||
showError('Screen Recording permission required. Opening System Settings…')
|
||||
} else if (result && typeof result === 'string' && result.startsWith('data:')) {
|
||||
images = [...images, result]
|
||||
} else if (!result) {
|
||||
showError('Screenshot capture failed.')
|
||||
}
|
||||
selRect = { x: 0, y: 0, w: 0, h: 0 }
|
||||
} else {
|
||||
// Click on background (not a drag) — dismiss
|
||||
selRect = { x: 0, y: 0, w: 0, h: 0 }
|
||||
api?.closeSpotlight()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const removeImage = (index: number) => {
|
||||
images = images.filter((_, i) => i !== index)
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
inputEl?.focus()
|
||||
window.addEventListener('focus', () => {
|
||||
inputEl?.focus()
|
||||
})
|
||||
|
||||
api?.onInit?.((data) => {
|
||||
if (data.screenSize) {
|
||||
screenW = data.screenSize.width
|
||||
screenH = data.screenSize.height
|
||||
}
|
||||
if (data.barOffset) {
|
||||
barX = data.barOffset.x
|
||||
barY = data.barOffset.y
|
||||
} else {
|
||||
// Default: center horizontally, near top
|
||||
barX = Math.round((screenW - BAR_W) / 2)
|
||||
barY = 160
|
||||
}
|
||||
if (data.query) {
|
||||
query = data.query
|
||||
requestAnimationFrame(() => inputEl?.select())
|
||||
}
|
||||
requestAnimationFrame(() => inputEl?.focus())
|
||||
|
||||
// Show screenshot hint
|
||||
showHint = true
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<svelte:window
|
||||
onkeydown={(e) => e.key === 'Escape' && api?.closeSpotlight()}
|
||||
onmousemove={onMouseMove}
|
||||
onmouseup={onMouseUp}
|
||||
/>
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="wrapper" onmousedown={onWrapperMouseDown}>
|
||||
<!-- Screen capture overlay -->
|
||||
{#if selecting && didDrag}
|
||||
<!-- Selection with shadow-based dimming -->
|
||||
<div
|
||||
class="selection"
|
||||
style="left:{selRect.x}px;top:{selRect.y}px;width:{selRect.w}px;height:{selRect.h}px"
|
||||
>
|
||||
<div class="handle tl"></div>
|
||||
<div class="handle tr"></div>
|
||||
<div class="handle bl"></div>
|
||||
<div class="handle br"></div>
|
||||
</div>
|
||||
<!-- Dimensions label -->
|
||||
<div
|
||||
class="dimensions"
|
||||
style="left:{selRect.x + selRect.w / 2}px;top:{selRect.y + selRect.h + 12}px"
|
||||
>
|
||||
{Math.round(selRect.w)} × {Math.round(selRect.h)}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Floating bar -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="bar"
|
||||
style="left:{barX}px;top:{barY}px"
|
||||
onmousedown={onBarMouseDown}
|
||||
>
|
||||
{#if images.length > 0}
|
||||
<div class="attachments">
|
||||
{#each images as img, i}
|
||||
<div class="preview-item">
|
||||
<img src={img} alt="Screenshot {i + 1}" />
|
||||
<button class="preview-remove" onclick={() => removeImage(i)} aria-label="Remove">×</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="input-row">
|
||||
<img class="logo" src={logoImage} alt="" />
|
||||
|
||||
<input
|
||||
bind:this={inputEl}
|
||||
bind:value={query}
|
||||
type="text"
|
||||
placeholder="What can I help you with today?"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter' && !e.isComposing) {
|
||||
e.preventDefault()
|
||||
submit()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<button class="send" class:active={query.trim().length > 0 || images.length > 0} aria-label="Send" onclick={submit}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 19V5" />
|
||||
<path d="M5 12l7-7 7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Screenshot hint -->
|
||||
{#if showHint && images.length === 0 && !selecting}
|
||||
<div class="hint">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M4 8V6a2 2 0 012-2h2" /><path d="M4 16v2a2 2 0 002 2h2" /><path d="M16 4h2a2 2 0 012 2v2" /><path d="M16 20h2a2 2 0 002-2v-2" />
|
||||
</svg>
|
||||
Drag anywhere to capture a screenshot
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Error toast -->
|
||||
{#if errorMsg}
|
||||
<div class="error-toast">{errorMsg}</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@font-face {
|
||||
font-family: 'Archivo';
|
||||
src: url('../lib/assets/fonts/Archivo-Variable.ttf');
|
||||
font-display: swap;
|
||||
}
|
||||
:global(*) { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
:global(html), :global(body), :global(#app) {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.selection {
|
||||
position: absolute;
|
||||
border: 2px solid rgba(255, 255, 255, 0.9);
|
||||
border-radius: 2px;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
/* Massive spread shadow dims everything outside the selection */
|
||||
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.handle {
|
||||
position: absolute;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: white;
|
||||
border-radius: 1px;
|
||||
box-shadow: 0 0 2px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
.handle.tl { top: -3px; left: -3px; }
|
||||
.handle.tr { top: -3px; right: -3px; }
|
||||
.handle.bl { bottom: -3px; left: -3px; }
|
||||
.handle.br { bottom: -3px; right: -3px; }
|
||||
|
||||
.dimensions {
|
||||
position: absolute;
|
||||
transform: translateX(-50%);
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
font-family: 'Archivo', -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.02em;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
backdrop-filter: blur(4px);
|
||||
pointer-events: none;
|
||||
z-index: 11;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bar {
|
||||
position: absolute;
|
||||
width: 748px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 14px;
|
||||
font-family: 'Archivo', -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
|
||||
cursor: grab;
|
||||
z-index: 100;
|
||||
overflow: hidden;
|
||||
|
||||
background: #f5f5f7;
|
||||
color: #1d1d1f;
|
||||
border: 0.5px solid rgba(0, 0, 0, 0.08);
|
||||
box-shadow:
|
||||
0 2px 8px rgba(0, 0, 0, 0.06),
|
||||
0 8px 24px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.bar {
|
||||
background: #1a1a1c;
|
||||
color: #fafafa;
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
box-shadow:
|
||||
0 2px 8px rgba(0, 0, 0, 0.2),
|
||||
0 8px 24px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
}
|
||||
|
||||
.input-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
height: 52px;
|
||||
padding: 0 9px 0 15px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
flex-shrink: 0;
|
||||
object-fit: contain;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.logo { filter: invert(1); }
|
||||
}
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
font-family: inherit;
|
||||
font-size: 17px;
|
||||
font-weight: 450;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.01em;
|
||||
color: #1d1d1f;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
input::placeholder {
|
||||
color: rgba(0, 0, 0, 0.22);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
input { color: #fafafa; }
|
||||
input::placeholder { color: rgba(255, 255, 255, 0.18); }
|
||||
}
|
||||
|
||||
.send {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
outline: none;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
color: rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.send.active {
|
||||
background: #1d1d1f;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.send.active:hover { opacity: 0.8; }
|
||||
.send.active:active { transform: scale(0.92); }
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.send {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
.send.active {
|
||||
background: #fafafa;
|
||||
color: #1d1d1f;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Inline Attachments ─── */
|
||||
.attachments {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 10px 12px 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.preview-item {
|
||||
position: relative;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.preview-item img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.preview-remove {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.preview-item:hover .preview-remove {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ─── Error Toast ─── */
|
||||
.error-toast {
|
||||
position: fixed;
|
||||
bottom: 80px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 10px 20px;
|
||||
border-radius: 10px;
|
||||
font-family: 'Archivo', -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #fff;
|
||||
background: rgba(220, 38, 38, 0.9);
|
||||
backdrop-filter: blur(8px);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
|
||||
z-index: 200;
|
||||
pointer-events: none;
|
||||
animation: toast-in 0.25s ease-out;
|
||||
}
|
||||
|
||||
@keyframes toast-in {
|
||||
from { opacity: 0; transform: translateX(-50%) translateY(10px); }
|
||||
to { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||
}
|
||||
|
||||
/* ─── Screenshot Hint ─── */
|
||||
.hint {
|
||||
position: fixed;
|
||||
bottom: 48px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
border-radius: 10px;
|
||||
font-family: 'Archivo', -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
|
||||
font-size: 13px;
|
||||
font-weight: 450;
|
||||
z-index: 200;
|
||||
pointer-events: none;
|
||||
animation: hint-in 0.25s ease-out;
|
||||
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 0.5px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
@keyframes hint-in {
|
||||
from { opacity: 0; transform: translateX(-50%) translateY(6px); }
|
||||
to { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,337 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte'
|
||||
|
||||
const api = window.voiceInputAPI
|
||||
|
||||
let recording = $state(false)
|
||||
let transcribing = $state(false)
|
||||
let duration = $state(0)
|
||||
let errorMsg = $state('')
|
||||
|
||||
// Waveform
|
||||
let levels: number[] = $state(Array(5).fill(0.15))
|
||||
let animFrame: number | null = null
|
||||
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
let errorTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
// Audio
|
||||
let mediaRecorder: MediaRecorder | null = null
|
||||
let audioChunks: Blob[] = []
|
||||
let mediaStream: MediaStream | null = null
|
||||
let analyser: AnalyserNode | null = null
|
||||
let audioCtx: AudioContext | null = null
|
||||
let dataArray: Uint8Array | null = null
|
||||
|
||||
// Dragging
|
||||
let dragging = false
|
||||
let dragStart = { mx: 0, my: 0, wx: 0, wy: 0 }
|
||||
|
||||
const formatDuration = (s: number): string => {
|
||||
const m = Math.floor(s / 60)
|
||||
return `${m}:${(s % 60).toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const animateLevel = () => {
|
||||
if (analyser && dataArray) {
|
||||
analyser.getByteFrequencyData(dataArray)
|
||||
// Sample 5 frequency bands
|
||||
const bands = 5
|
||||
const step = Math.floor(dataArray.length / bands)
|
||||
levels = Array.from({ length: bands }, (_, i) => {
|
||||
const val = dataArray![i * step] / 255
|
||||
return Math.max(0.15, val)
|
||||
})
|
||||
} else {
|
||||
levels = levels.map(() => 0.15 + Math.random() * 0.6)
|
||||
}
|
||||
animFrame = requestAnimationFrame(animateLevel)
|
||||
}
|
||||
|
||||
const showError = (msg: string) => {
|
||||
errorMsg = msg
|
||||
if (errorTimer) clearTimeout(errorTimer)
|
||||
errorTimer = setTimeout(() => {
|
||||
errorMsg = ''
|
||||
api?.close()
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
const startRecording = async () => {
|
||||
// Reset all state from any previous session
|
||||
cleanup()
|
||||
errorMsg = ''
|
||||
transcribing = false
|
||||
recording = true
|
||||
duration = 0
|
||||
audioChunks = []
|
||||
animateLevel() // show placeholder bars immediately
|
||||
|
||||
// Wait for the start chime (played from main process) to finish
|
||||
// before activating mic — macOS ducks audio when mic activates
|
||||
await new Promise((r) => setTimeout(r, 500))
|
||||
|
||||
try {
|
||||
// Request system-level mic permission (macOS) before activating the mic
|
||||
const permStatus = await api?.checkMicPermission()
|
||||
if (permStatus === 'denied') {
|
||||
const msg = 'Microphone access denied. Enable it in System Settings → Privacy & Security → Microphone, then restart the app.'
|
||||
showError(msg)
|
||||
api?.error(msg)
|
||||
return
|
||||
}
|
||||
|
||||
mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||
audioChunks = []
|
||||
|
||||
// Set up analyser for real audio levels
|
||||
audioCtx = new AudioContext()
|
||||
analyser = audioCtx.createAnalyser()
|
||||
analyser.fftSize = 64
|
||||
dataArray = new Uint8Array(analyser.frequencyBinCount)
|
||||
const source = audioCtx.createMediaStreamSource(mediaStream)
|
||||
source.connect(analyser)
|
||||
|
||||
mediaRecorder = new MediaRecorder(mediaStream, {
|
||||
mimeType: MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
|
||||
? 'audio/webm;codecs=opus'
|
||||
: 'audio/webm'
|
||||
})
|
||||
|
||||
mediaRecorder.ondataavailable = (e) => {
|
||||
if (e.data.size > 0) audioChunks.push(e.data)
|
||||
}
|
||||
|
||||
mediaRecorder.start(250)
|
||||
timer = setInterval(() => { duration++ }, 1000)
|
||||
} catch (err: any) {
|
||||
const msg = err?.name === 'NotAllowedError'
|
||||
? 'Microphone access denied. Check system permissions.'
|
||||
: err?.name === 'NotFoundError'
|
||||
? 'No microphone found. Connect a microphone and try again.'
|
||||
: err?.message || 'Mic access failed'
|
||||
showError(msg)
|
||||
api?.error(msg)
|
||||
}
|
||||
}
|
||||
|
||||
const cleanup = () => {
|
||||
recording = false
|
||||
transcribing = false
|
||||
if (timer) { clearInterval(timer); timer = null }
|
||||
if (animFrame) { cancelAnimationFrame(animFrame); animFrame = null }
|
||||
levels = Array(5).fill(0.15)
|
||||
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
|
||||
mediaRecorder.stop()
|
||||
}
|
||||
if (mediaStream) {
|
||||
mediaStream.getTracks().forEach((t) => t.stop())
|
||||
mediaStream = null
|
||||
}
|
||||
if (audioCtx) {
|
||||
audioCtx.close()
|
||||
audioCtx = null
|
||||
analyser = null
|
||||
}
|
||||
mediaRecorder = null
|
||||
}
|
||||
|
||||
const cancelRecording = () => {
|
||||
cleanup()
|
||||
api?.close()
|
||||
}
|
||||
|
||||
const stopRecording = async () => {
|
||||
if (!mediaRecorder || mediaRecorder.state === 'inactive') {
|
||||
cancelRecording()
|
||||
return
|
||||
}
|
||||
|
||||
// Too short — treat as cancel (less than 0.8 seconds)
|
||||
if (duration < 1) {
|
||||
cancelRecording()
|
||||
return
|
||||
}
|
||||
|
||||
recording = false
|
||||
if (timer) { clearInterval(timer); timer = null }
|
||||
if (animFrame) { cancelAnimationFrame(animFrame); animFrame = null }
|
||||
levels = Array(5).fill(0.15)
|
||||
|
||||
const audioBlob = await new Promise<Blob>((resolve) => {
|
||||
mediaRecorder!.onstop = () => {
|
||||
resolve(new Blob(audioChunks, { type: mediaRecorder!.mimeType }))
|
||||
}
|
||||
mediaRecorder!.stop()
|
||||
})
|
||||
|
||||
if (mediaStream) {
|
||||
mediaStream.getTracks().forEach((t) => t.stop())
|
||||
mediaStream = null
|
||||
}
|
||||
if (audioCtx) {
|
||||
audioCtx.close()
|
||||
audioCtx = null
|
||||
analyser = null
|
||||
}
|
||||
|
||||
if (audioBlob.size < 4096) {
|
||||
api?.close()
|
||||
return
|
||||
}
|
||||
|
||||
transcribing = true
|
||||
try {
|
||||
const buffer = await audioBlob.arrayBuffer()
|
||||
const result = await api?.transcribe(buffer)
|
||||
const text = result?.text?.trim()
|
||||
if (text) {
|
||||
api?.done(text)
|
||||
} else {
|
||||
api?.close()
|
||||
}
|
||||
} catch (err: any) {
|
||||
const msg = err?.message || 'Transcription failed'
|
||||
showError(msg)
|
||||
api?.error(msg)
|
||||
}
|
||||
}
|
||||
|
||||
const onMouseDown = (e: MouseEvent) => {
|
||||
dragging = true
|
||||
dragStart = { mx: e.screenX, my: e.screenY, wx: window.screenX, wy: window.screenY }
|
||||
}
|
||||
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
if (!dragging) return
|
||||
window.moveTo(
|
||||
dragStart.wx + (e.screenX - dragStart.mx),
|
||||
dragStart.wy + (e.screenY - dragStart.my)
|
||||
)
|
||||
}
|
||||
|
||||
const onMouseUp = () => { dragging = false }
|
||||
|
||||
onMount(() => {
|
||||
api?.onRecordingState((data) => {
|
||||
if (data.recording && !recording) startRecording()
|
||||
else if (!data.recording && recording) stopRecording()
|
||||
})
|
||||
})
|
||||
|
||||
onDestroy(() => {
|
||||
cleanup()
|
||||
if (errorTimer) clearTimeout(errorTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<svelte:window
|
||||
onkeydown={(e) => { if (e.key === 'Escape') cancelRecording() }}
|
||||
onmousemove={onMouseMove}
|
||||
onmouseup={onMouseUp}
|
||||
/>
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="pill" onmousedown={onMouseDown}>
|
||||
{#if recording}
|
||||
<div class="bars">
|
||||
{#each levels as level}
|
||||
<div class="bar" style="height: {6 + level * 22}px"></div>
|
||||
{/each}
|
||||
</div>
|
||||
<span class="time">{formatDuration(duration)}</span>
|
||||
{:else if transcribing}
|
||||
<div class="loader"></div>
|
||||
{:else if errorMsg}
|
||||
<span class="err">{errorMsg}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@font-face {
|
||||
font-family: 'Archivo';
|
||||
src: url('../lib/assets/fonts/Archivo-Variable.ttf');
|
||||
font-display: swap;
|
||||
}
|
||||
:global(*) { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
:global(html), :global(body), :global(#app) {
|
||||
height: 100%; width: 100%;
|
||||
background: transparent;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.pill {
|
||||
position: absolute;
|
||||
top: 50%; left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 0 20px;
|
||||
height: 44px;
|
||||
border-radius: 22px;
|
||||
cursor: grab;
|
||||
font-family: 'Archivo', -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
|
||||
animation: appear 0.15s ease-out;
|
||||
|
||||
background: rgba(30, 30, 30, 0.78);
|
||||
backdrop-filter: blur(40px) saturate(1.8);
|
||||
-webkit-backdrop-filter: blur(40px) saturate(1.8);
|
||||
border: 0.5px solid rgba(255, 255, 255, 0.12);
|
||||
box-shadow:
|
||||
0 2px 12px rgba(0, 0, 0, 0.35),
|
||||
inset 0 0.5px 0 rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.pill:active { cursor: grabbing; }
|
||||
|
||||
@keyframes appear {
|
||||
from { opacity: 0; transform: translate(-50%, -50%) scale(0.92); }
|
||||
to { opacity: 1; transform: translate(-50%, -50%) scale(1); }
|
||||
}
|
||||
|
||||
.bars {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.bar {
|
||||
width: 4px;
|
||||
border-radius: 99px;
|
||||
background: #fff;
|
||||
opacity: 0.9;
|
||||
transition: height 60ms ease-out;
|
||||
min-height: 6px;
|
||||
}
|
||||
|
||||
.time {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.loader {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.15);
|
||||
border-top-color: rgba(255, 255, 255, 0.8);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.7s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.err {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #ff6b6b;
|
||||
}
|
||||
</style>
|
||||
@@ -14,11 +14,18 @@
|
||||
|
||||
onMount(async () => {
|
||||
connections.set(await window.electronAPI.getConnections())
|
||||
config.set(await window.electronAPI.getConfig())
|
||||
const cfg = await window.electronAPI.getConfig()
|
||||
config.set(cfg)
|
||||
sidebarOpen = cfg?.showSidebar ?? true
|
||||
setTimeout(() => {
|
||||
visible = true
|
||||
}, 50)
|
||||
})
|
||||
|
||||
const toggleSidebar = () => {
|
||||
sidebarOpen = !sidebarOpen
|
||||
window.electronAPI.setConfig({ showSidebar: sidebarOpen })
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if visible}
|
||||
@@ -33,13 +40,13 @@
|
||||
: 'h-8'}"
|
||||
>
|
||||
<div
|
||||
class="flex items-center {$appInfo?.platform === 'darwin'
|
||||
class="flex items-center gap-3 {$appInfo?.platform === 'darwin'
|
||||
? 'pl-25'
|
||||
: 'pl-3'} pr-2 shrink-0 translate-y-[0.5px]"
|
||||
>
|
||||
<button
|
||||
class="opacity-70 hover:opacity-100 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] no-drag"
|
||||
onclick={() => (sidebarOpen = !sidebarOpen)}
|
||||
onclick={toggleSidebar}
|
||||
use:tooltip={sidebarOpen ? $i18n.t('sidebar.tooltip.closeSidebar') : $i18n.t('sidebar.tooltip.openSidebar')}
|
||||
>
|
||||
<svg
|
||||
@@ -56,6 +63,31 @@
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if activeConnectionName}
|
||||
<button
|
||||
class="opacity-40 hover:opacity-80 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] no-drag cursor-pointer"
|
||||
onclick={() => {
|
||||
const wv = document.querySelector('webview[style*="display: flex"]') as any
|
||||
if (wv?.goBack) wv.goBack()
|
||||
}}
|
||||
>
|
||||
<svg class="w-[13px] h-[13px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="opacity-40 hover:opacity-80 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] no-drag cursor-pointer"
|
||||
onclick={() => {
|
||||
const wv = document.querySelector('webview[style*="display: flex"]') as any
|
||||
if (wv?.goForward) wv.goForward()
|
||||
}}
|
||||
>
|
||||
<svg class="w-[13px] h-[13px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex-1 flex items-center justify-center">
|
||||
<span class="text-[11px] opacity-80">{activeConnectionName || $i18n.t('app.name')}</span>
|
||||
|
||||
@@ -50,9 +50,11 @@
|
||||
const serverReachable = $derived($serverInfo?.reachable)
|
||||
|
||||
const isInitializing = $derived($appState === 'initializing')
|
||||
const hasLocal = $derived(($connections ?? []).some((c) => c.type === 'local'))
|
||||
const localConn = $derived(($connections ?? []).find((c) => c.type === 'local'))
|
||||
const remoteConnections = $derived(($connections ?? []).filter((c) => c.type !== 'local'))
|
||||
const localConn = $derived(localInstalled
|
||||
? { id: 'local', name: 'Open WebUI', type: 'local' as const, url: `http://127.0.0.1:${$config?.localServer?.port ?? 8080}` }
|
||||
: null
|
||||
)
|
||||
const remoteConnections = $derived($connections ?? [])
|
||||
|
||||
// Open Terminal state
|
||||
let openTerminalStatus = $state<string | null>(null)
|
||||
@@ -62,13 +64,22 @@
|
||||
let llamaCppStatus = $state<string | null>(null)
|
||||
let llamaCppInfo = $state<{ url?: string; pid?: number } | null>(null)
|
||||
let llamaCppSetupStatus = $state('')
|
||||
let openTerminalSetupStatus = $state('')
|
||||
|
||||
const startInstall = async (options?: { installOpenTerminal?: boolean; installLlamaCpp?: boolean }) => {
|
||||
const startInstall = async (options?: { installOpenTerminal?: boolean; installLlamaCpp?: boolean; installDir?: string }) => {
|
||||
installPhase = 'working'
|
||||
installError = ''
|
||||
installStatus = ''
|
||||
toastVisible = false
|
||||
try {
|
||||
// Save custom install directory before anything else
|
||||
if (options?.installDir) {
|
||||
const currentDir = await window.electronAPI.getInstallDir()
|
||||
if (options.installDir !== currentDir) {
|
||||
await window.electronAPI.setConfig({ installDir: options.installDir })
|
||||
}
|
||||
}
|
||||
|
||||
// Check disk space before installing (minimum 5 GB)
|
||||
const MINIMUM_DISK_BYTES = 5 * 1024 * 1024 * 1024
|
||||
const disk = await window.electronAPI.getDiskSpace()
|
||||
@@ -101,14 +112,7 @@
|
||||
const info = await window.electronAPI.getServerInfo()
|
||||
|
||||
installStatus = $i18n.t('main.install.settingUpConnection')
|
||||
await window.electronAPI.addConnection({
|
||||
id: 'local',
|
||||
name: 'Local',
|
||||
type: 'local',
|
||||
url: info?.url || 'http://127.0.0.1:8080'
|
||||
})
|
||||
await window.electronAPI.setDefaultConnection('local')
|
||||
connections.set(await window.electronAPI.getConnections())
|
||||
config.set(await window.electronAPI.getConfig())
|
||||
|
||||
// Wait for server to actually be reachable before showing connected view
|
||||
@@ -132,8 +136,7 @@
|
||||
|
||||
// Now connect — the server is ready
|
||||
installStatus = ''
|
||||
localInstalled = true
|
||||
await connect('local')
|
||||
connect('local')
|
||||
installPhase = 'idle'
|
||||
} catch (e: any) {
|
||||
installPhase = 'error'
|
||||
@@ -169,7 +172,6 @@
|
||||
type: 'remote',
|
||||
url: u
|
||||
})
|
||||
connections.set(await window.electronAPI.getConnections())
|
||||
config.set(await window.electronAPI.getConfig())
|
||||
url = ''
|
||||
error = ''
|
||||
@@ -182,33 +184,60 @@
|
||||
}
|
||||
}
|
||||
|
||||
const connect = async (id: string) => {
|
||||
const connect = (id: string) => {
|
||||
showingLogs = false
|
||||
// Toggle: clicking the active connection unselects it
|
||||
if (activeConnectionId === id && view === 'connected') {
|
||||
connectingId = ''
|
||||
activeConnectionId = ''
|
||||
connectedUrl = ''
|
||||
view = 'welcome'
|
||||
return
|
||||
}
|
||||
// Persist as default so spotlight/startup always use the last-selected connection
|
||||
window.electronAPI.setDefaultConnection(id)
|
||||
// Already-open connection — just switch to it
|
||||
if (openConnections.has(id)) {
|
||||
connectingId = ''
|
||||
activeConnectionId = id
|
||||
connectedUrl = openConnections.get(id)!
|
||||
view = 'connected'
|
||||
return
|
||||
}
|
||||
connectingId = id
|
||||
try {
|
||||
const result = await window.electronAPI.connectTo(id)
|
||||
if (result?.url) {
|
||||
openConnections.set(result.connectionId, result.url)
|
||||
openConnections = new Map(openConnections) // trigger reactivity
|
||||
connectedUrl = result.url
|
||||
activeConnectionId = result.connectionId
|
||||
view = 'connected'
|
||||
}
|
||||
} finally {
|
||||
|
||||
activeConnectionId = id
|
||||
|
||||
if (id === 'local') {
|
||||
// Local needs server start — use IPC (no renderer-side conn needed)
|
||||
connectingId = id
|
||||
view = 'welcome'
|
||||
window.electronAPI.connectTo(id).then((result: any) => {
|
||||
if (!result?.url) {
|
||||
if (connectingId === id) connectingId = ''
|
||||
return
|
||||
}
|
||||
if (!openConnections.has(result.connectionId)) {
|
||||
openConnections.set(result.connectionId, result.url)
|
||||
openConnections = new Map(openConnections)
|
||||
}
|
||||
if (connectingId === id) {
|
||||
connectedUrl = result.url
|
||||
activeConnectionId = result.connectionId
|
||||
connectingId = ''
|
||||
if (installPhase !== 'working') {
|
||||
view = 'connected'
|
||||
}
|
||||
}
|
||||
})
|
||||
} else {
|
||||
const conn = ($connections ?? []).find((c) => c.id === id)
|
||||
if (!conn) return
|
||||
// Remote — open immediately, no IPC needed
|
||||
connectingId = ''
|
||||
openConnections.set(id, conn.url)
|
||||
openConnections = new Map(openConnections)
|
||||
connectedUrl = conn.url
|
||||
view = 'connected'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,7 +249,6 @@
|
||||
|
||||
const remove = async (id: string) => {
|
||||
await window.electronAPI.removeConnection(id)
|
||||
connections.set(await window.electronAPI.getConnections())
|
||||
config.set(await window.electronAPI.getConfig())
|
||||
if (activeConnectionId === id) {
|
||||
disconnect()
|
||||
@@ -229,11 +257,15 @@
|
||||
openConnections = new Map(openConnections)
|
||||
}
|
||||
|
||||
// Sync active connection info to parent
|
||||
$effect(() => {
|
||||
const conn = ($connections ?? []).find((c) => c.id === activeConnectionId)
|
||||
activeConnectionName = conn?.name ?? ''
|
||||
isLocalConnection = conn?.type === 'local'
|
||||
if (activeConnectionId === 'local') {
|
||||
activeConnectionName = localConn?.name ?? 'Open WebUI'
|
||||
isLocalConnection = true
|
||||
} else {
|
||||
const conn = ($connections ?? []).find((c) => c.id === activeConnectionId)
|
||||
activeConnectionName = conn?.name ?? ''
|
||||
isLocalConnection = false
|
||||
}
|
||||
})
|
||||
|
||||
// React to showingLogs from parent — open the server log panel
|
||||
@@ -301,41 +333,125 @@
|
||||
activeLog = activeLog === log ? null : (log as typeof activeLog)
|
||||
}
|
||||
|
||||
// ── Webview event delivery ─────────────────────────────
|
||||
// Single path: all events from the main process flow through here.
|
||||
// Query events target a specific webview; everything else broadcasts.
|
||||
const sendToWebview = (event: any, connId?: string) => {
|
||||
const container = document.querySelector('.content-webview-container')
|
||||
if (!container) return
|
||||
|
||||
const webviews = connId
|
||||
? [container.querySelector(`webview[partition="persist:connection-${connId}"]`) as any].filter(Boolean)
|
||||
: Array.from(container.querySelectorAll('webview'))
|
||||
|
||||
for (const wv of webviews) {
|
||||
try {
|
||||
// Attempt to send — throws if webview hasn't fired dom-ready yet
|
||||
wv.send('desktop:event', event)
|
||||
} catch {
|
||||
// Webview not ready — queue delivery until dom-ready
|
||||
const onReady = () => {
|
||||
wv.removeEventListener('dom-ready', onReady)
|
||||
try { wv.send('desktop:event', event) } catch (_) {}
|
||||
}
|
||||
wv.addEventListener('dom-ready', onReady)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for events from main process
|
||||
onMount(() => {
|
||||
window.electronAPI.onData((data: any) => {
|
||||
// ── Connection opened (startup, tray click) ───────
|
||||
if (data.type === 'connection:open' && data.data?.url) {
|
||||
const connId = data.data.connectionId ?? ''
|
||||
openConnections.set(connId, data.data.url)
|
||||
openConnections = new Map(openConnections)
|
||||
connectedUrl = data.data.url
|
||||
activeConnectionId = connId
|
||||
// Don't switch to connected view during active install — the install
|
||||
// flow handles its own transition after confirming reachability.
|
||||
if (installPhase !== 'working') {
|
||||
view = 'connected'
|
||||
const incomingUrl = data.data.url
|
||||
|
||||
if (!openConnections.has(connId)) {
|
||||
openConnections.set(connId, incomingUrl)
|
||||
openConnections = new Map(openConnections)
|
||||
}
|
||||
|
||||
if (view !== 'connected') {
|
||||
connectedUrl = openConnections.get(connId) ?? incomingUrl
|
||||
activeConnectionId = connId
|
||||
if (installPhase !== 'working') view = 'connected'
|
||||
}
|
||||
return
|
||||
}
|
||||
if (data.type === 'status:open-terminal') {
|
||||
openTerminalStatus = data.data
|
||||
|
||||
// ── Spotlight / desktop query ─────────────────────
|
||||
if (data.type === 'query' && (data.data?.query || data.data?.files?.length)) {
|
||||
const connId = data.data.connectionId ?? ''
|
||||
const query = data.data.query
|
||||
const files = data.data.files
|
||||
const baseUrl = data.data.url ?? ''
|
||||
|
||||
if (!openConnections.has(connId)) {
|
||||
openConnections.set(connId, baseUrl)
|
||||
openConnections = new Map(openConnections)
|
||||
connectedUrl = baseUrl
|
||||
} else {
|
||||
connectedUrl = openConnections.get(connId)!
|
||||
}
|
||||
activeConnectionId = connId
|
||||
if (installPhase !== 'working') view = 'connected'
|
||||
|
||||
// Targeted delivery — wait a frame for the webview DOM to exist
|
||||
requestAnimationFrame(() => {
|
||||
sendToWebview({ type: 'query', data: { query, files } }, connId)
|
||||
})
|
||||
return
|
||||
}
|
||||
if (data.type === 'open-terminal:ready') {
|
||||
openTerminalInfo = data.data
|
||||
openTerminalStatus = 'started'
|
||||
|
||||
// ── Call shortcut ─────────────────────────────────
|
||||
if (data.type === 'call' && data.data?.connectionId) {
|
||||
const connId = data.data.connectionId ?? ''
|
||||
const baseUrl = data.data.url ?? ''
|
||||
|
||||
if (!openConnections.has(connId)) {
|
||||
openConnections.set(connId, baseUrl)
|
||||
openConnections = new Map(openConnections)
|
||||
connectedUrl = baseUrl
|
||||
} else {
|
||||
connectedUrl = openConnections.get(connId)!
|
||||
}
|
||||
activeConnectionId = connId
|
||||
if (installPhase !== 'working') view = 'connected'
|
||||
|
||||
// Targeted delivery — wait a frame for the webview DOM to exist
|
||||
requestAnimationFrame(() => {
|
||||
sendToWebview({ type: 'call' }, connId)
|
||||
})
|
||||
return
|
||||
}
|
||||
if (data.type === 'status:llamacpp') {
|
||||
llamaCppStatus = data.data
|
||||
|
||||
// ── Desktop-only state (not forwarded to webviews) ─
|
||||
if (data.type === 'status:open-terminal') { openTerminalStatus = data.data; return }
|
||||
if (data.type === 'status:open-terminal-setup') { openTerminalSetupStatus = data.data ?? ''; return }
|
||||
if (data.type === 'open-terminal:ready') { openTerminalInfo = data.data; openTerminalStatus = 'started'; openTerminalSetupStatus = ''; return }
|
||||
if (data.type === 'status:llamacpp') { llamaCppStatus = data.data; return }
|
||||
if (data.type === 'status:llamacpp-setup') { llamaCppSetupStatus = data.data ?? ''; return }
|
||||
if (data.type === 'llamacpp:ready') { llamaCppInfo = data.data; llamaCppStatus = 'started'; llamaCppSetupStatus = ''; return }
|
||||
if (data.type === 'status:install') { installStatus = data.data ?? ''; return }
|
||||
if (data.type === 'packages:changed') {
|
||||
localInstalled = !!data.data?.['open-webui']
|
||||
return
|
||||
}
|
||||
if (data.type === 'status:llamacpp-setup') {
|
||||
llamaCppSetupStatus = data.data ?? ''
|
||||
if (data.type === 'connections:changed') {
|
||||
connections.set(data.data ?? [])
|
||||
return
|
||||
}
|
||||
if (data.type === 'llamacpp:ready') {
|
||||
llamaCppInfo = data.data
|
||||
llamaCppStatus = 'started'
|
||||
llamaCppSetupStatus = ''
|
||||
}
|
||||
if (data.type === 'status:install') {
|
||||
installStatus = data.data ?? ''
|
||||
|
||||
// ── Everything else → broadcast to all webviews ───
|
||||
sendToWebview(data)
|
||||
})
|
||||
|
||||
// Auto-connect to the default connection on startup so the webview
|
||||
// is pre-loaded and ready for spotlight queries.
|
||||
window.electronAPI.getConfig().then((cfg: any) => {
|
||||
if (cfg?.defaultConnectionId && !activeConnectionId) {
|
||||
connect(cfg.defaultConnectionId)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -375,8 +491,10 @@
|
||||
await window.electronAPI.stopOpenTerminal()
|
||||
openTerminalStatus = null
|
||||
openTerminalInfo = null
|
||||
openTerminalSetupStatus = ''
|
||||
} else {
|
||||
openTerminalStatus = 'starting'
|
||||
openTerminalSetupStatus = ''
|
||||
const result = await window.electronAPI.startOpenTerminal()
|
||||
if (result) {
|
||||
openTerminalInfo = result
|
||||
@@ -384,6 +502,7 @@
|
||||
} else {
|
||||
openTerminalStatus = 'failed'
|
||||
}
|
||||
openTerminalSetupStatus = ''
|
||||
}
|
||||
}
|
||||
|
||||
@@ -427,7 +546,6 @@
|
||||
{onOpenSettings}
|
||||
onRename={async (id, name) => {
|
||||
await window.electronAPI.updateConnection(id, { name })
|
||||
connections.set(await window.electronAPI.getConnections())
|
||||
}}
|
||||
onRemove={remove}
|
||||
{openGithub}
|
||||
@@ -469,7 +587,7 @@
|
||||
statusText={activeLog === 'server'
|
||||
? (serverStatus === 'starting' ? 'Starting Open WebUI…' : serverStatus === 'running' && !serverReachable ? 'Waiting for server…' : installStatus || '')
|
||||
: activeLog === 'open-terminal'
|
||||
? (openTerminalStatus === 'stopping' ? 'Stopping Open Terminal…' : openTerminalStatus === 'starting' ? 'Starting Open Terminal…' : '')
|
||||
? (openTerminalStatus === 'stopping' ? 'Stopping Open Terminal…' : openTerminalSetupStatus || (openTerminalStatus === 'starting' ? 'Starting Open Terminal…' : ''))
|
||||
: (llamaCppStatus === 'stopping' ? 'Stopping llama-server…' : llamaCppSetupStatus || (llamaCppStatus === 'starting' ? 'Starting llama-server…' : llamaCppStatus === 'setting-up' ? 'Setting up llama.cpp…' : ''))}
|
||||
connectPty={getConnectPty(activeLog)}
|
||||
disconnectPty={getDisconnectPty(activeLog)}
|
||||
|
||||
@@ -37,12 +37,24 @@
|
||||
<div
|
||||
class="relative flex h-36 items-center justify-center overflow-hidden bg-gradient-to-br from-gray-900 via-gray-800 to-black dark:from-white dark:via-gray-100 dark:to-gray-200"
|
||||
>
|
||||
<div class="absolute inset-0 bg-gradient-to-r from-transparent via-white/5 to-transparent"></div>
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-r from-transparent via-white/5 to-transparent"
|
||||
></div>
|
||||
<div class="relative z-10 text-center">
|
||||
<div class="mb-2.5 flex justify-center">
|
||||
<div class="rounded-full bg-white/10 p-3 dark:bg-black/10">
|
||||
<svg class="w-6 h-6 text-white dark:text-gray-900" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5a17.92 17.92 0 01-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418" />
|
||||
<svg
|
||||
class="w-6 h-6 text-white dark:text-gray-900"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5a17.92 17.92 0 01-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
@@ -57,11 +69,14 @@
|
||||
|
||||
<!-- Body -->
|
||||
<div class="px-6 py-5">
|
||||
<label class="block text-[11px] text-gray-400 dark:text-gray-500"
|
||||
>{$i18n.t('setup.connectionManager.serverUrl')}</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={url}
|
||||
placeholder={$i18n.t('setup.urlPlaceholder')}
|
||||
class="w-full px-4 py-2.5 rounded-xl bg-black/[0.015] dark:bg-white/[0.02] text-[13px] text-[#1d1d1f] dark:text-[#fafafa] placeholder:opacity-25 outline-none focus:bg-black/[0.03] dark:focus:bg-white/[0.04] transition border border-black/[0.06] dark:border-white/[0.06]"
|
||||
placeholder="https://"
|
||||
class="w-full py-2 text-[14px] text-[#1d1d1f] dark:text-[#fafafa] placeholder:opacity-20 outline-none bg-transparent border-none border-b border-black/[0.08] dark:border-white/[0.08]"
|
||||
onkeydown={(e) => e.key === 'Enter' && onConnect()}
|
||||
/>
|
||||
|
||||
@@ -79,7 +94,9 @@
|
||||
>
|
||||
{#if connecting}
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<span class="w-3.5 h-3.5 rounded-full border-2 border-white/30 dark:border-black/30 border-t-white dark:border-t-black animate-spin inline-block"></span>
|
||||
<span
|
||||
class="w-3.5 h-3.5 rounded-full border-2 border-white/30 dark:border-black/30 border-t-white dark:border-t-black animate-spin inline-block"
|
||||
></span>
|
||||
{$i18n.t('common.connecting')}
|
||||
</span>
|
||||
{:else}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import { fade, fly } from 'svelte/transition'
|
||||
import { connections, config, serverInfo, appState } from '../../../stores'
|
||||
import { config, serverInfo, appState } from '../../../stores'
|
||||
import i18n from '../../../i18n'
|
||||
import LocalInstall from '../../Setup/LocalInstall.svelte'
|
||||
import GetStartedModal from './GetStartedModal.svelte'
|
||||
@@ -25,7 +25,7 @@
|
||||
connecting: boolean
|
||||
error: string
|
||||
autoInstall: boolean
|
||||
onStartInstall: (options?: { installOpenTerminal?: boolean; installLlamaCpp?: boolean }) => void
|
||||
onStartInstall: (options?: { installOpenTerminal?: boolean; installLlamaCpp?: boolean; installDir?: string }) => void
|
||||
onAddConnection: () => void
|
||||
onSetView: (v: string) => void
|
||||
showAddConnectionModal: boolean
|
||||
@@ -72,42 +72,55 @@
|
||||
// Track webview loading per connection
|
||||
let webviewLoading: Map<string, boolean> = $state(new Map())
|
||||
|
||||
// Track webview load errors per connection
|
||||
let webviewErrors: Map<string, { code: number; description: string; url: string }> = $state(new Map())
|
||||
|
||||
// Content preload path for webview bridge
|
||||
let contentPreloadPath: string = $state('')
|
||||
|
||||
// Server is starting up (local)
|
||||
const serverStarting = $derived(
|
||||
localConn && localInstalled && (
|
||||
localInstalled && (
|
||||
$serverInfo?.status === 'starting' ||
|
||||
($serverInfo?.status === 'running' && !$serverInfo?.reachable)
|
||||
)
|
||||
)
|
||||
|
||||
const activeWebviewError = $derived(
|
||||
view === 'connected' && activeConnectionId
|
||||
? webviewErrors.get(activeConnectionId) ?? null
|
||||
: null
|
||||
)
|
||||
|
||||
const isLoading = $derived(
|
||||
connectingId !== '' ||
|
||||
serverStarting ||
|
||||
(view === 'connected' && activeConnectionId && (webviewLoading.get(activeConnectionId) ?? true))
|
||||
(serverStarting && activeConnectionId === 'local') ||
|
||||
(view === 'connected' && !activeWebviewError && webviewLoading.get(activeConnectionId) === true)
|
||||
)
|
||||
|
||||
const retryActiveWebview = () => {
|
||||
const wv = document.querySelector(
|
||||
`webview[partition="persist:connection-${activeConnectionId}"]`
|
||||
) as any
|
||||
if (wv?.reload) {
|
||||
webviewErrors.delete(activeConnectionId)
|
||||
webviewErrors = new Map(webviewErrors)
|
||||
wv.reload()
|
||||
}
|
||||
}
|
||||
|
||||
const openActiveInBrowser = () => {
|
||||
const connUrl = openConnections.get(activeConnectionId)
|
||||
if (connUrl) {
|
||||
window.electronAPI.openInBrowser(connUrl)
|
||||
}
|
||||
}
|
||||
|
||||
// Attach load event listeners and IPC forwarding to webviews
|
||||
onMount(async () => {
|
||||
// Fetch the content preload path once
|
||||
contentPreloadPath = await window.electronAPI.getContentPreloadPath()
|
||||
|
||||
// Forward main:data events from the main process into all active webviews
|
||||
window.electronAPI.onData((data: any) => {
|
||||
const container = document.querySelector('.content-webview-container')
|
||||
if (!container) return
|
||||
const webviews = container.querySelectorAll('webview')
|
||||
webviews.forEach((wv: any) => {
|
||||
try {
|
||||
wv.send('desktop:event', data)
|
||||
} catch (_) {
|
||||
// webview may not be ready yet
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
const container = document.querySelector('.content-webview-container')
|
||||
if (!container) return
|
||||
@@ -130,11 +143,63 @@
|
||||
webviewLoading = new Map(webviewLoading)
|
||||
})
|
||||
|
||||
// Track load failures so we can show an error overlay
|
||||
wv.addEventListener('did-fail-load', (event: any) => {
|
||||
// Ignore sub-resource failures and aborted navigations (-3)
|
||||
if (event.errorCode === -3 || event.isMainFrame === false) return
|
||||
webviewErrors.set(connId, {
|
||||
code: event.errorCode,
|
||||
description: event.errorDescription || 'Unknown error',
|
||||
url: event.validatedURL || ''
|
||||
})
|
||||
webviewErrors = new Map(webviewErrors)
|
||||
})
|
||||
|
||||
// Clear error when a navigation succeeds (retry, redirect, etc.)
|
||||
wv.addEventListener('did-navigate', () => {
|
||||
if (webviewErrors.has(connId)) {
|
||||
webviewErrors.delete(connId)
|
||||
webviewErrors = new Map(webviewErrors)
|
||||
}
|
||||
})
|
||||
|
||||
// Renderer process crash
|
||||
wv.addEventListener('crashed', () => {
|
||||
webviewErrors.set(connId, {
|
||||
code: -1,
|
||||
description: 'crashed',
|
||||
url: ''
|
||||
})
|
||||
webviewErrors = new Map(webviewErrors)
|
||||
})
|
||||
|
||||
// Log guest page console messages for debugging blank-page issues (#124)
|
||||
wv.addEventListener('console-message', (event: any) => {
|
||||
if (event.level >= 2) { // warnings and errors only
|
||||
console.warn(`[webview:${connId}]`, event.message)
|
||||
}
|
||||
})
|
||||
|
||||
// If this webview was created before the preload path resolved
|
||||
// (race between auto-connect and async IPC), the preload didn't
|
||||
// attach. Force a reload now so it picks up the correct preload.
|
||||
if (contentPreloadPath && wv.getAttribute('preload') !== contentPreloadPath) {
|
||||
wv.setAttribute('preload', contentPreloadPath)
|
||||
wv.reload()
|
||||
}
|
||||
|
||||
// Handle IPC messages from the webview guest (Open WebUI → desktop)
|
||||
wv.addEventListener('ipc-message', async (event: any) => {
|
||||
if (event.channel === 'webview:send') {
|
||||
const requestData = event.args?.[0]
|
||||
if (!requestData) return
|
||||
|
||||
// Handle auth token relay from webview
|
||||
if (requestData.type === 'token:update' && requestData.token) {
|
||||
window.electronAPI.setAuthToken?.(requestData.token)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await window.electronAPI[requestData.type]?.(requestData)
|
||||
if (requestData._requestId) {
|
||||
@@ -149,6 +214,35 @@
|
||||
} else if (event.channel === 'webview:load') {
|
||||
const page = event.args?.[0]
|
||||
if (page) onSetView(page === 'home' ? 'welcome' : page)
|
||||
} else if (event.channel === 'webview:event') {
|
||||
const payload = event.args?.[0]
|
||||
if (!payload?.type) return
|
||||
|
||||
if (payload.type === 'theme:update') {
|
||||
const webuiTheme = payload.data?.theme ?? 'system'
|
||||
|
||||
// Map Open WebUI theme names to desktop-compatible values
|
||||
let desktopTheme: string
|
||||
if (webuiTheme === 'system') {
|
||||
desktopTheme = 'system'
|
||||
} else if (webuiTheme.includes('dark')) {
|
||||
desktopTheme = 'dark'
|
||||
} else {
|
||||
desktopTheme = 'light'
|
||||
}
|
||||
|
||||
// Resolve and apply CSS class
|
||||
let resolved = desktopTheme
|
||||
if (desktopTheme === 'system') {
|
||||
resolved = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
}
|
||||
document.documentElement.classList.remove('light', 'dark')
|
||||
document.documentElement.classList.add(resolved)
|
||||
|
||||
// Persist to desktop config
|
||||
await window.electronAPI.setConfig({ theme: desktopTheme })
|
||||
config.set(await window.electronAPI.getConfig())
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -174,12 +268,54 @@
|
||||
src={connUrl}
|
||||
class="flex-1 min-h-0 border-none"
|
||||
style="display: {view === 'connected' && activeConnectionId === connId ? 'flex' : 'none'}"
|
||||
allowpopups
|
||||
partition="persist:connection-{connId}"
|
||||
preload={contentPreloadPath}
|
||||
allowpopups
|
||||
></webview>
|
||||
{/each}
|
||||
|
||||
<!-- Error overlay when webview fails to load -->
|
||||
{#if activeWebviewError}
|
||||
<div class="absolute inset-0 z-20 flex items-center justify-center bg-[#eee] dark:bg-[#111]" transition:fade={{ duration: 200 }}>
|
||||
<div class="text-center max-w-sm px-6">
|
||||
<div class="mx-auto mb-4 w-10 h-10 rounded-full bg-black/[0.04] dark:bg-white/[0.06] flex items-center justify-center">
|
||||
{#if activeWebviewError.code === -1}
|
||||
<svg class="w-5 h-5 opacity-30" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
|
||||
</svg>
|
||||
{:else}
|
||||
<svg class="w-5 h-5 opacity-30" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5a17.92 17.92 0 01-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418" />
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="text-[14px] font-medium mb-1 opacity-80">
|
||||
{activeWebviewError.code === -1 ? $i18n.t('setup.pageCrashed') : $i18n.t('setup.couldNotLoadPage')}
|
||||
</div>
|
||||
<div class="text-[12px] opacity-30 mb-1">{activeWebviewError.description}</div>
|
||||
{#if activeWebviewError.url}
|
||||
<div class="text-[11px] opacity-20 mb-6 break-all font-mono">{activeWebviewError.url}</div>
|
||||
{:else}
|
||||
<div class="mb-6"></div>
|
||||
{/if}
|
||||
<div class="flex gap-2 justify-center">
|
||||
<button
|
||||
class="px-4 py-2 rounded-xl text-[13px] font-medium bg-black dark:bg-white text-white dark:text-black border-none cursor-pointer transition hover:bg-gray-800 dark:hover:bg-gray-100 active:scale-[0.98]"
|
||||
onclick={retryActiveWebview}
|
||||
>
|
||||
{$i18n.t('common.retry')}
|
||||
</button>
|
||||
<button
|
||||
class="px-4 py-2 rounded-xl text-[13px] bg-black/[0.04] dark:bg-white/[0.06] text-[#1d1d1f] dark:text-[#fafafa] border-none cursor-pointer opacity-60 hover:opacity-90 transition active:scale-[0.98]"
|
||||
onclick={openActiveInBrowser}
|
||||
>
|
||||
{$i18n.t('setup.openInBrowser')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Loading overlay for webview -->
|
||||
{#if isLoading}
|
||||
<div class="absolute inset-0 z-10 flex items-center justify-center bg-[#eee] dark:bg-[#111]" transition:fade={{ duration: 200 }}>
|
||||
@@ -253,7 +389,7 @@
|
||||
|
||||
<div class="flex-1 flex items-center justify-center px-6 relative overflow-hidden">
|
||||
{#if view === 'welcome'}
|
||||
{#if remoteConnections.length > 0 || (localConn && localInstalled)}
|
||||
{#if remoteConnections.length > 0 || localInstalled}
|
||||
<div class="text-center max-w-[320px]" in:fade={{ duration: 200 }}>
|
||||
<div class="text-lg opacity-80 mb-1.5">{$i18n.t('app.name')}</div>
|
||||
<div class="text-[12px] opacity-30 mb-6">
|
||||
@@ -351,7 +487,6 @@
|
||||
autoStart={autoInstall}
|
||||
onBack={() => { autoInstall = false; onSetView('welcome') }}
|
||||
onComplete={async () => {
|
||||
connections.set(await window.electronAPI.getConnections())
|
||||
config.set(await window.electronAPI.getConfig())
|
||||
onSetView('welcome')
|
||||
}}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import { fade, scale } from 'svelte/transition'
|
||||
import i18n from '../../../i18n'
|
||||
import Switch from '../../common/Switch.svelte'
|
||||
|
||||
interface Props {
|
||||
onContinue: (options: { installOpenTerminal: boolean; installLlamaCpp: boolean }) => void
|
||||
onContinue: (options: { installOpenTerminal: boolean; installLlamaCpp: boolean; installDir: string }) => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
@@ -12,6 +13,21 @@
|
||||
|
||||
let installOpenTerminal = $state(true)
|
||||
let installLlamaCpp = $state(true)
|
||||
let installDir = $state('')
|
||||
let defaultInstallDir = $state('')
|
||||
let advancedOpen = $state(false)
|
||||
|
||||
onMount(async () => {
|
||||
defaultInstallDir = await window.electronAPI.getInstallDir()
|
||||
installDir = defaultInstallDir
|
||||
})
|
||||
|
||||
const changeInstallDir = async () => {
|
||||
const folder = await window.electronAPI.selectFolder()
|
||||
if (folder) {
|
||||
installDir = folder
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
@@ -24,7 +40,7 @@
|
||||
<div class="absolute inset-0 bg-black/50 backdrop-blur-sm"></div>
|
||||
|
||||
<div
|
||||
class="relative mx-4 w-full max-w-lg overflow-hidden rounded-3xl bg-white shadow-2xl dark:bg-gray-950"
|
||||
class="relative mx-4 w-full max-w-xl overflow-hidden rounded-3xl bg-white shadow-2xl dark:bg-gray-950"
|
||||
transition:scale={{ start: 0.97, duration: 180 }}
|
||||
onmousedown={(e) => e.stopPropagation()}
|
||||
>
|
||||
@@ -51,7 +67,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Options -->
|
||||
<div class="px-6 py-4 flex flex-col divide-y divide-gray-100 dark:divide-gray-800/60">
|
||||
<div class="px-6 py-4 flex flex-col divide-y divide-gray-100/30 dark:divide-gray-800/15">
|
||||
<div class="py-3.5 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div class="text-[13px] font-medium text-gray-700 dark:text-gray-300">{$i18n.t('main.getStarted.openTerminal')}</div>
|
||||
@@ -78,11 +94,48 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Advanced (collapsed) -->
|
||||
<div class="px-6 pb-4">
|
||||
<button
|
||||
class="flex items-center gap-1.5 bg-transparent border-none p-0 cursor-pointer"
|
||||
onclick={() => { advancedOpen = !advancedOpen }}
|
||||
>
|
||||
<svg
|
||||
class="w-2.5 h-2.5 text-gray-400 dark:text-gray-500 transition-transform duration-200 {advancedOpen ? 'rotate-90' : ''}"
|
||||
fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<span class="text-[11px] text-gray-400 dark:text-gray-500">{$i18n.t('common.advanced')}</span>
|
||||
</button>
|
||||
|
||||
{#if advancedOpen}
|
||||
<div class="mt-3">
|
||||
<div class="text-[11px] text-gray-400 dark:text-gray-500 mb-1.5">{$i18n.t('setup.install.installLocation')}</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="flex-1 min-w-0 px-3 py-2 bg-gray-50 dark:bg-gray-900 text-[11px] text-gray-500 dark:text-gray-400 font-mono truncate rounded-xl"
|
||||
title={installDir}
|
||||
>
|
||||
{installDir || '…'}
|
||||
</div>
|
||||
<button
|
||||
class="shrink-0 text-[11px] text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 px-3 py-2 bg-gray-50 dark:bg-gray-900 rounded-xl transition border-none cursor-pointer"
|
||||
onclick={changeInstallDir}
|
||||
>
|
||||
{$i18n.t('setup.install.changeLocation')}
|
||||
</button>
|
||||
</div>
|
||||
<div class="text-[10px] text-gray-300 dark:text-gray-600 mt-1">{$i18n.t('setup.install.installLocationDesc')}</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="px-5 pb-5 flex flex-col gap-2">
|
||||
<div class="px-5 pb-5 pt-1 flex flex-col gap-2">
|
||||
<button
|
||||
class="w-full rounded-xl bg-gray-900 dark:bg-white px-4 py-2.5 text-sm font-medium text-white dark:text-gray-900 transition-all duration-200 hover:bg-gray-800 dark:hover:bg-gray-100 active:scale-[0.98] border-none cursor-pointer"
|
||||
onclick={() => onContinue({ installOpenTerminal, installLlamaCpp })}
|
||||
onclick={() => onContinue({ installOpenTerminal, installLlamaCpp, installDir })}
|
||||
>
|
||||
{$i18n.t('main.getStarted.continue')}
|
||||
</button>
|
||||
@@ -95,3 +148,4 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -69,7 +69,9 @@
|
||||
>
|
||||
<!-- Connections header -->
|
||||
<div class="flex items-center justify-between px-4 pt-2 pb-1.5">
|
||||
<span class="text-[10px] tracking-wider uppercase opacity-60">{$i18n.t('sidebar.connections')}</span>
|
||||
<span class="text-[10px] tracking-wider uppercase opacity-60"
|
||||
>{$i18n.t('sidebar.connections')}</span
|
||||
>
|
||||
<button
|
||||
class="opacity-25 hover:opacity-60 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] leading-none"
|
||||
onclick={() => {
|
||||
@@ -93,20 +95,18 @@
|
||||
<div class="flex-1 min-h-0 overflow-y-auto px-2">
|
||||
<!-- Pinned: Open WebUI (local) -->
|
||||
{#if localConn && localInstalled}
|
||||
{@const isServerLoading = connectingId === localConn.id || serverStatus === 'starting' || (serverStatus === 'running' && !serverReachable)}
|
||||
{@const isLocalDisabled = !serverReachable && !isServerLoading}
|
||||
{@const isServerLoading =
|
||||
connectingId === localConn.id ||
|
||||
serverStatus === 'starting' ||
|
||||
(serverStatus === 'running' && !serverReachable)}
|
||||
<div
|
||||
class="w-full px-2 py-[6px] rounded-xl group flex items-center gap-2 transition-colors {isLocalDisabled
|
||||
? 'opacity-40 cursor-default'
|
||||
: 'cursor-pointer'} {activeConnectionId === localConn.id || isServerLoading
|
||||
? 'bg-black/[0.06] dark:bg-white/[0.08]'
|
||||
: isLocalDisabled
|
||||
? ''
|
||||
: 'hover:bg-black/[0.03] dark:bg-white/[0.05]'}"
|
||||
class="w-full px-2.5 py-1.5 rounded-xl group flex items-center gap-2 transition-colors cursor-pointer {activeConnectionId === localConn.id
|
||||
? 'bg-black/[0.08] dark:bg-white/[0.08]'
|
||||
: 'hover:bg-black/[0.04] dark:hover:bg-white/[0.06]'}"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => !isLocalDisabled && onConnect(localConn.id)}
|
||||
onkeydown={(e) => e.key === 'Enter' && !isLocalDisabled && onConnect(localConn.id)}
|
||||
onclick={() => onConnect(localConn.id)}
|
||||
onkeydown={(e) => e.key === 'Enter' && onConnect(localConn.id)}
|
||||
>
|
||||
{#if connectingId === localConn.id || serverStatus === 'starting' || (serverStatus === 'running' && !serverReachable)}
|
||||
<div class="w-[14px] h-[14px] shrink-0 flex items-center justify-center">
|
||||
@@ -144,8 +144,8 @@
|
||||
{:else}
|
||||
<span
|
||||
class="text-[12px] {activeConnectionId === localConn.id
|
||||
? 'opacity-90'
|
||||
: 'opacity-100'} transition-opacity truncate flex-1 min-w-0"
|
||||
? 'font-medium opacity-100'
|
||||
: 'opacity-70'} transition-opacity truncate flex-1 min-w-0"
|
||||
>{localConn.name ?? 'Open WebUI'}</span
|
||||
>
|
||||
{/if}
|
||||
@@ -180,7 +180,7 @@
|
||||
>
|
||||
<div class="py-1 px-1.5">
|
||||
<button
|
||||
class="w-full flex items-center gap-2.5 px-3 py-1.5 text-left text-[12px] opacity-50 hover:opacity-90 hover:bg-black/[0.04] dark:bg-white/[0.06] transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-xl"
|
||||
class="w-full flex items-center gap-2.5 px-3 py-1.5 text-left text-[12px] opacity-50 hover:opacity-90 hover:bg-black/[0.04] dark:hover:bg-white/[0.06] transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-xl"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation()
|
||||
menuOpenId = null
|
||||
@@ -203,7 +203,7 @@
|
||||
{$i18n.t('common.rename')}
|
||||
</button>
|
||||
<button
|
||||
class="w-full flex items-center gap-2.5 px-3 py-1.5 text-left text-[12px] opacity-50 hover:opacity-90 hover:bg-black/[0.04] dark:bg-white/[0.06] transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-xl"
|
||||
class="w-full flex items-center gap-2.5 px-3 py-1.5 text-left text-[12px] opacity-50 hover:opacity-90 hover:bg-black/[0.04] dark:hover:bg-white/[0.06] transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-xl"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation()
|
||||
menuOpenId = null
|
||||
@@ -238,10 +238,10 @@
|
||||
|
||||
{#each remoteConnections as conn (conn.id)}
|
||||
<div
|
||||
class="w-full px-2 py-[6px] rounded-xl group flex items-center gap-2 transition-colors cursor-pointer {activeConnectionId ===
|
||||
class="w-full px-2.5 py-1.5 rounded-xl group flex items-center gap-2 transition-colors cursor-pointer {activeConnectionId ===
|
||||
conn.id
|
||||
? 'bg-black/[0.06] dark:bg-white/[0.08]'
|
||||
: 'hover:bg-black/[0.03] dark:bg-white/[0.05]'}"
|
||||
? 'bg-black/[0.08] dark:bg-white/[0.08]'
|
||||
: 'hover:bg-black/[0.04] dark:hover:bg-white/[0.06]'}"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => editingId !== conn.id && onConnect(conn.id)}
|
||||
@@ -288,8 +288,8 @@
|
||||
{:else}
|
||||
<span
|
||||
class="text-[12px] {activeConnectionId === conn.id
|
||||
? 'opacity-90'
|
||||
: 'opacity-100'} transition-opacity truncate flex-1 min-w-0">{conn.name}</span
|
||||
? 'font-medium opacity-100'
|
||||
: 'opacity-70'} transition-opacity truncate flex-1 min-w-0">{conn.name}</span
|
||||
>
|
||||
{/if}
|
||||
|
||||
@@ -324,7 +324,7 @@
|
||||
>
|
||||
<div class="py-1 px-1.5">
|
||||
<button
|
||||
class="w-full flex items-center gap-2.5 px-3 py-1.5 text-left text-[12px] opacity-50 hover:opacity-90 hover:bg-black/[0.04] dark:bg-white/[0.06] transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-xl"
|
||||
class="w-full flex items-center gap-2.5 px-3 py-1.5 text-left text-[12px] opacity-50 hover:opacity-90 hover:bg-black/[0.04] dark:hover:bg-white/[0.06] transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-xl"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation()
|
||||
menuOpenId = null
|
||||
@@ -347,7 +347,7 @@
|
||||
{$i18n.t('common.rename')}
|
||||
</button>
|
||||
<button
|
||||
class="w-full flex items-center gap-2.5 px-3 py-1.5 text-left text-[12px] opacity-50 hover:opacity-90 hover:bg-black/[0.04] dark:bg-white/[0.06] transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-xl"
|
||||
class="w-full flex items-center gap-2.5 px-3 py-1.5 text-left text-[12px] opacity-50 hover:opacity-90 hover:bg-black/[0.04] dark:hover:bg-white/[0.06] transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-xl"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation()
|
||||
menuOpenId = null
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
|
||||
const remove = async (id: string) => {
|
||||
await window.electronAPI.removeConnection(id)
|
||||
connections.set(await window.electronAPI.getConnections())
|
||||
config.set(await window.electronAPI.getConfig())
|
||||
}
|
||||
</script>
|
||||
@@ -30,7 +29,7 @@
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3"
|
||||
d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5a17.92 17.92 0 01-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418"
|
||||
/>
|
||||
{/if}
|
||||
</svg>
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
let resetting = $state(false)
|
||||
let theme = $state<string>('system')
|
||||
let advancedOpen = $state(false)
|
||||
let installDirPath = $state('')
|
||||
let defaultInstallDir = $state('')
|
||||
|
||||
// Env vars editor state
|
||||
let envEntries = $state<{ key: string; value: string }[]>([])
|
||||
@@ -26,6 +28,10 @@
|
||||
theme = cfg?.theme ?? 'system'
|
||||
applyThemeClass(theme)
|
||||
|
||||
// Load install dir
|
||||
defaultInstallDir = await window.electronAPI.getInstallDir()
|
||||
installDirPath = cfg?.installDir || defaultInstallDir
|
||||
|
||||
// Load languages
|
||||
languages = await getLanguages()
|
||||
selectedLanguage = cfg?.language ?? localStorage.getItem('locale') ?? 'en-US'
|
||||
@@ -45,6 +51,18 @@
|
||||
applyThemeClass(newTheme)
|
||||
await window.electronAPI.setConfig({ theme: newTheme })
|
||||
config.set(await window.electronAPI.getConfig())
|
||||
|
||||
// Push theme to all active Open WebUI webviews
|
||||
const container = document.querySelector('.content-webview-container')
|
||||
if (container) {
|
||||
container.querySelectorAll('webview').forEach((wv: any) => {
|
||||
try {
|
||||
wv.send('desktop:event', { type: 'theme:update', data: { theme: newTheme } })
|
||||
} catch (_) {
|
||||
// webview may not be ready yet
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const setDefault = async (id: string) => {
|
||||
@@ -76,6 +94,26 @@
|
||||
let recording = $state(false)
|
||||
let shortcutInputEl = $state<HTMLButtonElement | null>(null)
|
||||
|
||||
// Spotlight shortcut recorder
|
||||
let spotlightShortcutValue = $state('')
|
||||
let spotlightRecording = $state(false)
|
||||
let spotlightShortcutInputEl = $state<HTMLButtonElement | null>(null)
|
||||
|
||||
// Voice input shortcut recorder
|
||||
let voiceInputShortcutValue = $state('')
|
||||
let voiceInputRecording = $state(false)
|
||||
let voiceInputShortcutInputEl = $state<HTMLButtonElement | null>(null)
|
||||
let voiceInputEnabled = $state(true)
|
||||
|
||||
// Call shortcut recorder
|
||||
let callShortcutValue = $state('')
|
||||
let callRecording = $state(false)
|
||||
let callShortcutInputEl = $state<HTMLButtonElement | null>(null)
|
||||
let callEnabled = $state(true)
|
||||
|
||||
// Spotlight clipboard paste
|
||||
let spotlightClipboardPaste = $state(true)
|
||||
|
||||
// Keep shortcut value in sync with config store
|
||||
$effect(() => {
|
||||
if ($config?.globalShortcut !== undefined) {
|
||||
@@ -83,6 +121,33 @@
|
||||
}
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
if ($config?.spotlightShortcut !== undefined) {
|
||||
spotlightShortcutValue = $config.spotlightShortcut ?? ''
|
||||
}
|
||||
if ($config?.spotlightClipboardPaste !== undefined) {
|
||||
spotlightClipboardPaste = $config.spotlightClipboardPaste ?? true
|
||||
}
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
if ($config?.voiceInputShortcut !== undefined) {
|
||||
voiceInputShortcutValue = $config.voiceInputShortcut ?? ''
|
||||
}
|
||||
if ($config?.voiceInputEnabled !== undefined) {
|
||||
voiceInputEnabled = $config.voiceInputEnabled ?? true
|
||||
}
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
if ($config?.callShortcut !== undefined) {
|
||||
callShortcutValue = $config.callShortcut ?? ''
|
||||
}
|
||||
if ($config?.callEnabled !== undefined) {
|
||||
callEnabled = $config.callEnabled ?? true
|
||||
}
|
||||
})
|
||||
|
||||
const keyToElectron = (e: KeyboardEvent): string | null => {
|
||||
const parts: string[] = []
|
||||
if (e.metaKey || e.ctrlKey) parts.push('CommandOrControl')
|
||||
@@ -93,16 +158,40 @@
|
||||
const ignore = ['Control', 'Meta', 'Alt', 'Shift']
|
||||
if (ignore.includes(e.key)) return null
|
||||
|
||||
// Map special keys
|
||||
const keyMap: Record<string, string> = {
|
||||
' ': 'Space',
|
||||
// Use e.code to get the physical key (avoids macOS Alt producing unicode like √ for V)
|
||||
const codeMap: Record<string, string> = {
|
||||
Space: 'Space',
|
||||
ArrowUp: 'Up',
|
||||
ArrowDown: 'Down',
|
||||
ArrowLeft: 'Left',
|
||||
ArrowRight: 'Right',
|
||||
Enter: 'Return'
|
||||
Enter: 'Return',
|
||||
Backquote: '`',
|
||||
Minus: '-',
|
||||
Equal: '=',
|
||||
BracketLeft: '[',
|
||||
BracketRight: ']',
|
||||
Backslash: '\\',
|
||||
Semicolon: ';',
|
||||
Quote: "'",
|
||||
Comma: ',',
|
||||
Period: '.',
|
||||
Slash: '/'
|
||||
}
|
||||
const key = keyMap[e.key] ?? (e.key.length === 1 ? e.key.toUpperCase() : e.key)
|
||||
|
||||
let key: string
|
||||
if (codeMap[e.code]) {
|
||||
key = codeMap[e.code]
|
||||
} else if (e.code.startsWith('Key')) {
|
||||
key = e.code.slice(3) // KeyA → A
|
||||
} else if (e.code.startsWith('Digit')) {
|
||||
key = e.code.slice(5) // Digit1 → 1
|
||||
} else if (e.code.startsWith('F') && /^F\d+$/.test(e.code)) {
|
||||
key = e.code // F1, F2, etc.
|
||||
} else {
|
||||
key = e.key.length === 1 ? e.key.toUpperCase() : e.key
|
||||
}
|
||||
|
||||
parts.push(key)
|
||||
return parts.join('+')
|
||||
}
|
||||
@@ -142,6 +231,84 @@
|
||||
config.set(await window.electronAPI.getConfig())
|
||||
}
|
||||
}
|
||||
|
||||
const handleSpotlightShortcutKeydown = async (e: KeyboardEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
spotlightRecording = false
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === 'Backspace' || e.key === 'Delete') {
|
||||
spotlightShortcutValue = ''
|
||||
spotlightRecording = false
|
||||
await window.electronAPI.setConfig({ spotlightShortcut: '' })
|
||||
config.set(await window.electronAPI.getConfig())
|
||||
return
|
||||
}
|
||||
|
||||
const accel = keyToElectron(e)
|
||||
if (accel) {
|
||||
spotlightShortcutValue = accel
|
||||
spotlightRecording = false
|
||||
await window.electronAPI.setConfig({ spotlightShortcut: accel })
|
||||
config.set(await window.electronAPI.getConfig())
|
||||
}
|
||||
}
|
||||
|
||||
const handleVoiceInputShortcutKeydown = async (e: KeyboardEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
voiceInputRecording = false
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === 'Backspace' || e.key === 'Delete') {
|
||||
voiceInputShortcutValue = ''
|
||||
voiceInputRecording = false
|
||||
await window.electronAPI.setConfig({ voiceInputShortcut: '' })
|
||||
config.set(await window.electronAPI.getConfig())
|
||||
return
|
||||
}
|
||||
|
||||
const accel = keyToElectron(e)
|
||||
if (accel) {
|
||||
voiceInputShortcutValue = accel
|
||||
voiceInputRecording = false
|
||||
await window.electronAPI.setConfig({ voiceInputShortcut: accel })
|
||||
config.set(await window.electronAPI.getConfig())
|
||||
}
|
||||
}
|
||||
|
||||
const handleCallShortcutKeydown = async (e: KeyboardEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
callRecording = false
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === 'Backspace' || e.key === 'Delete') {
|
||||
callShortcutValue = ''
|
||||
callRecording = false
|
||||
await window.electronAPI.setConfig({ callShortcut: '' })
|
||||
config.set(await window.electronAPI.getConfig())
|
||||
return
|
||||
}
|
||||
|
||||
const accel = keyToElectron(e)
|
||||
if (accel) {
|
||||
callShortcutValue = accel
|
||||
callRecording = false
|
||||
await window.electronAPI.setConfig({ callShortcut: accel })
|
||||
config.set(await window.electronAPI.getConfig())
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col divide-y divide-white/[0.04]">
|
||||
@@ -303,6 +470,220 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="py-4 flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-[13px] opacity-70">{$i18n.t('settings.general.spotlightShortcut')}</div>
|
||||
<div class="text-[11px] opacity-25 mt-0.5">
|
||||
{#if spotlightRecording}
|
||||
{$i18n.t('settings.general.globalShortcutRecording')}
|
||||
{:else}
|
||||
{$i18n.t('settings.general.spotlightShortcutDesc')}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<button
|
||||
bind:this={spotlightShortcutInputEl}
|
||||
class="text-[12px] px-3 py-1.5 border-none outline-none rounded-xl transition min-w-[80px] text-center
|
||||
{spotlightRecording
|
||||
? 'bg-black/[0.08] dark:bg-white/[0.10] text-[#1d1d1f] dark:text-[#fafafa] opacity-80 animate-pulse'
|
||||
: 'bg-black/[0.04] dark:bg-white/[0.06] text-[#1d1d1f] dark:text-[#fafafa] opacity-60 hover:opacity-80'}"
|
||||
onclick={() => {
|
||||
spotlightRecording = true
|
||||
spotlightShortcutInputEl?.focus()
|
||||
}}
|
||||
onkeydown={(e) => {
|
||||
if (spotlightRecording) handleSpotlightShortcutKeydown(e)
|
||||
}}
|
||||
onblur={() => {
|
||||
spotlightRecording = false
|
||||
}}
|
||||
>
|
||||
{#if spotlightRecording}
|
||||
<span class="text-[11px]">{$i18n.t('settings.general.pressShortcut')}</span>
|
||||
{:else if spotlightShortcutValue}
|
||||
{displayShortcut(spotlightShortcutValue)}
|
||||
{:else}
|
||||
<span class="opacity-40">{$i18n.t('common.disabled')}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{#if spotlightShortcutValue && !spotlightRecording}
|
||||
<button
|
||||
class="opacity-20 hover:opacity-50 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] p-0.5 shrink-0"
|
||||
onclick={async () => {
|
||||
spotlightShortcutValue = ''
|
||||
await window.electronAPI.setConfig({ spotlightShortcut: '' })
|
||||
config.set(await window.electronAPI.getConfig())
|
||||
}}
|
||||
>
|
||||
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="py-4 flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-[13px] opacity-70">Clipboard Auto-Paste</div>
|
||||
<div class="text-[11px] opacity-25 mt-0.5">Automatically paste clipboard contents into Spotlight</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={spotlightClipboardPaste}
|
||||
label="Toggle clipboard auto-paste"
|
||||
onchange={async (value) => {
|
||||
spotlightClipboardPaste = value
|
||||
await window.electronAPI.setConfig({ spotlightClipboardPaste: value })
|
||||
config.set(await window.electronAPI.getConfig())
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="py-4 flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-[13px] opacity-70">Voice Input</div>
|
||||
<div class="text-[11px] opacity-25 mt-0.5">Enable global push-to-talk voice transcription</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={voiceInputEnabled}
|
||||
label="Toggle voice input"
|
||||
onchange={async (value) => {
|
||||
voiceInputEnabled = value
|
||||
await window.electronAPI.setConfig({ voiceInputEnabled: value })
|
||||
config.set(await window.electronAPI.getConfig())
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if voiceInputEnabled}
|
||||
<div class="py-4 flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-[13px] opacity-70">Voice Input Shortcut</div>
|
||||
<div class="text-[11px] opacity-25 mt-0.5">
|
||||
{#if voiceInputRecording}
|
||||
Press a key combination…
|
||||
{:else}
|
||||
Toggle microphone recording from anywhere
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<button
|
||||
bind:this={voiceInputShortcutInputEl}
|
||||
class="text-[12px] px-3 py-1.5 border-none outline-none rounded-xl transition min-w-[80px] text-center
|
||||
{voiceInputRecording
|
||||
? 'bg-black/[0.08] dark:bg-white/[0.10] text-[#1d1d1f] dark:text-[#fafafa] opacity-80 animate-pulse'
|
||||
: 'bg-black/[0.04] dark:bg-white/[0.06] text-[#1d1d1f] dark:text-[#fafafa] opacity-60 hover:opacity-80'}"
|
||||
onclick={() => {
|
||||
voiceInputRecording = true
|
||||
voiceInputShortcutInputEl?.focus()
|
||||
}}
|
||||
onkeydown={(e) => {
|
||||
if (voiceInputRecording) handleVoiceInputShortcutKeydown(e)
|
||||
}}
|
||||
onblur={() => {
|
||||
voiceInputRecording = false
|
||||
}}
|
||||
>
|
||||
{#if voiceInputRecording}
|
||||
<span class="text-[11px]">Press keys…</span>
|
||||
{:else if voiceInputShortcutValue}
|
||||
{displayShortcut(voiceInputShortcutValue)}
|
||||
{:else}
|
||||
<span class="opacity-40">Disabled</span>
|
||||
{/if}
|
||||
</button>
|
||||
{#if voiceInputShortcutValue && !voiceInputRecording}
|
||||
<button
|
||||
class="opacity-20 hover:opacity-50 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] p-0.5 shrink-0"
|
||||
onclick={async () => {
|
||||
voiceInputShortcutValue = ''
|
||||
await window.electronAPI.setConfig({ voiceInputShortcut: '' })
|
||||
config.set(await window.electronAPI.getConfig())
|
||||
}}
|
||||
>
|
||||
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="py-4 flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-[13px] opacity-70">Call</div>
|
||||
<div class="text-[11px] opacity-25 mt-0.5">Enable global shortcut to start a voice/video call</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={callEnabled}
|
||||
label="Toggle call shortcut"
|
||||
onchange={async (value) => {
|
||||
callEnabled = value
|
||||
await window.electronAPI.setConfig({ callEnabled: value })
|
||||
config.set(await window.electronAPI.getConfig())
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if callEnabled}
|
||||
<div class="py-4 flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-[13px] opacity-70">Call Shortcut</div>
|
||||
<div class="text-[11px] opacity-25 mt-0.5">
|
||||
{#if callRecording}
|
||||
Press a key combination…
|
||||
{:else}
|
||||
Start a call from anywhere
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<button
|
||||
bind:this={callShortcutInputEl}
|
||||
class="text-[12px] px-3 py-1.5 border-none outline-none rounded-xl transition min-w-[80px] text-center
|
||||
{callRecording
|
||||
? 'bg-black/[0.08] dark:bg-white/[0.10] text-[#1d1d1f] dark:text-[#fafafa] opacity-80 animate-pulse'
|
||||
: 'bg-black/[0.04] dark:bg-white/[0.06] text-[#1d1d1f] dark:text-[#fafafa] opacity-60 hover:opacity-80'}"
|
||||
onclick={() => {
|
||||
callRecording = true
|
||||
callShortcutInputEl?.focus()
|
||||
}}
|
||||
onkeydown={(e) => {
|
||||
if (callRecording) handleCallShortcutKeydown(e)
|
||||
}}
|
||||
onblur={() => {
|
||||
callRecording = false
|
||||
}}
|
||||
>
|
||||
{#if callRecording}
|
||||
<span class="text-[11px]">Press keys…</span>
|
||||
{:else if callShortcutValue}
|
||||
{displayShortcut(callShortcutValue)}
|
||||
{:else}
|
||||
<span class="opacity-40">Disabled</span>
|
||||
{/if}
|
||||
</button>
|
||||
{#if callShortcutValue && !callRecording}
|
||||
<button
|
||||
class="opacity-20 hover:opacity-50 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] p-0.5 shrink-0"
|
||||
onclick={async () => {
|
||||
callShortcutValue = ''
|
||||
await window.electronAPI.setConfig({ callShortcut: '' })
|
||||
config.set(await window.electronAPI.getConfig())
|
||||
}}
|
||||
>
|
||||
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Advanced (collapsed by default) -->
|
||||
<div class="py-4">
|
||||
<button
|
||||
@@ -319,7 +700,43 @@
|
||||
</button>
|
||||
|
||||
{#if advancedOpen}
|
||||
<div class="flex flex-col divide-y divide-white/[0.04] mt-1">
|
||||
<div class="flex flex-col divide-y divide-white/[0.04] mt-1">
|
||||
<!-- Install location -->
|
||||
<div class="py-4 flex items-center justify-between gap-4">
|
||||
<div class="shrink-0">
|
||||
<div class="text-[13px] opacity-70">{$i18n.t('settings.general.installLocation')}</div>
|
||||
<div class="text-[11px] opacity-25 mt-0.5">{$i18n.t('settings.general.installLocationDesc')}</div>
|
||||
<div class="text-[10px] opacity-15 mt-0.5">{$i18n.t('settings.general.installLocationNote')}</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 min-w-0 flex-1 max-w-[280px] justify-end">
|
||||
<input
|
||||
type="text"
|
||||
class="bg-black/[0.04] dark:bg-white/[0.06] text-[12px] text-[#1d1d1f] dark:text-[#fafafa] px-3 py-1.5 border-none outline-none rounded-xl opacity-60 min-w-0 flex-1 text-right font-mono"
|
||||
placeholder={defaultInstallDir || 'Default'}
|
||||
value={installDirPath === defaultInstallDir ? '' : installDirPath}
|
||||
onchange={async (e) => {
|
||||
const val = (e.target as HTMLInputElement).value.trim()
|
||||
installDirPath = val || defaultInstallDir
|
||||
await window.electronAPI.setConfig({ installDir: val })
|
||||
config.set(await window.electronAPI.getConfig())
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
class="shrink-0 text-[12px] opacity-40 hover:opacity-70 px-2.5 py-1.5 bg-black/[0.04] dark:bg-white/[0.06] transition border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-xl"
|
||||
onclick={async () => {
|
||||
const folder = await window.electronAPI.selectFolder()
|
||||
if (folder) {
|
||||
installDirPath = folder
|
||||
await window.electronAPI.setConfig({ installDir: folder })
|
||||
config.set(await window.electronAPI.getConfig())
|
||||
}
|
||||
}}
|
||||
>
|
||||
{$i18n.t('common.browse')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Environment variables -->
|
||||
<div class="py-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
|
||||
@@ -429,7 +429,7 @@
|
||||
</div>
|
||||
<Switch
|
||||
checked={$config?.llamaCpp?.enabled ?? false}
|
||||
onToggle={() => updateConfig('enabled', !($config?.llamaCpp?.enabled ?? false))}
|
||||
onchange={(value) => updateConfig('enabled', value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -41,8 +41,10 @@
|
||||
let repoFiles = $state<HfFileInfo[]>([])
|
||||
let loadingFiles = $state(false)
|
||||
|
||||
// Download state — track active download in the "Downloaded" section
|
||||
let activeDownload = $state<{ repo: string; filename: string; percent: number } | null>(null)
|
||||
// Download state — track active downloads in the "Downloaded" section
|
||||
let activeDownloads = $state<Map<string, { repo: string; filename: string; percent: number }>>(new Map())
|
||||
|
||||
const dlKey = (repo: string, filename: string): string => `${repo}/${filename}`
|
||||
|
||||
onMount(async () => {
|
||||
models = await window.electronAPI.listHfModels()
|
||||
@@ -52,15 +54,22 @@
|
||||
window.electronAPI.onData((data: any) => {
|
||||
if (data.type === 'status:huggingface-download') {
|
||||
const d = data.data
|
||||
const key = dlKey(d.repo, d.filename)
|
||||
if (d?.status === 'downloading') {
|
||||
activeDownload = { repo: d.repo, filename: d.filename, percent: d.percent ?? 0 }
|
||||
const updated = new Map(activeDownloads)
|
||||
updated.set(key, { repo: d.repo, filename: d.filename, percent: d.percent ?? 0 })
|
||||
activeDownloads = updated
|
||||
}
|
||||
if (d?.status === 'done') {
|
||||
activeDownload = null
|
||||
const updated = new Map(activeDownloads)
|
||||
updated.delete(key)
|
||||
activeDownloads = updated
|
||||
window.electronAPI.listHfModels().then((m: HfModel[]) => { models = m })
|
||||
}
|
||||
if (d?.status === 'failed') {
|
||||
activeDownload = null
|
||||
const updated = new Map(activeDownloads)
|
||||
updated.delete(key)
|
||||
activeDownloads = updated
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -109,22 +118,29 @@
|
||||
}
|
||||
|
||||
const startDownload = async (repo: string, filename: string, size?: number) => {
|
||||
activeDownload = { repo, filename, percent: 0 }
|
||||
const key = dlKey(repo, filename)
|
||||
const updated = new Map(activeDownloads)
|
||||
updated.set(key, { repo, filename, percent: 0 })
|
||||
activeDownloads = updated
|
||||
try {
|
||||
await window.electronAPI.downloadHfModel(repo, filename, undefined, size)
|
||||
} catch (e) {
|
||||
console.error('Failed to download model:', e)
|
||||
activeDownload = null
|
||||
const cleaned = new Map(activeDownloads)
|
||||
cleaned.delete(key)
|
||||
activeDownloads = cleaned
|
||||
}
|
||||
}
|
||||
|
||||
const cancelDownload = async () => {
|
||||
const cancelDownload = async (repo: string, filename: string) => {
|
||||
try {
|
||||
await window.electronAPI.cancelHfDownload()
|
||||
await window.electronAPI.cancelHfDownload(repo, filename)
|
||||
} catch (e) {
|
||||
console.error('Failed to cancel download:', e)
|
||||
}
|
||||
activeDownload = null
|
||||
const updated = new Map(activeDownloads)
|
||||
updated.delete(dlKey(repo, filename))
|
||||
activeDownloads = updated
|
||||
}
|
||||
|
||||
const removeModel = async (repo: string, filename: string) => {
|
||||
@@ -143,9 +159,15 @@
|
||||
}
|
||||
|
||||
const isDownloading = (repo: string, filename: string): boolean => {
|
||||
return activeDownload?.repo === repo && activeDownload?.filename === filename
|
||||
return activeDownloads.has(dlKey(repo, filename))
|
||||
}
|
||||
|
||||
const getDownloadPercent = (repo: string, filename: string): number => {
|
||||
return activeDownloads.get(dlKey(repo, filename))?.percent ?? 0
|
||||
}
|
||||
|
||||
const hasActiveDownloads = $derived(activeDownloads.size > 0)
|
||||
|
||||
const formatSize = (bytes: number): string => {
|
||||
if (!bytes) return ''
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
@@ -180,50 +202,50 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Downloaded models + active download -->
|
||||
<!-- Downloaded models + active downloads -->
|
||||
<div class="py-4">
|
||||
<div class="text-[12px] opacity-50 mb-2">{$i18n.t('settings.models.downloadedModels')}</div>
|
||||
|
||||
{#if models.length > 0 || activeDownload}
|
||||
<div class="flex flex-col gap-1.5">
|
||||
{#if models.length > 0 || hasActiveDownloads}
|
||||
<div class="flex flex-col">
|
||||
|
||||
<!-- Active download in progress -->
|
||||
{#if activeDownload}
|
||||
<div class="px-2.5 py-2 bg-black/[0.03] dark:bg-white/[0.04] rounded-xl">
|
||||
<div class="flex items-center justify-between gap-2 mb-1.5">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-[12px] opacity-60 truncate font-mono">{activeDownload.filename}</div>
|
||||
<div class="text-[10px] opacity-25 truncate">{activeDownload.repo} · {$i18n.t('common.downloading')}</div>
|
||||
<!-- Active downloads -->
|
||||
{#each [...activeDownloads.values()] as dl (dlKey(dl.repo, dl.filename))}
|
||||
<div class="flex items-center gap-3 py-2 group">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-[12px] opacity-60 truncate font-mono">{dl.filename}</span>
|
||||
<span class="text-[10px] opacity-30 font-mono shrink-0">{dl.percent.toFixed(1)}%</span>
|
||||
</div>
|
||||
<button
|
||||
class="opacity-30 hover:opacity-70 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] p-1 shrink-0"
|
||||
onclick={cancelDownload}
|
||||
title={$i18n.t('settings.models.cancelDownload')}
|
||||
>
|
||||
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
<div class="mt-1.5 w-full h-[3px] bg-black/[0.06] dark:bg-white/[0.06] rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-emerald-400/70 rounded-full transition-[width] duration-300"
|
||||
style="width: {dl.percent}%"
|
||||
></div>
|
||||
</div>
|
||||
<div class="text-[10px] opacity-20 mt-1 truncate">{dl.repo}</div>
|
||||
</div>
|
||||
<div class="w-full h-1 bg-black/[0.06] dark:bg-white/[0.06] rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-emerald-400/80 rounded-full"
|
||||
style="width: {activeDownload.percent}%"
|
||||
></div>
|
||||
</div>
|
||||
<div class="text-[10px] opacity-25 mt-1 text-right font-mono">{activeDownload.percent.toFixed(1)}%</div>
|
||||
<button
|
||||
class="opacity-0 group-hover:opacity-40 hover:!opacity-70 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] p-1 shrink-0"
|
||||
onclick={() => cancelDownload(dl.repo, dl.filename)}
|
||||
title={$i18n.t('settings.models.cancelDownload')}
|
||||
>
|
||||
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<!-- Completed downloads -->
|
||||
{#each models as model}
|
||||
<div class="flex items-center justify-between gap-2 px-2.5 py-2 bg-black/[0.03] dark:bg-white/[0.04] rounded-xl">
|
||||
<div class="flex items-center gap-3 py-2 group">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-[12px] opacity-60 truncate font-mono">{model.filename}</div>
|
||||
<div class="text-[10px] opacity-25 truncate">{model.repo} · {formatSize(model.size)}</div>
|
||||
<div class="text-[10px] opacity-20 truncate mt-0.5">{model.repo} · {formatSize(model.size)}</div>
|
||||
</div>
|
||||
<button
|
||||
class="opacity-20 hover:opacity-60 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] p-1 shrink-0 {deleting === `${model.repo}/${model.filename}` ? 'pointer-events-none' : ''}"
|
||||
class="opacity-0 group-hover:opacity-30 hover:!opacity-60 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] p-1 shrink-0 {deleting === `${model.repo}/${model.filename}` ? '!opacity-30 pointer-events-none' : ''}"
|
||||
onclick={() => removeModel(model.repo, model.filename)}
|
||||
title={$i18n.t('settings.models.deleteModel')}
|
||||
>
|
||||
@@ -239,7 +261,7 @@
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-[11px] opacity-40 py-3">{$i18n.t('settings.models.noModels')}</div>
|
||||
<div class="text-[11px] opacity-20 py-3">{$i18n.t('settings.models.noModels')}</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -248,13 +270,13 @@
|
||||
<div class="text-[12px] opacity-50 mb-2">
|
||||
{#if selectedRepo}
|
||||
<button
|
||||
class="opacity-50 hover:opacity-80 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] p-0 text-[12px] flex items-center gap-1"
|
||||
class="opacity-70 hover:opacity-100 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] p-0 text-[12px] flex items-center gap-1 font-mono truncate"
|
||||
onclick={backToSearch}
|
||||
>
|
||||
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<svg class="w-3 h-3 shrink-0 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
||||
</svg>
|
||||
{$i18n.t('common.back')}
|
||||
<span class="truncate">{selectedRepo}</span>
|
||||
</button>
|
||||
{:else}
|
||||
{$i18n.t('settings.models.downloadFromHF')}
|
||||
@@ -263,10 +285,6 @@
|
||||
|
||||
{#if selectedRepo}
|
||||
<!-- Repo file browser -->
|
||||
<div class="mb-2">
|
||||
<div class="text-[12px] opacity-60 font-mono truncate mb-2">{selectedRepo}</div>
|
||||
</div>
|
||||
|
||||
{#if loadingFiles}
|
||||
<div class="flex items-center gap-2 py-3 justify-center">
|
||||
<div class="w-3 h-3 rounded-full border-[1.5px] border-black/20 dark:border-white/30 border-t-transparent animate-spin"></div>
|
||||
@@ -275,27 +293,42 @@
|
||||
{:else if repoFiles.length === 0}
|
||||
<div class="text-[11px] opacity-20 text-center py-3">{$i18n.t('settings.models.noGgufFiles')}</div>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex flex-col">
|
||||
{#each repoFiles as file}
|
||||
{@const downloaded = isDownloaded(selectedRepo, file.filename)}
|
||||
{@const dlActive = isDownloading(selectedRepo, file.filename)}
|
||||
<div class="flex items-center justify-between gap-2 px-2.5 py-2 bg-black/[0.03] dark:bg-white/[0.04] rounded-xl">
|
||||
<div class="flex items-center gap-3 py-2 group">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-[12px] opacity-50 truncate font-mono">{file.filename}</div>
|
||||
<div class="text-[10px] opacity-25">{formatSize(file.size)}</div>
|
||||
<div class="text-[10px] opacity-20 mt-0.5">{formatSize(file.size)}</div>
|
||||
{#if dlActive}
|
||||
<div class="mt-1.5 w-full h-[3px] bg-black/[0.06] dark:bg-white/[0.06] rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-emerald-400/70 rounded-full transition-[width] duration-300"
|
||||
style="width: {getDownloadPercent(selectedRepo, file.filename)}%"
|
||||
></div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if downloaded}
|
||||
<span class="text-[10px] opacity-30 shrink-0 px-2">{$i18n.t('settings.models.downloaded')}</span>
|
||||
<span class="text-[10px] opacity-25 shrink-0">{$i18n.t('settings.models.downloaded')}</span>
|
||||
{:else if dlActive}
|
||||
<div class="flex items-center gap-1.5 shrink-0">
|
||||
<div class="w-2.5 h-2.5 rounded-full border-[1.5px] border-black/20 dark:border-white/30 border-t-transparent animate-spin"></div>
|
||||
<span class="text-[10px] opacity-40 font-mono">{activeDownload?.percent?.toFixed(0) ?? 0}%</span>
|
||||
<span class="text-[10px] opacity-40 font-mono">{getDownloadPercent(selectedRepo, file.filename).toFixed(0)}%</span>
|
||||
<button
|
||||
class="opacity-30 hover:opacity-70 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] p-0.5"
|
||||
onclick={() => cancelDownload(selectedRepo, file.filename)}
|
||||
title={$i18n.t('settings.models.cancelDownload')}
|
||||
>
|
||||
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
class="opacity-30 hover:opacity-70 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] p-1 shrink-0 {activeDownload ? 'pointer-events-none opacity-10' : ''}"
|
||||
class="opacity-0 group-hover:opacity-40 hover:!opacity-70 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] p-1 shrink-0"
|
||||
onclick={() => startDownload(selectedRepo, file.filename, file.size)}
|
||||
disabled={!!activeDownload}
|
||||
title={$i18n.t('common.download')}
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
@@ -329,10 +362,10 @@
|
||||
{#if searchError}
|
||||
<div class="text-[11px] text-red-400/70 text-center py-2">{searchError}</div>
|
||||
{:else if searchResults.length > 0}
|
||||
<div class="flex flex-col gap-1 max-h-[300px] overflow-y-auto">
|
||||
<div class="flex flex-col max-h-[300px] overflow-y-auto">
|
||||
{#each searchResults as repo}
|
||||
<button
|
||||
class="flex items-center justify-between gap-2 px-2.5 py-2 bg-black/[0.03] dark:bg-white/[0.04] hover:bg-black/[0.06] dark:hover:bg-white/[0.08] rounded-xl transition border-none text-left w-full text-[#1d1d1f] dark:text-[#fafafa]"
|
||||
class="flex items-center justify-between gap-2 py-2 hover:bg-black/[0.03] dark:hover:bg-white/[0.04] rounded-lg transition border-none text-left w-full text-[#1d1d1f] dark:text-[#fafafa] bg-transparent px-1"
|
||||
onclick={() => selectRepo(repo.id)}
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
@@ -352,7 +385,7 @@
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<svg class="w-3 h-3 opacity-20 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<svg class="w-3 h-3 opacity-15 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
@@ -34,7 +34,6 @@
|
||||
type: 'remote',
|
||||
url: u
|
||||
})
|
||||
connections.set(await window.electronAPI.getConnections())
|
||||
config.set(await window.electronAPI.getConfig())
|
||||
url = ''; name = ''; view = 'list'
|
||||
} catch { error = $i18n.t('setup.connectionManager.failed') }
|
||||
@@ -48,10 +47,8 @@
|
||||
}
|
||||
const remove = async (id: string) => {
|
||||
await window.electronAPI.removeConnection(id)
|
||||
const conns = await window.electronAPI.getConnections()
|
||||
connections.set(conns)
|
||||
config.set(await window.electronAPI.getConfig())
|
||||
if (conns.length === 0) appState.set('setup')
|
||||
if (($connections ?? []).length === 0) appState.set('setup')
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { fade, fly } from 'svelte/transition'
|
||||
import { onMount } from 'svelte'
|
||||
import { connections, config, serverInfo } from '../../stores'
|
||||
import { config, serverInfo } from '../../stores'
|
||||
import i18n from '../../i18n'
|
||||
|
||||
import logoImage from '../../assets/images/splash.png'
|
||||
@@ -10,24 +10,30 @@
|
||||
|
||||
let phase = $state(autoStart ? 'working' : 'ready') // ready | working | done | error
|
||||
let errorMsg = $state('')
|
||||
let installDir = $state('')
|
||||
let defaultInstallDir = $state('')
|
||||
|
||||
onMount(async () => {
|
||||
defaultInstallDir = await window.electronAPI.getInstallDir()
|
||||
installDir = defaultInstallDir
|
||||
if (autoStart) install()
|
||||
})
|
||||
|
||||
const install = async () => {
|
||||
phase = 'working'
|
||||
try {
|
||||
// Save custom install directory before installing
|
||||
if (installDir && installDir !== defaultInstallDir) {
|
||||
await window.electronAPI.setConfig({ installDir })
|
||||
}
|
||||
|
||||
const ok = await window.electronAPI.installPackage()
|
||||
if (!ok) { phase = 'error'; errorMsg = $i18n.t('setup.install.failed'); return }
|
||||
|
||||
await window.electronAPI.startServer()
|
||||
const info = await window.electronAPI.getServerInfo()
|
||||
|
||||
await window.electronAPI.addConnection({
|
||||
id: 'local',
|
||||
name: 'Local',
|
||||
type: 'local',
|
||||
url: info?.url || 'http://127.0.0.1:8080'
|
||||
})
|
||||
await window.electronAPI.setDefaultConnection('local')
|
||||
connections.set(await window.electronAPI.getConnections())
|
||||
config.set(await window.electronAPI.getConfig())
|
||||
|
||||
phase = 'done'
|
||||
@@ -41,9 +47,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (autoStart) install()
|
||||
})
|
||||
const changeInstallDir = async () => {
|
||||
const folder = await window.electronAPI.selectFolder()
|
||||
if (folder) {
|
||||
installDir = folder
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col" in:fade={{ duration: 200 }}>
|
||||
@@ -58,10 +67,30 @@
|
||||
{#if phase === 'ready'}
|
||||
<div class="mb-1 text-sm font-normal opacity-50">{$i18n.t('app.name')}</div>
|
||||
<h1 class="text-2xl font-light tracking-tight mb-2">{$i18n.t('setup.install.title')}</h1>
|
||||
<p class="text-[12px] opacity-30 mb-8 leading-relaxed">
|
||||
<p class="text-[12px] opacity-30 mb-6 leading-relaxed">
|
||||
{$i18n.t('setup.install.description')}
|
||||
</p>
|
||||
|
||||
<!-- Install location -->
|
||||
<div class="mb-6">
|
||||
<div class="text-[11px] opacity-40 mb-1.5">{$i18n.t('setup.install.installLocation')}</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="flex-1 min-w-0 px-3 py-2 bg-black/[0.04] dark:bg-white/[0.06] text-[12px] text-[#1d1d1f] dark:text-[#fafafa] opacity-50 font-mono truncate rounded-lg"
|
||||
title={installDir}
|
||||
>
|
||||
{installDir || '…'}
|
||||
</div>
|
||||
<button
|
||||
class="shrink-0 text-[11px] opacity-40 hover:opacity-70 px-2.5 py-2 bg-black/[0.04] dark:bg-white/[0.06] transition border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-lg"
|
||||
onclick={changeInstallDir}
|
||||
>
|
||||
{$i18n.t('setup.install.changeLocation')}
|
||||
</button>
|
||||
</div>
|
||||
<div class="text-[10px] opacity-20 mt-1">{$i18n.t('setup.install.installLocationDesc')}</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="w-fit inline-flex items-center gap-2 bg-white px-8 py-2.5 text-black text-[13px] transition hover:bg-gray-100 border-none"
|
||||
onclick={install}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import { fly, fade } from 'svelte/transition'
|
||||
import { appState, connections, config } from '../../stores'
|
||||
import { appState, config } from '../../stores'
|
||||
import i18n from '../../i18n'
|
||||
import LocalInstall from './LocalInstall.svelte'
|
||||
|
||||
@@ -30,16 +30,15 @@
|
||||
try {
|
||||
const valid = await window.electronAPI.validateUrl(u)
|
||||
if (!valid) { error = $i18n.t('setup.couldNotReachServer'); connecting = false; return }
|
||||
const connId = crypto.randomUUID()
|
||||
await window.electronAPI.addConnection({
|
||||
id: crypto.randomUUID(),
|
||||
id: connId,
|
||||
name: new URL(u).hostname,
|
||||
type: 'remote',
|
||||
url: u
|
||||
})
|
||||
connections.set(await window.electronAPI.getConnections())
|
||||
config.set(await window.electronAPI.getConfig())
|
||||
const conns = await window.electronAPI.getConnections()
|
||||
await window.electronAPI.connectTo(conns[conns.length - 1].id)
|
||||
await window.electronAPI.connectTo(connId)
|
||||
appState.set('ready')
|
||||
} catch {
|
||||
error = $i18n.t('setup.connectionFailed')
|
||||
|
||||
@@ -55,6 +55,9 @@
|
||||
"setup.couldNotReachServer": "Could not reach this server",
|
||||
"setup.invalidUrl": "Please enter a valid URL",
|
||||
"setup.connectionFailed": "Connection failed",
|
||||
"setup.couldNotLoadPage": "Could not load this page",
|
||||
"setup.pageCrashed": "This page crashed unexpectedly",
|
||||
"setup.openInBrowser": "Open in Browser",
|
||||
"setup.urlPlaceholder": "e.g. https://your-server.com",
|
||||
"setup.preparingEnvironment": "Preparing environment…",
|
||||
"setup.settingUp": "Setting up…",
|
||||
@@ -66,6 +69,9 @@
|
||||
"setup.install.mightTakeMinutes": "This might take a few minutes",
|
||||
"setup.install.failed": "Install failed",
|
||||
"setup.install.somethingWentWrong": "Something went wrong",
|
||||
"setup.install.installLocation": "Install location",
|
||||
"setup.install.installLocationDesc": "Where data, models, and runtimes will be stored",
|
||||
"setup.install.changeLocation": "Change",
|
||||
|
||||
"error.notEnoughDiskSpace": "Not enough disk space",
|
||||
"error.diskSpaceDetail": "At least 5 GB is required. Only {{available}} GB available.",
|
||||
@@ -152,6 +158,8 @@
|
||||
"settings.general.globalShortcutDesc": "Bring the app to the foreground from anywhere",
|
||||
"settings.general.globalShortcutRecording": "Press a key combination… (Esc to cancel, Del to clear)",
|
||||
"settings.general.pressShortcut": "Press shortcut…",
|
||||
"settings.general.spotlightShortcut": "Spotlight shortcut",
|
||||
"settings.general.spotlightShortcutDesc": "Open the quick-chat input bar from anywhere",
|
||||
"settings.general.appearance": "Appearance",
|
||||
"settings.general.appearanceDesc": "Choose light, dark, or system default",
|
||||
"settings.general.language": "Language",
|
||||
@@ -167,6 +175,9 @@
|
||||
"settings.general.light": "Light",
|
||||
"settings.general.dark": "Dark",
|
||||
"settings.general.keyPlaceholder": "KEY",
|
||||
"settings.general.installLocation": "Install location",
|
||||
"settings.general.installLocationDesc": "Root directory for Python, models, and runtimes",
|
||||
"settings.general.installLocationNote": "Changing this requires reinstalling components",
|
||||
|
||||
"settings.openwebui.notInstalled": "Not installed",
|
||||
"settings.openwebui.notInstalledDesc": "Set up a local Open WebUI server",
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { mount } from 'svelte'
|
||||
import Spotlight from './components/Spotlight.svelte'
|
||||
|
||||
const app = mount(Spotlight, {
|
||||
target: document.getElementById('app')!
|
||||
})
|
||||
|
||||
export default app
|
||||
@@ -0,0 +1,8 @@
|
||||
import { mount } from 'svelte'
|
||||
import VoiceInput from './components/VoiceInput.svelte'
|
||||
|
||||
const app = mount(VoiceInput, {
|
||||
target: document.getElementById('app')!
|
||||
})
|
||||
|
||||
export default app
|
||||
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Open WebUI – Voice Input</title>
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/voice-input-main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user