mirror of
https://github.com/open-webui/desktop.git
synced 2026-07-15 04:35:40 -04:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eb3c569078 | |||
| 3350da65ec | |||
| 953327b9ef | |||
| 1a56df0c6e |
@@ -5,6 +5,20 @@ 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.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
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "open-webui",
|
||||
"version": "0.0.9",
|
||||
"version": "0.0.10",
|
||||
"license": "AGPL-3.0",
|
||||
"description": "Open WebUI Desktop",
|
||||
"main": "./out/main/index.js",
|
||||
|
||||
+71
-3
@@ -89,10 +89,31 @@ log.transports.file.resolvePathFn = () => getLogFilePath('main')
|
||||
|
||||
import icon from '../../resources/icon.png?asset'
|
||||
|
||||
import { existsSync, writeFileSync, unlinkSync } from 'fs'
|
||||
|
||||
if (process.platform === 'linux') {
|
||||
app.commandLine.appendSwitch('no-sandbox')
|
||||
}
|
||||
|
||||
// ─── GPU Crash Recovery ─────────────────────────────────
|
||||
// When the GPU process crashes fatally (common on certain NVIDIA/Intel
|
||||
// driver + Windows combos), we write a marker file and relaunch with
|
||||
// --disable-gpu-sandbox so the user doesn't have to manually edit
|
||||
// shortcut properties. On the next launch the marker is detected and
|
||||
// the switch is applied preemptively.
|
||||
|
||||
const gpuCrashMarkerPath = join(app.getPath('userData'), '.gpu-sandbox-disabled')
|
||||
const gpuSandboxDisabled = existsSync(gpuCrashMarkerPath)
|
||||
|
||||
if (gpuSandboxDisabled) {
|
||||
log.info('GPU sandbox disabled due to previous GPU process crash')
|
||||
app.commandLine.appendSwitch('disable-gpu-sandbox')
|
||||
}
|
||||
|
||||
// Prevent Chromium from permanently blocking WebGL / 3-D APIs after
|
||||
// repeated GPU process crashes within the same session.
|
||||
app.disableDomainBlockingFor3DAPIs()
|
||||
|
||||
// ─── State ──────────────────────────────────────────────
|
||||
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
@@ -948,6 +969,15 @@ const resetAppHandler = async () => {
|
||||
} catch (e) {
|
||||
log.warn('Failed to uninstall llama.cpp during reset:', e)
|
||||
}
|
||||
// Remove GPU crash marker so sandbox is re-tested on next launch
|
||||
try {
|
||||
if (existsSync(gpuCrashMarkerPath)) {
|
||||
unlinkSync(gpuCrashMarkerPath)
|
||||
log.info('GPU crash marker removed during reset')
|
||||
}
|
||||
} catch (e) {
|
||||
log.warn('Failed to remove GPU crash marker during reset:', e)
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
await resetApp()
|
||||
CONFIG = await getConfig() // reload from defaults since config.json was deleted
|
||||
@@ -998,6 +1028,43 @@ if (!gotTheLock) {
|
||||
}
|
||||
electronApp.setAppUserModelId('com.openwebui.desktop')
|
||||
|
||||
// ─── GPU Process Crash Recovery ──────────────────
|
||||
// If the GPU process exits fatally (e.g. sandbox init failure on
|
||||
// certain NVIDIA/Intel drivers), write a marker and relaunch with
|
||||
// --disable-gpu-sandbox so the user doesn't have to manually edit
|
||||
// shortcut targets (see issue #110).
|
||||
app.on('child-process-gone', (_event, details) => {
|
||||
if (details.type === 'GPU') {
|
||||
log.error(
|
||||
`GPU process gone: reason=${details.reason}, exitCode=${details.exitCode}`
|
||||
)
|
||||
|
||||
// Only auto-recover from fatal crashes, not normal/clean exits
|
||||
if (
|
||||
details.reason === 'crashed' ||
|
||||
details.reason === 'launch-failed' ||
|
||||
details.reason === 'abnormal-exit'
|
||||
) {
|
||||
if (!gpuSandboxDisabled) {
|
||||
log.info('Writing GPU crash marker and relaunching with --disable-gpu-sandbox')
|
||||
try {
|
||||
writeFileSync(gpuCrashMarkerPath, new Date().toISOString(), 'utf-8')
|
||||
} catch (e) {
|
||||
log.warn('Failed to write GPU crash marker:', e)
|
||||
}
|
||||
app.relaunch({ args: [...process.argv.slice(1), '--disable-gpu-sandbox'] })
|
||||
app.exit(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// If we previously set the GPU sandbox marker and this session
|
||||
// started successfully, log it so it's visible in diagnostics.
|
||||
if (gpuSandboxDisabled) {
|
||||
log.info('Running with GPU sandbox disabled (marker file present)')
|
||||
}
|
||||
|
||||
app.on('browser-window-created', (_, window) => {
|
||||
optimizer.watchWindowShortcuts(window)
|
||||
})
|
||||
@@ -1010,7 +1077,8 @@ if (!gotTheLock) {
|
||||
version: app.getVersion(),
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
username: require('os').userInfo().username
|
||||
username: require('os').userInfo().username,
|
||||
gpuSandboxDisabled
|
||||
}))
|
||||
|
||||
ipcMain.handle('app:contentPreloadPath', () => {
|
||||
@@ -1647,8 +1715,8 @@ if (!gotTheLock) {
|
||||
ipcMain.handle('huggingface:models:delete', (_event, repo: string, filename: string) => {
|
||||
return deleteModel(repo, filename)
|
||||
})
|
||||
ipcMain.handle('huggingface:models:cancel', () => {
|
||||
cancelDownload()
|
||||
ipcMain.handle('huggingface:models:cancel', (_event, repo?: string, filename?: string) => {
|
||||
cancelDownload(repo, filename)
|
||||
return true
|
||||
})
|
||||
ipcMain.handle('huggingface:search', async (_event, query: string, token?: string) => {
|
||||
|
||||
@@ -62,15 +62,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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,8 +149,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 +201,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 +210,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()
|
||||
|
||||
@@ -155,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) =>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user