mirror of
https://github.com/open-webui/desktop.git
synced 2026-04-28 01:49:29 +00:00
refac
This commit is contained in:
parent
86cf4834cd
commit
146f75f979
14 changed files with 591 additions and 71 deletions
41
.github/workflows/release.yml
vendored
Normal file
41
.github/workflows/release.yml
vendored
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
name: Build & Publish Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- release
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- os: macos-latest
|
||||
platform: mac
|
||||
- os: ubuntu-latest
|
||||
platform: linux
|
||||
- os: windows-latest
|
||||
platform: win
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build & Publish
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: npm run build && npx electron-builder --${{ matrix.platform }} --publish always
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
provider: generic
|
||||
url: https://example.com/auto-updates
|
||||
provider: github
|
||||
owner: open-webui
|
||||
repo: desktop
|
||||
updaterCacheDirName: desktop-updater
|
||||
|
|
|
|||
|
|
@ -50,5 +50,6 @@ appImage:
|
|||
artifactName: ${name}-${version}.${ext}
|
||||
npmRebuild: false
|
||||
publish:
|
||||
provider: generic
|
||||
url: https://example.com/auto-updates
|
||||
provider: github
|
||||
owner: open-webui
|
||||
repo: desktop
|
||||
|
|
|
|||
|
|
@ -13,7 +13,8 @@ import {
|
|||
Notification,
|
||||
Menu,
|
||||
ipcMain,
|
||||
Tray
|
||||
Tray,
|
||||
dialog
|
||||
} from 'electron'
|
||||
import path, { join } from 'path'
|
||||
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
||||
|
|
@ -52,6 +53,8 @@ import {
|
|||
getOpenTerminalLog
|
||||
} from './utils/open-terminal'
|
||||
|
||||
import { initUpdater, checkForUpdates, downloadUpdate, installUpdate } from './updater'
|
||||
|
||||
import log from 'electron-log'
|
||||
log.transports.file.resolvePathFn = () => getLogFilePath('main')
|
||||
|
||||
|
|
@ -590,6 +593,18 @@ if (!gotTheLock) {
|
|||
return config.connections
|
||||
})
|
||||
|
||||
ipcMain.handle('connections:update', async (_event, id: string, updates: Partial<Connection>) => {
|
||||
const config = await getConfig()
|
||||
const idx = config.connections.findIndex((c) => c.id === id)
|
||||
if (idx !== -1) {
|
||||
config.connections[idx] = { ...config.connections[idx], ...updates }
|
||||
await setConfig(config)
|
||||
CONFIG = config
|
||||
updateTray()
|
||||
}
|
||||
return config.connections
|
||||
})
|
||||
|
||||
ipcMain.handle('connections:setDefault', async (_event, id: string) => {
|
||||
const config = await getConfig()
|
||||
config.defaultConnectionId = id
|
||||
|
|
@ -611,6 +626,11 @@ if (!gotTheLock) {
|
|||
return await validateRemoteUrl(url)
|
||||
})
|
||||
|
||||
// Updater
|
||||
ipcMain.handle('updater:check', () => checkForUpdates())
|
||||
ipcMain.handle('updater:download', () => downloadUpdate())
|
||||
ipcMain.handle('updater:install', () => installUpdate())
|
||||
|
||||
// Misc
|
||||
ipcMain.handle('app:reset', () => resetAppHandler())
|
||||
|
||||
|
|
@ -655,6 +675,13 @@ if (!gotTheLock) {
|
|||
return uninstallPackage(packageName)
|
||||
})
|
||||
|
||||
ipcMain.handle('dialog:selectFolder', async () => {
|
||||
const result = await dialog.showOpenDialog(mainWindow!, {
|
||||
properties: ['openDirectory']
|
||||
})
|
||||
return result.canceled ? null : result.filePaths[0] ?? null
|
||||
})
|
||||
|
||||
ipcMain.handle('app:launchAtLogin:get', () => {
|
||||
return app.getLoginItemSettings().openAtLogin
|
||||
})
|
||||
|
|
@ -746,6 +773,11 @@ if (!gotTheLock) {
|
|||
createMainWindow()
|
||||
}
|
||||
|
||||
// Initialize auto-updater
|
||||
if (mainWindow) {
|
||||
initUpdater(mainWindow)
|
||||
}
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) createMainWindow()
|
||||
else {
|
||||
|
|
|
|||
66
src/main/updater.ts
Normal file
66
src/main/updater.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { autoUpdater, type UpdateInfo } from 'electron-updater'
|
||||
import log from 'electron-log'
|
||||
import { BrowserWindow } from 'electron'
|
||||
|
||||
let mainWin: BrowserWindow | null = null
|
||||
|
||||
const send = (type: string, data?: any): void => {
|
||||
mainWin?.webContents.send('main:data', { type, data })
|
||||
}
|
||||
|
||||
export function initUpdater(window: BrowserWindow): void {
|
||||
mainWin = window
|
||||
|
||||
autoUpdater.logger = log
|
||||
autoUpdater.autoDownload = false
|
||||
autoUpdater.autoInstallOnAppQuit = true
|
||||
|
||||
autoUpdater.on('checking-for-update', () => {
|
||||
send('update:checking')
|
||||
})
|
||||
|
||||
autoUpdater.on('update-available', (info: UpdateInfo) => {
|
||||
send('update:available', {
|
||||
version: info.version,
|
||||
releaseDate: info.releaseDate
|
||||
})
|
||||
})
|
||||
|
||||
autoUpdater.on('update-not-available', (_info: UpdateInfo) => {
|
||||
send('update:not-available')
|
||||
})
|
||||
|
||||
autoUpdater.on('download-progress', (progress) => {
|
||||
send('update:download-progress', {
|
||||
percent: progress.percent,
|
||||
bytesPerSecond: progress.bytesPerSecond,
|
||||
transferred: progress.transferred,
|
||||
total: progress.total
|
||||
})
|
||||
})
|
||||
|
||||
autoUpdater.on('update-downloaded', (_info: UpdateInfo) => {
|
||||
send('update:downloaded')
|
||||
})
|
||||
|
||||
autoUpdater.on('error', (error: Error) => {
|
||||
send('update:error', { message: error?.message ?? 'Update error' })
|
||||
})
|
||||
|
||||
// Auto-check on launch (silently)
|
||||
autoUpdater.checkForUpdates().catch((err) => {
|
||||
log.warn('Auto update check failed:', err)
|
||||
})
|
||||
}
|
||||
|
||||
export async function checkForUpdates(): Promise<void> {
|
||||
await autoUpdater.checkForUpdates()
|
||||
}
|
||||
|
||||
export async function downloadUpdate(): Promise<void> {
|
||||
await autoUpdater.downloadUpdate()
|
||||
}
|
||||
|
||||
export function installUpdate(): void {
|
||||
autoUpdater.quitAndInstall(false, true)
|
||||
}
|
||||
|
|
@ -738,6 +738,7 @@ export interface AppConfig {
|
|||
openTerminal: {
|
||||
enabled: boolean
|
||||
port: number
|
||||
cwd: string
|
||||
}
|
||||
envVars: Record<string, string>
|
||||
}
|
||||
|
|
@ -753,7 +754,8 @@ const DEFAULT_CONFIG: AppConfig = {
|
|||
},
|
||||
openTerminal: {
|
||||
enabled: false,
|
||||
port: 8000
|
||||
port: 8000,
|
||||
cwd: ''
|
||||
},
|
||||
envVars: {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,11 +63,14 @@ export const startOpenTerminal = async (
|
|||
}
|
||||
}
|
||||
|
||||
const cwd = config.openTerminal?.cwd || require('os').homedir()
|
||||
|
||||
const commandArgs = [
|
||||
'-m', 'uv', 'run', 'open-terminal', 'run',
|
||||
'--host', host,
|
||||
'--port', availablePort.toString(),
|
||||
'--api-key', generatedKey
|
||||
'--api-key', generatedKey,
|
||||
'--cwd', cwd
|
||||
]
|
||||
|
||||
log.info('Starting Open Terminal...', pythonPath, commandArgs.join(' '))
|
||||
|
|
|
|||
|
|
@ -138,9 +138,16 @@ const api = {
|
|||
getConnections: () => ipcRenderer.invoke('connections:list'),
|
||||
addConnection: (connection: any) => ipcRenderer.invoke('connections:add', connection),
|
||||
removeConnection: (id: string) => ipcRenderer.invoke('connections:remove', id),
|
||||
updateConnection: (id: string, updates: any) => ipcRenderer.invoke('connections:update', id, updates),
|
||||
setDefaultConnection: (id: string) => ipcRenderer.invoke('connections:setDefault', id),
|
||||
connectTo: (id: string) => ipcRenderer.invoke('connections:connect', id),
|
||||
validateUrl: (url: string) => ipcRenderer.invoke('validate:url', url)
|
||||
validateUrl: (url: string) => ipcRenderer.invoke('validate:url', url),
|
||||
selectFolder: () => ipcRenderer.invoke('dialog:selectFolder'),
|
||||
|
||||
// Updater
|
||||
checkForUpdates: () => ipcRenderer.invoke('updater:check'),
|
||||
downloadUpdate: () => ipcRenderer.invoke('updater:download'),
|
||||
installUpdate: () => ipcRenderer.invoke('updater:install')
|
||||
}
|
||||
|
||||
if (process.contextIsolated) {
|
||||
|
|
|
|||
|
|
@ -116,7 +116,7 @@
|
|||
>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="w-[calc(100%-32px)] h-[calc(100%-32px)] max-w-[900px] max-h-[600px] rounded-2xl overflow-hidden shadow-2xl border border-white/[0.08]"
|
||||
class="w-[calc(100%-32px)] h-[calc(100%-32px)] max-w-[900px] max-h-[600px] rounded-3xl overflow-hidden shadow-2xl border border-white/[0.08]"
|
||||
in:fade={{ duration: 150 }}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -148,6 +148,13 @@
|
|||
const connect = async (id: string) => {
|
||||
destroyTerminal()
|
||||
showingLogs = false
|
||||
// Toggle: clicking the active connection unselects it
|
||||
if (activeConnectionId === id && view === 'connected') {
|
||||
activeConnectionId = ''
|
||||
connectedUrl = ''
|
||||
view = 'welcome'
|
||||
return
|
||||
}
|
||||
if (openConnections.has(id)) {
|
||||
activeConnectionId = id
|
||||
connectedUrl = openConnections.get(id)!
|
||||
|
|
@ -412,6 +419,11 @@
|
|||
{onOpenSettings}
|
||||
onToggleOpenTerminal={toggleOpenTerminal}
|
||||
onToggleOtLogs={toggleOtLogs}
|
||||
onRename={async (id, name) => {
|
||||
await window.electronAPI.updateConnection(id, { name })
|
||||
connections.set(await window.electronAPI.getConnections())
|
||||
}}
|
||||
onRemove={remove}
|
||||
{openGithub}
|
||||
/>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@
|
|||
onOpenSettings: () => void
|
||||
onToggleOpenTerminal: () => void
|
||||
onToggleOtLogs: () => void
|
||||
onRename: (id: string, name: string) => void
|
||||
onRemove: (id: string) => void
|
||||
openGithub: () => void
|
||||
}
|
||||
|
||||
|
|
@ -38,8 +40,33 @@
|
|||
onOpenSettings,
|
||||
onToggleOpenTerminal,
|
||||
onToggleOtLogs,
|
||||
onRename,
|
||||
onRemove,
|
||||
openGithub
|
||||
}: Props = $props()
|
||||
|
||||
// Inline rename state
|
||||
let editingId = $state<string | null>(null)
|
||||
let editValue = $state('')
|
||||
let menuOpenId = $state<string | null>(null)
|
||||
|
||||
const startRename = (id: string, currentName: string) => {
|
||||
editingId = id
|
||||
editValue = currentName
|
||||
}
|
||||
|
||||
const commitRename = () => {
|
||||
if (editingId && editValue.trim()) {
|
||||
onRename(editingId, editValue.trim())
|
||||
}
|
||||
editingId = null
|
||||
editValue = ''
|
||||
}
|
||||
|
||||
const cancelRename = () => {
|
||||
editingId = null
|
||||
editValue = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
|
|
@ -84,11 +111,12 @@
|
|||
onclick={() => !isLocalDisabled && onConnect(localConn.id)}
|
||||
onkeydown={(e) => e.key === 'Enter' && !isLocalDisabled && onConnect(localConn.id)}
|
||||
>
|
||||
{#if serverStatus === 'starting'}
|
||||
{#if serverStatus === 'starting' || (serverStatus === 'running' && !serverReachable)}
|
||||
<div class="w-[14px] h-[14px] shrink-0 flex items-center justify-center">
|
||||
<div
|
||||
class="w-2.5 h-2.5 rounded-full border-2 border-amber-400/60 border-t-transparent animate-spin"
|
||||
></div>
|
||||
<svg class="w-3 h-3 animate-spin text-white/40" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
{:else if serverReachable}
|
||||
<div class="w-[14px] h-[14px] shrink-0 flex items-center justify-center">
|
||||
|
|
@ -101,35 +129,82 @@
|
|||
<div class="w-2 h-2 rounded-full bg-white/15"></div>
|
||||
</div>
|
||||
{/if}
|
||||
<span
|
||||
class="text-[12px] {activeConnectionId === localConn.id
|
||||
? 'opacity-90'
|
||||
: 'opacity-100'} transition-opacity truncate"
|
||||
>Open WebUI</span
|
||||
>
|
||||
|
||||
<button
|
||||
class="ml-auto opacity-0 group-hover:opacity-30 hover:!opacity-70 transition bg-transparent border-none text-[#fafafa] shrink-0"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation()
|
||||
window.electronAPI?.openInBrowser?.(localConn.url)
|
||||
}}
|
||||
title="Open in browser"
|
||||
>
|
||||
<svg
|
||||
class="w-[12px] h-[12px]"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
{#if editingId === localConn.id}
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
type="text"
|
||||
class="text-[12px] bg-transparent text-[#fafafa] px-0 py-0 border-none outline-none rounded-md flex-1 min-w-0"
|
||||
bind:value={editValue}
|
||||
autofocus
|
||||
onfocus={(e) => e.currentTarget.select()}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => {
|
||||
e.stopPropagation()
|
||||
if (e.key === 'Enter') commitRename()
|
||||
if (e.key === 'Escape') cancelRename()
|
||||
}}
|
||||
onblur={commitRename}
|
||||
/>
|
||||
{:else}
|
||||
<span
|
||||
class="text-[12px] {activeConnectionId === localConn.id
|
||||
? 'opacity-90'
|
||||
: 'opacity-100'} transition-opacity truncate flex-1 min-w-0"
|
||||
>{localConn.name ?? 'Open WebUI'}</span
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<div class="ml-auto relative shrink-0">
|
||||
<button
|
||||
class="opacity-20 hover:opacity-70 transition bg-transparent border-none text-[#fafafa] p-0.5 leading-none"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation()
|
||||
menuOpenId = menuOpenId === 'local' ? null : 'local'
|
||||
}}
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M6 10a2 2 0 11-4 0 2 2 0 014 0zM12 10a2 2 0 11-4 0 2 2 0 014 0zM18 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if menuOpenId === 'local'}
|
||||
<div class="fixed inset-0 z-40" onclick={(e) => { e.stopPropagation(); menuOpenId = null }}></div>
|
||||
<div
|
||||
class="absolute right-0 top-6 z-50 w-[160px] bg-[#1a1a1a]/90 backdrop-blur-xl border border-white/[0.08] rounded-2xl shadow-2xl py-0.5 overflow-hidden"
|
||||
in:fly={{ y: -4, duration: 150 }}
|
||||
out:fade={{ duration: 100 }}
|
||||
>
|
||||
<div class="py-1 px-1.5">
|
||||
<button
|
||||
class="w-full flex items-center gap-2.5 px-3 py-1.5 text-left text-[12px] opacity-50 hover:opacity-90 hover:bg-white/[0.06] transition bg-transparent border-none text-[#fafafa] rounded-xl"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation()
|
||||
menuOpenId = null
|
||||
startRename(localConn.id, localConn.name ?? 'Open WebUI')
|
||||
}}
|
||||
>
|
||||
<svg class="w-[14px] h-[14px] shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L6.832 19.82a4.5 4.5 0 01-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 011.13-1.897L16.863 4.487z" />
|
||||
</svg>
|
||||
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-white/[0.06] transition bg-transparent border-none text-[#fafafa] rounded-xl"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation()
|
||||
menuOpenId = null
|
||||
window.electronAPI?.openInBrowser?.(localConn.url)
|
||||
}}
|
||||
>
|
||||
<svg class="w-[14px] h-[14px] shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
|
||||
</svg>
|
||||
Open in Browser
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
|
@ -145,8 +220,8 @@
|
|||
: 'hover:bg-white/[0.05]'}"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => onConnect(conn.id)}
|
||||
onkeydown={(e) => e.key === 'Enter' && onConnect(conn.id)}
|
||||
onclick={() => editingId !== conn.id && onConnect(conn.id)}
|
||||
onkeydown={(e) => e.key === 'Enter' && editingId !== conn.id && onConnect(conn.id)}
|
||||
>
|
||||
<svg
|
||||
class="w-[14px] h-[14px] shrink-0 opacity-30"
|
||||
|
|
@ -161,35 +236,100 @@
|
|||
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>
|
||||
<span
|
||||
class="text-[12px] {activeConnectionId === conn.id
|
||||
? 'opacity-90'
|
||||
: 'opacity-100'} transition-opacity truncate"
|
||||
>{conn.name}</span
|
||||
>
|
||||
|
||||
<button
|
||||
class="ml-auto opacity-0 group-hover:opacity-30 hover:!opacity-70 transition bg-transparent border-none text-[#fafafa] shrink-0"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation()
|
||||
window.electronAPI?.openInBrowser?.(conn.url)
|
||||
}}
|
||||
title="Open in browser"
|
||||
>
|
||||
<svg
|
||||
class="w-[12px] h-[12px]"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
{#if editingId === conn.id}
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
type="text"
|
||||
class="text-[12px] bg-transparent text-[#fafafa] px-0 py-0 border-none outline-none rounded-md flex-1 min-w-0"
|
||||
bind:value={editValue}
|
||||
autofocus
|
||||
onfocus={(e) => e.currentTarget.select()}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => {
|
||||
e.stopPropagation()
|
||||
if (e.key === 'Enter') commitRename()
|
||||
if (e.key === 'Escape') cancelRename()
|
||||
}}
|
||||
onblur={commitRename}
|
||||
/>
|
||||
{:else}
|
||||
<span
|
||||
class="text-[12px] {activeConnectionId === conn.id
|
||||
? 'opacity-90'
|
||||
: 'opacity-100'} transition-opacity truncate flex-1 min-w-0"
|
||||
>{conn.name}</span
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Three-dots menu -->
|
||||
<div class="ml-auto relative shrink-0">
|
||||
<button
|
||||
class="opacity-20 hover:opacity-70 transition bg-transparent border-none text-[#fafafa] p-0.5 leading-none"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation()
|
||||
menuOpenId = menuOpenId === conn.id ? null : conn.id
|
||||
}}
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M6 10a2 2 0 11-4 0 2 2 0 014 0zM12 10a2 2 0 11-4 0 2 2 0 014 0zM18 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if menuOpenId === conn.id}
|
||||
<div class="fixed inset-0 z-40" onclick={(e) => { e.stopPropagation(); menuOpenId = null }}></div>
|
||||
<div
|
||||
class="absolute right-0 top-6 z-50 w-[160px] bg-[#1a1a1a]/90 backdrop-blur-xl border border-white/[0.08] rounded-2xl shadow-2xl py-0.5 overflow-hidden"
|
||||
in:fly={{ y: -4, duration: 150 }}
|
||||
out:fade={{ duration: 100 }}
|
||||
>
|
||||
<div class="py-1 px-1.5">
|
||||
<button
|
||||
class="w-full flex items-center gap-2.5 px-3 py-1.5 text-left text-[12px] opacity-50 hover:opacity-90 hover:bg-white/[0.06] transition bg-transparent border-none text-[#fafafa] rounded-xl"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation()
|
||||
menuOpenId = null
|
||||
startRename(conn.id, conn.name)
|
||||
}}
|
||||
>
|
||||
<svg class="w-[14px] h-[14px] shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L6.832 19.82a4.5 4.5 0 01-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 011.13-1.897L16.863 4.487z" />
|
||||
</svg>
|
||||
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-white/[0.06] transition bg-transparent border-none text-[#fafafa] rounded-xl"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation()
|
||||
menuOpenId = null
|
||||
window.electronAPI?.openInBrowser?.(conn.url)
|
||||
}}
|
||||
>
|
||||
<svg class="w-[14px] h-[14px] shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
|
||||
</svg>
|
||||
Open in Browser
|
||||
</button>
|
||||
</div>
|
||||
<div class="mx-3 border-t border-white/[0.06]"></div>
|
||||
<div class="py-1 px-1.5">
|
||||
<button
|
||||
class="w-full flex items-center gap-2.5 px-3 py-1.5 text-left text-[12px] opacity-50 hover:opacity-90 hover:bg-red-500/10 transition bg-transparent border-none text-red-400 rounded-xl"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation()
|
||||
menuOpenId = null
|
||||
onRemove(conn.id)
|
||||
}}
|
||||
>
|
||||
<svg class="w-[14px] h-[14px] shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
|
||||
</svg>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,18 +1,87 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import { onMount, onDestroy } from 'svelte'
|
||||
import { appInfo } from '../../../stores'
|
||||
|
||||
let openWebuiVersion = $state<string | null>(null)
|
||||
let openTerminalVersion = $state<string | null>(null)
|
||||
|
||||
// Update state
|
||||
type UpdateStatus = 'idle' | 'checking' | 'available' | 'downloading' | 'downloaded' | 'up-to-date' | 'error'
|
||||
let updateStatus = $state<UpdateStatus>('idle')
|
||||
let updateVersion = $state<string | null>(null)
|
||||
let downloadPercent = $state(0)
|
||||
let updateError = $state<string | null>(null)
|
||||
|
||||
let removeListener: (() => void) | null = null
|
||||
|
||||
onMount(async () => {
|
||||
openWebuiVersion = await window.electronAPI.getPackageVersion('open-webui')
|
||||
openTerminalVersion = await window.electronAPI.getPackageVersion('open-terminal')
|
||||
|
||||
// Listen for update events from main process
|
||||
const handler = (data: any) => {
|
||||
switch (data.type) {
|
||||
case 'update:checking':
|
||||
updateStatus = 'checking'
|
||||
updateError = null
|
||||
break
|
||||
case 'update:available':
|
||||
updateStatus = 'available'
|
||||
updateVersion = data.data?.version ?? null
|
||||
break
|
||||
case 'update:not-available':
|
||||
updateStatus = 'up-to-date'
|
||||
break
|
||||
case 'update:download-progress':
|
||||
updateStatus = 'downloading'
|
||||
downloadPercent = Math.round(data.data?.percent ?? 0)
|
||||
break
|
||||
case 'update:downloaded':
|
||||
updateStatus = 'downloaded'
|
||||
downloadPercent = 100
|
||||
break
|
||||
case 'update:error':
|
||||
updateStatus = 'error'
|
||||
updateError = data.data?.message ?? 'Unknown error'
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
window.electronAPI.onData(handler)
|
||||
removeListener = () => {
|
||||
// electron onData doesn't return an unsubscribe, so we just null out
|
||||
}
|
||||
})
|
||||
|
||||
const openGithub = () => {
|
||||
window.electronAPI?.openInBrowser?.('https://github.com/open-webui/desktop')
|
||||
}
|
||||
|
||||
const handleCheck = async () => {
|
||||
updateStatus = 'checking'
|
||||
updateError = null
|
||||
try {
|
||||
await window.electronAPI.checkForUpdates()
|
||||
} catch (e: any) {
|
||||
updateStatus = 'error'
|
||||
updateError = e?.message ?? 'Check failed'
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownload = async () => {
|
||||
updateStatus = 'downloading'
|
||||
downloadPercent = 0
|
||||
try {
|
||||
await window.electronAPI.downloadUpdate()
|
||||
} catch (e: any) {
|
||||
updateStatus = 'error'
|
||||
updateError = e?.message ?? 'Download failed'
|
||||
}
|
||||
}
|
||||
|
||||
const handleInstall = () => {
|
||||
window.electronAPI.installUpdate()
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col divide-y divide-white/[0.04]">
|
||||
|
|
@ -40,6 +109,71 @@
|
|||
<div class="text-[12px] opacity-30">{$appInfo?.platform ?? 'Unknown'}</div>
|
||||
</div>
|
||||
|
||||
<!-- Update section -->
|
||||
<div class="py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-[13px] opacity-70">Software Update</div>
|
||||
{#if updateStatus === 'up-to-date'}
|
||||
<div class="text-[11px] opacity-25 mt-0.5">You're on the latest version</div>
|
||||
{:else if updateStatus === 'available' && updateVersion}
|
||||
<div class="text-[11px] opacity-40 mt-0.5">Version {updateVersion} is available</div>
|
||||
{:else if updateStatus === 'downloading'}
|
||||
<div class="text-[11px] opacity-25 mt-0.5">Downloading… {downloadPercent}%</div>
|
||||
{:else if updateStatus === 'downloaded'}
|
||||
<div class="text-[11px] opacity-40 mt-0.5">Update ready — restart to apply</div>
|
||||
{:else if updateStatus === 'error'}
|
||||
<div class="text-[11px] text-red-400/60 mt-0.5">{updateError}</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{#if updateStatus === 'idle' || updateStatus === 'up-to-date' || updateStatus === 'error'}
|
||||
<button
|
||||
class="text-[12px] opacity-40 hover:opacity-70 px-3 py-1.5 bg-white/[0.06] transition border-none text-[#fafafa] rounded-xl"
|
||||
onclick={handleCheck}
|
||||
>
|
||||
Check for Updates
|
||||
</button>
|
||||
{:else if updateStatus === 'checking'}
|
||||
<button
|
||||
class="text-[12px] opacity-30 px-3 py-1.5 bg-white/[0.06] border-none text-[#fafafa] rounded-xl pointer-events-none flex items-center gap-1.5"
|
||||
disabled
|
||||
>
|
||||
<svg class="w-3 h-3 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3" stroke-dasharray="31.4 31.4" stroke-linecap="round" />
|
||||
</svg>
|
||||
Checking…
|
||||
</button>
|
||||
{:else if updateStatus === 'available'}
|
||||
<button
|
||||
class="text-[12px] opacity-50 hover:opacity-80 px-3 py-1.5 bg-white/[0.08] transition border-none text-[#fafafa] rounded-xl"
|
||||
onclick={handleDownload}
|
||||
>
|
||||
Download Update
|
||||
</button>
|
||||
{:else if updateStatus === 'downloading'}
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-24 h-1.5 bg-white/[0.06] rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-white/30 rounded-full transition-all duration-300"
|
||||
style="width: {downloadPercent}%"
|
||||
></div>
|
||||
</div>
|
||||
<span class="text-[11px] opacity-30">{downloadPercent}%</span>
|
||||
</div>
|
||||
{:else if updateStatus === 'downloaded'}
|
||||
<button
|
||||
class="text-[12px] opacity-50 hover:opacity-80 px-3 py-1.5 bg-white/[0.08] transition border-none text-[#fafafa] rounded-xl"
|
||||
onclick={handleInstall}
|
||||
>
|
||||
Restart to Update
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="py-4">
|
||||
<button
|
||||
class="text-[12px] opacity-40 hover:opacity-70 transition bg-transparent border-none text-[#fafafa]"
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
let version = $state<string | null>(null)
|
||||
let stopping = $state(false)
|
||||
let starting = $state(false)
|
||||
let restarting = $state(false)
|
||||
let updating = $state(false)
|
||||
let loaded = $state(false)
|
||||
let uninstalling = $state(false)
|
||||
|
|
@ -51,6 +52,18 @@
|
|||
starting = false
|
||||
}
|
||||
|
||||
const restartTerminal = async () => {
|
||||
restarting = true
|
||||
try {
|
||||
await window.electronAPI.stopOpenTerminal()
|
||||
await window.electronAPI.startOpenTerminal()
|
||||
otInfo = await window.electronAPI.getOpenTerminalInfo()
|
||||
} catch (e) {
|
||||
console.error('Failed to restart Open Terminal:', e)
|
||||
}
|
||||
restarting = false
|
||||
}
|
||||
|
||||
const updatePackage = async () => {
|
||||
updating = true
|
||||
try {
|
||||
|
|
@ -149,6 +162,21 @@
|
|||
Stop
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
class="text-[12px] opacity-40 hover:opacity-70 px-3 py-1.5 bg-white/[0.06] transition border-none text-[#fafafa] rounded-xl flex items-center gap-1.5 {restarting ? 'pointer-events-none opacity-20' : ''}"
|
||||
disabled={restarting}
|
||||
onclick={restartTerminal}
|
||||
>
|
||||
{#if restarting}
|
||||
<div class="w-2.5 h-2.5 rounded-full border-[1.5px] border-white/30 border-t-transparent animate-spin"></div>
|
||||
Restarting…
|
||||
{:else}
|
||||
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M20.015 4.356v4.992m0 0h-4.992m4.993 0l-3.181-3.183a8.25 8.25 0 00-13.803 3.7" />
|
||||
</svg>
|
||||
Restart
|
||||
{/if}
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
class="text-[12px] opacity-40 hover:opacity-70 px-3 py-1.5 bg-white/[0.06] transition border-none text-[#fafafa] rounded-xl flex items-center gap-1.5 {starting ? 'pointer-events-none opacity-20' : ''}"
|
||||
|
|
@ -221,6 +249,32 @@
|
|||
/>
|
||||
</div>
|
||||
|
||||
<div class="py-4 flex items-center justify-between gap-4">
|
||||
<div class="shrink-0">
|
||||
<div class="text-[13px] opacity-70">Working Directory</div>
|
||||
<div class="text-[11px] opacity-25 mt-0.5">Starting directory for terminal sessions</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-white/[0.06] text-[12px] 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="~ (default)"
|
||||
value={$config?.openTerminal?.cwd ?? ''}
|
||||
onchange={(e) =>
|
||||
updateOtConfig('cwd', (e.target as HTMLInputElement).value.trim())}
|
||||
/>
|
||||
<button
|
||||
class="shrink-0 text-[12px] opacity-40 hover:opacity-70 px-2.5 py-1.5 bg-white/[0.06] transition border-none text-[#fafafa] rounded-xl"
|
||||
onclick={async () => {
|
||||
const folder = await window.electronAPI.selectFolder()
|
||||
if (folder) updateOtConfig('cwd', folder)
|
||||
}}
|
||||
>
|
||||
Browse
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if isRunning && otInfo}
|
||||
<div class="py-4">
|
||||
<div class="text-[13px] opacity-70 mb-3">Running Instance</div>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
let updating = $state(false)
|
||||
let stopping = $state(false)
|
||||
let starting = $state(false)
|
||||
let restarting = $state(false)
|
||||
let version = $state<string | null>(null)
|
||||
let loaded = $state(false)
|
||||
|
||||
|
|
@ -54,6 +55,17 @@
|
|||
starting = false
|
||||
}
|
||||
|
||||
const restartServer = async () => {
|
||||
restarting = true
|
||||
try {
|
||||
await window.electronAPI.restartServer()
|
||||
serverStatus = 'running'
|
||||
} catch (e) {
|
||||
console.error('Failed to restart server:', e)
|
||||
}
|
||||
restarting = false
|
||||
}
|
||||
|
||||
const updatePackage = async () => {
|
||||
updating = true
|
||||
try {
|
||||
|
|
@ -144,6 +156,21 @@
|
|||
Stop
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
class="text-[12px] opacity-40 hover:opacity-70 px-3 py-1.5 bg-white/[0.06] transition border-none text-[#fafafa] rounded-xl flex items-center gap-1.5 {restarting ? 'pointer-events-none opacity-20' : ''}"
|
||||
disabled={restarting}
|
||||
onclick={restartServer}
|
||||
>
|
||||
{#if restarting}
|
||||
<div class="w-2.5 h-2.5 rounded-full border-[1.5px] border-white/30 border-t-transparent animate-spin"></div>
|
||||
Restarting…
|
||||
{:else}
|
||||
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M20.015 4.356v4.992m0 0h-4.992m4.993 0l-3.181-3.183a8.25 8.25 0 00-13.803 3.7" />
|
||||
</svg>
|
||||
Restart
|
||||
{/if}
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
class="text-[12px] opacity-40 hover:opacity-70 px-3 py-1.5 bg-white/[0.06] transition border-none text-[#fafafa] rounded-xl flex items-center gap-1.5 {starting ? 'pointer-events-none opacity-20' : ''}"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue