mirror of
https://github.com/open-webui/desktop.git
synced 2026-07-13 20:41:47 -04:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5aa13c813e | |||
| 14258c4b36 | |||
| a2f6de45f5 | |||
| 3b3349d3b5 | |||
| b099a4a6fa | |||
| 4920e90bef | |||
| 6852b4f83e | |||
| a29f1fe1f1 | |||
| 4076e12976 | |||
| e9f8c89cc9 |
@@ -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
|
||||
@@ -29,14 +56,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 only) ──
|
||||
- name: Cache Flatpak SDKs
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
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: matrix.os == 'ubuntu-latest'
|
||||
continue-on-error: true
|
||||
run: |
|
||||
sudo apt-get update
|
||||
@@ -45,9 +86,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 }}
|
||||
@@ -67,39 +109,54 @@ jobs:
|
||||
security import $CERTIFICATE_PATH -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
|
||||
security list-keychain -d user -s $KEYCHAIN_PATH
|
||||
|
||||
# Build commands — 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: matrix.os == 'windows-latest'
|
||||
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: matrix.os == 'windows-latest' && 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_signed
|
||||
if: matrix.os == 'macos-latest' && steps.apple_cert.outcome == 'success'
|
||||
continue-on-error: true
|
||||
env:
|
||||
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 }}
|
||||
ELECTRON_BUILDER_BINARIES_MIRROR: https://github.com/electron-userland/electron-builder-binaries/releases/download/
|
||||
run: npx electron-builder --mac --${{ matrix.arch }} -c.mac.identity=auto -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_signed.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' }}
|
||||
ELECTRON_BUILDER_BINARIES_MIRROR: https://github.com/electron-userland/electron-builder-binaries/releases/download/
|
||||
run: |
|
||||
rm -rf dist/
|
||||
npx electron-builder --mac --${{ matrix.arch }} -c.mac.notarize=false --publish never
|
||||
|
||||
- name: Package for Linux
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: |
|
||||
npm run build
|
||||
if [ "${{ steps.flatpak.outcome }}" == "success" ]; then
|
||||
npx electron-builder --linux --${{ matrix.arch }}
|
||||
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: matrix.os == 'windows-latest'
|
||||
continue-on-error: true
|
||||
uses: azure/trusted-signing-action@v0.5.1
|
||||
with:
|
||||
@@ -112,12 +169,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 +190,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 +233,12 @@ jobs:
|
||||
|
||||
- name: Download Artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
|
||||
- name: List downloaded artifacts
|
||||
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
|
||||
with:
|
||||
pattern: '*-*'
|
||||
merge-multiple: false
|
||||
|
||||
- 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:
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
electron_mirror=https://npmmirror.com/mirrors/electron/
|
||||
electron_builder_binaries_mirror=https://npmmirror.com/mirrors/electron-builder-binaries/
|
||||
|
||||
|
||||
@@ -5,6 +5,30 @@ 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).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.0.3] - 2026-04-06
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Spotlight Focus** — Spotlight now reliably appears after interacting with the main window or webview (fixed blur-during-show race condition on macOS)
|
||||
- **Spotlight Query Passthrough** — The `?q=` search parameter from spotlight now correctly navigates already-open webviews 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 the 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 to config and restored across app restarts
|
||||
- **Spotlight Settings** — Shortcut recorder in Settings → General for the spotlight shortcut
|
||||
|
||||
|
||||
### Fixed
|
||||
|
||||
- **System Theme Sync** — App now listens for 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
|
||||
|
||||
@@ -26,13 +26,13 @@ mac:
|
||||
target:
|
||||
- dmg
|
||||
- zip
|
||||
identity: null
|
||||
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}
|
||||
|
||||
+10
-1
@@ -14,12 +14,21 @@ 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')
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
renderer: {
|
||||
build: {
|
||||
rollupOptions: {
|
||||
input: {
|
||||
index: resolve(__dirname, 'src/renderer/index.html'),
|
||||
spotlight: resolve(__dirname, 'src/renderer/spotlight.html')
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: [tailwindcss(), svelte()]
|
||||
}
|
||||
})
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "open-webui",
|
||||
"version": "0.0.1",
|
||||
"version": "0.0.3",
|
||||
"license": "AGPL-3.0",
|
||||
"description": "Open WebUI Desktop",
|
||||
"main": "./out/main/index.js",
|
||||
|
||||
+213
-23
@@ -94,6 +94,7 @@ if (process.platform === 'linux') {
|
||||
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
let contentWindow: BrowserWindow | null = null
|
||||
let spotlightWindow: BrowserWindow | null = null
|
||||
let tray: Tray | null = null
|
||||
let isQuiting = false
|
||||
|
||||
@@ -103,23 +104,170 @@ let SERVER_STATUS: string | null = null
|
||||
let SERVER_REACHABLE = false
|
||||
let SERVER_PID: number | null = null
|
||||
|
||||
// ─── Global Shortcut ────────────────────────────────────
|
||||
// ─── Global Shortcuts ───────────────────────────────────
|
||||
|
||||
const registerGlobalShortcut = (accelerator?: string): void => {
|
||||
const registerShortcuts = (globalAccel?: string, spotlightAccel?: string): void => {
|
||||
globalShortcut.unregisterAll()
|
||||
if (!accelerator) return
|
||||
try {
|
||||
globalShortcut.register(accelerator, () => {
|
||||
if (contentWindow && !contentWindow.isDestroyed()) {
|
||||
contentWindow.show()
|
||||
contentWindow.focus()
|
||||
} else {
|
||||
mainWindow?.show()
|
||||
mainWindow?.focus()
|
||||
}
|
||||
|
||||
// Global shortcut – bring main window to foreground
|
||||
if (globalAccel) {
|
||||
try {
|
||||
globalShortcut.register(globalAccel, () => {
|
||||
if (mainWindow) {
|
||||
mainWindow.show()
|
||||
mainWindow.focus()
|
||||
} else {
|
||||
createMainWindow()
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
log.warn('Failed to register global shortcut:', globalAccel, error)
|
||||
}
|
||||
}
|
||||
|
||||
// Spotlight shortcut – toggle the spotlight input bar
|
||||
if (spotlightAccel) {
|
||||
try {
|
||||
globalShortcut.register(spotlightAccel, () => {
|
||||
toggleSpotlight()
|
||||
})
|
||||
} catch (error) {
|
||||
log.warn('Failed to register spotlight shortcut:', spotlightAccel, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Spotlight Window ───────────────────────────────────
|
||||
|
||||
// Remember where the user dragged the spotlight
|
||||
let spotlightPosition: { x: number; y: number } | null = null
|
||||
|
||||
// Load persisted spotlight position from config (call after CONFIG is loaded)
|
||||
function loadSpotlightPosition(): void {
|
||||
if (CONFIG?.spotlightPosition) {
|
||||
spotlightPosition = { ...CONFIG.spotlightPosition }
|
||||
}
|
||||
}
|
||||
|
||||
function getDefaultSpotlightPosition(): { x: number; y: number } {
|
||||
const { screen } = require('electron')
|
||||
const cursorPoint = screen.getCursorScreenPoint()
|
||||
const activeDisplay = screen.getDisplayNearestPoint(cursorPoint)
|
||||
const { width: screenW } = activeDisplay.workAreaSize
|
||||
const { x: screenX, y: screenY } = activeDisplay.workArea
|
||||
const winW = 748
|
||||
return {
|
||||
x: Math.round(screenX + (screenW - winW) / 2),
|
||||
y: Math.round(screenY + 160)
|
||||
}
|
||||
}
|
||||
|
||||
function createSpotlightWindow(): BrowserWindow {
|
||||
const pos = spotlightPosition || getDefaultSpotlightPosition()
|
||||
|
||||
const winW = 748
|
||||
const winH = 86
|
||||
|
||||
spotlightWindow = new BrowserWindow({
|
||||
width: winW,
|
||||
height: winH,
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
frame: false,
|
||||
transparent: true,
|
||||
alwaysOnTop: true,
|
||||
skipTaskbar: true,
|
||||
resizable: false,
|
||||
hasShadow: false,
|
||||
show: false,
|
||||
focusable: true,
|
||||
icon: path.join(__dirname, 'assets/icon.png'),
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/spotlight-preload.js'),
|
||||
sandbox: false,
|
||||
webviewTag: false
|
||||
}
|
||||
})
|
||||
|
||||
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
|
||||
spotlightWindow.loadURL(`${process.env['ELECTRON_RENDERER_URL']}/spotlight.html`)
|
||||
} else {
|
||||
spotlightWindow.loadFile(join(__dirname, '../renderer/spotlight.html'))
|
||||
}
|
||||
|
||||
// Save position when user drags to a new spot
|
||||
spotlightWindow.on('moved', () => {
|
||||
if (spotlightWindow && !spotlightWindow.isDestroyed()) {
|
||||
const [x, y] = spotlightWindow.getPosition()
|
||||
spotlightPosition = { x, y }
|
||||
// Persist to config for cross-session recall
|
||||
setConfig({ spotlightPosition: { x, y } }).catch((err) =>
|
||||
log.warn('Failed to persist spotlight position:', err)
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// Hide on blur — but only when the window was truly visible and settled.
|
||||
// Without the guard the blur fires during the show→focus transition and
|
||||
// the window disappears immediately (especially when the user had been
|
||||
// interacting with the main window / webview).
|
||||
let blurArmed = false
|
||||
spotlightWindow.on('focus', () => {
|
||||
blurArmed = false
|
||||
setTimeout(() => {
|
||||
blurArmed = true
|
||||
}, 200)
|
||||
})
|
||||
spotlightWindow.on('blur', () => {
|
||||
if (blurArmed) {
|
||||
spotlightWindow?.hide()
|
||||
}
|
||||
})
|
||||
|
||||
spotlightWindow.on('closed', () => {
|
||||
spotlightWindow = null
|
||||
})
|
||||
|
||||
return spotlightWindow
|
||||
}
|
||||
|
||||
function showAndFocusSpotlight(win: BrowserWindow): void {
|
||||
// On macOS the app may not be the "active" application when the global
|
||||
// shortcut fires (e.g. user clicked into a webview which is a separate
|
||||
// render process). Calling app.focus() first ensures macOS brings the
|
||||
// app to the foreground so the subsequent window.focus() actually works.
|
||||
if (process.platform === 'darwin') {
|
||||
app.focus({ steal: true })
|
||||
}
|
||||
|
||||
// Restore to saved position, or default if none saved
|
||||
if (spotlightPosition) {
|
||||
win.setPosition(spotlightPosition.x, spotlightPosition.y)
|
||||
} else {
|
||||
const pos = getDefaultSpotlightPosition()
|
||||
win.setPosition(pos.x, pos.y)
|
||||
}
|
||||
|
||||
win.show()
|
||||
win.focus()
|
||||
|
||||
// Tell the renderer to focus the input field (belt-and-suspenders —
|
||||
// the renderer also listens for the 'focus' window event)
|
||||
win.webContents.focus()
|
||||
}
|
||||
|
||||
function toggleSpotlight(): void {
|
||||
if (spotlightWindow && !spotlightWindow.isDestroyed()) {
|
||||
if (spotlightWindow.isVisible()) {
|
||||
spotlightWindow.hide()
|
||||
} else {
|
||||
showAndFocusSpotlight(spotlightWindow)
|
||||
}
|
||||
} else {
|
||||
const win = createSpotlightWindow()
|
||||
win.once('ready-to-show', () => {
|
||||
showAndFocusSpotlight(win)
|
||||
})
|
||||
} catch (error) {
|
||||
log.warn('Failed to register global shortcut:', accelerator, error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,10 +275,10 @@ const registerGlobalShortcut = (accelerator?: string): void => {
|
||||
|
||||
function createMainWindow(show = true): void {
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1100,
|
||||
height: 700,
|
||||
minWidth: 900,
|
||||
minHeight: 560,
|
||||
width: 1280,
|
||||
height: 800,
|
||||
minWidth: 1280,
|
||||
minHeight: 800,
|
||||
icon: path.join(__dirname, 'assets/icon.png'),
|
||||
show: false,
|
||||
titleBarStyle: process.platform === 'win32' ? 'default' : 'hidden',
|
||||
@@ -192,10 +340,10 @@ function createContentWindow(url: string, connectionId: string): BrowserWindow {
|
||||
}
|
||||
|
||||
contentWindow = new BrowserWindow({
|
||||
width: 1200,
|
||||
width: 1280,
|
||||
height: 800,
|
||||
minWidth: 900,
|
||||
minHeight: 560,
|
||||
minWidth: 1280,
|
||||
minHeight: 800,
|
||||
icon: path.join(__dirname, 'assets/icon.png'),
|
||||
show: false,
|
||||
titleBarStyle: process.platform === 'win32' ? 'default' : 'hidden',
|
||||
@@ -603,6 +751,7 @@ if (!gotTheLock) {
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
CONFIG = await getConfig()
|
||||
loadSpotlightPosition()
|
||||
log.info('Config:', CONFIG)
|
||||
|
||||
app.name = 'Open WebUI'
|
||||
@@ -649,7 +798,7 @@ if (!gotTheLock) {
|
||||
await setConfig(config)
|
||||
CONFIG = await getConfig()
|
||||
updateTray()
|
||||
registerGlobalShortcut(CONFIG.globalShortcut)
|
||||
registerShortcuts(CONFIG.globalShortcut, CONFIG.spotlightShortcut)
|
||||
})
|
||||
|
||||
// Python/uv
|
||||
@@ -803,6 +952,43 @@ if (!gotTheLock) {
|
||||
// Misc
|
||||
ipcMain.handle('app:reset', () => resetAppHandler())
|
||||
|
||||
// Spotlight
|
||||
ipcMain.handle('spotlight:submit', async (_event, query: string) => {
|
||||
const config = await getConfig()
|
||||
if (!config.defaultConnectionId || config.connections.length === 0) {
|
||||
// No default connection — just show main window
|
||||
mainWindow?.show()
|
||||
mainWindow?.focus()
|
||||
return
|
||||
}
|
||||
const conn = config.connections.find((c) => c.id === config.defaultConnectionId)
|
||||
if (!conn) {
|
||||
mainWindow?.show()
|
||||
mainWindow?.focus()
|
||||
return
|
||||
}
|
||||
|
||||
let url = conn.url
|
||||
if (conn.type === 'local' && SERVER_URL) {
|
||||
url = SERVER_URL
|
||||
}
|
||||
if (url.startsWith('http://0.0.0.0')) {
|
||||
url = url.replace('http://0.0.0.0', 'http://localhost')
|
||||
}
|
||||
|
||||
// Navigate to the connection URL with query
|
||||
const targetUrl = `${url}/?q=${encodeURIComponent(query)}`
|
||||
sendToRenderer('connection:open', { url: targetUrl, connectionId: conn.id })
|
||||
|
||||
// Show main window and hide spotlight
|
||||
mainWindow?.show()
|
||||
mainWindow?.focus()
|
||||
spotlightWindow?.hide()
|
||||
})
|
||||
ipcMain.handle('spotlight:close', () => {
|
||||
spotlightWindow?.hide()
|
||||
})
|
||||
|
||||
// Open Terminal
|
||||
ipcMain.handle('open-terminal:start', async () => {
|
||||
try {
|
||||
@@ -1058,7 +1244,7 @@ if (!gotTheLock) {
|
||||
|
||||
|
||||
// Global shortcut
|
||||
registerGlobalShortcut(CONFIG.globalShortcut)
|
||||
registerShortcuts(CONFIG.globalShortcut, CONFIG.spotlightShortcut)
|
||||
|
||||
// Enable screen capture
|
||||
session.defaultSession.setDisplayMediaRequestHandler(
|
||||
@@ -1145,6 +1331,10 @@ if (!gotTheLock) {
|
||||
globalShortcut.unregisterAll()
|
||||
mainWindow = null
|
||||
contentWindow = null
|
||||
if (spotlightWindow && !spotlightWindow.isDestroyed()) {
|
||||
spotlightWindow.destroy()
|
||||
}
|
||||
spotlightWindow = null
|
||||
tray?.destroy()
|
||||
tray = null
|
||||
})
|
||||
|
||||
@@ -780,6 +780,7 @@ export interface AppConfig {
|
||||
connections: Connection[]
|
||||
runInBackground: boolean
|
||||
globalShortcut: string
|
||||
spotlightShortcut: string
|
||||
dataDir: string
|
||||
localServer: {
|
||||
port: number
|
||||
@@ -799,6 +800,8 @@ export interface AppConfig {
|
||||
extraArgs: string[]
|
||||
}
|
||||
envVars: Record<string, string>
|
||||
showSidebar: boolean
|
||||
spotlightPosition: { x: number; y: number } | null
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: AppConfig = {
|
||||
@@ -807,6 +810,7 @@ const DEFAULT_CONFIG: AppConfig = {
|
||||
connections: [],
|
||||
runInBackground: true,
|
||||
globalShortcut: 'Alt+CommandOrControl+O',
|
||||
spotlightShortcut: 'Shift+CommandOrControl+I',
|
||||
dataDir: '',
|
||||
localServer: {
|
||||
port: 8080,
|
||||
@@ -823,7 +827,9 @@ const DEFAULT_CONFIG: AppConfig = {
|
||||
variant: 'cpu',
|
||||
extraArgs: []
|
||||
},
|
||||
envVars: {}
|
||||
envVars: {},
|
||||
showSidebar: true,
|
||||
spotlightPosition: null
|
||||
}
|
||||
|
||||
export const getConfig = async (): Promise<AppConfig> => {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { ipcRenderer, contextBridge } from 'electron'
|
||||
|
||||
const api = {
|
||||
submitQuery: (query: string): void => {
|
||||
ipcRenderer.invoke('spotlight:submit', query)
|
||||
},
|
||||
closeSpotlight: (): void => {
|
||||
ipcRenderer.invoke('spotlight:close')
|
||||
}
|
||||
}
|
||||
|
||||
if (process.contextIsolated) {
|
||||
try {
|
||||
contextBridge.exposeInMainWorld('spotlightAPI', api)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
} else {
|
||||
// @ts-ignore
|
||||
window.spotlightAPI = api
|
||||
}
|
||||
@@ -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,182 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import logoImage from '../lib/assets/images/splash.png'
|
||||
|
||||
let inputEl = $state<HTMLInputElement | null>(null)
|
||||
let query = $state('')
|
||||
|
||||
const api = window.spotlightAPI
|
||||
|
||||
const submit = () => {
|
||||
const q = query.trim()
|
||||
if (!q || !api) return
|
||||
api.submitQuery(q)
|
||||
query = ''
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
inputEl?.focus()
|
||||
window.addEventListener('focus', () => {
|
||||
query = ''
|
||||
inputEl?.focus()
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<svelte:window on:keydown={(e) => e.key === 'Escape' && api?.closeSpotlight()} />
|
||||
|
||||
<div class="wrapper">
|
||||
<div class="bar">
|
||||
<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} 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>
|
||||
|
||||
<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 {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
padding: 10px 14px 24px 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.bar {
|
||||
width: 100%;
|
||||
height: 52px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 0 9px 0 15px;
|
||||
border-radius: 14px;
|
||||
font-family: 'Archivo', -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
|
||||
-webkit-app-region: drag;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
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;
|
||||
-webkit-app-region: no-drag;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
</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>
|
||||
|
||||
@@ -306,10 +306,32 @@
|
||||
window.electronAPI.onData((data: any) => {
|
||||
if (data.type === 'connection:open' && data.data?.url) {
|
||||
const connId = data.data.connectionId ?? ''
|
||||
openConnections.set(connId, data.data.url)
|
||||
const incomingUrl = data.data.url
|
||||
const alreadyOpen = openConnections.has(connId)
|
||||
|
||||
openConnections.set(connId, incomingUrl)
|
||||
openConnections = new Map(openConnections)
|
||||
connectedUrl = data.data.url
|
||||
connectedUrl = incomingUrl
|
||||
activeConnectionId = connId
|
||||
|
||||
// If the webview for this connection already exists in the DOM,
|
||||
// updating the map value won't cause it to re-navigate (Electron
|
||||
// webview `src` changes on an existing element are ignored). We
|
||||
// need to explicitly call loadURL so that e.g. the spotlight ?q=
|
||||
// parameter actually reaches the Open WebUI instance.
|
||||
if (alreadyOpen) {
|
||||
requestAnimationFrame(() => {
|
||||
const container = document.querySelector('.content-webview-container')
|
||||
if (!container) return
|
||||
const wv = container.querySelector(
|
||||
`webview[partition="persist:connection-${connId}"]`
|
||||
) as any
|
||||
if (wv?.loadURL) {
|
||||
wv.loadURL(incomingUrl)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Don't switch to connected view during active install — the install
|
||||
// flow handles its own transition after confirming reachability.
|
||||
if (installPhase !== 'working') {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -24,7 +24,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 +51,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>
|
||||
@@ -95,3 +95,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,13 +95,16 @@
|
||||
<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 isServerLoading =
|
||||
connectingId === localConn.id ||
|
||||
serverStatus === 'starting' ||
|
||||
(serverStatus === 'running' && !serverReachable)}
|
||||
{@const isLocalDisabled = !serverReachable && !isServerLoading}
|
||||
<div
|
||||
class="w-full px-2 py-[6px] rounded-xl group flex items-center gap-2 transition-colors {isLocalDisabled
|
||||
class="w-full px-2.5 py-1.5 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]'
|
||||
? 'bg-black/[0.06] dark:bg-white/[0.06]'
|
||||
: isLocalDisabled
|
||||
? ''
|
||||
: 'hover:bg-black/[0.03] dark:bg-white/[0.05]'}"
|
||||
@@ -238,9 +243,9 @@
|
||||
|
||||
{#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]'
|
||||
? 'bg-black/[0.06] dark:bg-white/[0.06]'
|
||||
: 'hover:bg-black/[0.03] dark:bg-white/[0.05]'}"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
|
||||
@@ -76,6 +76,11 @@
|
||||
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)
|
||||
|
||||
// Keep shortcut value in sync with config store
|
||||
$effect(() => {
|
||||
if ($config?.globalShortcut !== undefined) {
|
||||
@@ -83,6 +88,12 @@
|
||||
}
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
if ($config?.spotlightShortcut !== undefined) {
|
||||
spotlightShortcutValue = $config.spotlightShortcut ?? ''
|
||||
}
|
||||
})
|
||||
|
||||
const keyToElectron = (e: KeyboardEvent): string | null => {
|
||||
const parts: string[] = []
|
||||
if (e.metaKey || e.ctrlKey) parts.push('CommandOrControl')
|
||||
@@ -142,6 +153,32 @@
|
||||
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())
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col divide-y divide-white/[0.04]">
|
||||
@@ -303,6 +340,60 @@
|
||||
</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>
|
||||
|
||||
<!-- Advanced (collapsed by default) -->
|
||||
<div class="py-4">
|
||||
<button
|
||||
|
||||
@@ -152,6 +152,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",
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user