💬 Commit message: Update 2026-02-15 05:04:35, 10 files, 374 lines

📁 Files changed: 10
📝 Lines changed: 374

  • convert.sh
  • index-BQdjF_w0.css
  • index-CKJOpgtQ.js
  • index-DEHUU-Zz.js
  • index-Ljwp9hgM.css
  • index.html
  • App.vue
  • DownloadsPanel.vue
  • ModelCard.vue
  • app.ts
This commit is contained in:
Adam Ladachowski
2026-02-15 05:04:35 +01:00
parent 0cd3216125
commit 8b3e19d237
10 changed files with 293 additions and 81 deletions
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# Convert a safetensors model to q8_0 GGUF format
# Usage: ./scripts/convert.sh <input.safetensors>
set -euo pipefail
if [[ $# -lt 1 ]]; then
echo "Usage: $0 <input.safetensors> [quantization]"
echo " quantization: f32, f16, q4_0, q5_0, q8_0 (default: q8_0)"
exit 1
fi
INPUT="$1"
QUANT="${2:-q8_0}"
if [[ ! -f "$INPUT" ]]; then
echo "Error: File not found: $INPUT"
exit 1
fi
# Derive output filename: model.safetensors -> model-q8_0.gguf
BASENAME=$(basename "$INPUT" .safetensors)
DIRNAME=$(dirname "$INPUT")
OUTPUT="${DIRNAME}/${BASENAME}-${QUANT}.gguf"
echo "==> Converting: $INPUT"
echo " Output: $OUTPUT"
echo " Quantization: $QUANT"
echo ""
sd -M convert -m "$INPUT" -o "$OUTPUT" --type "$QUANT"
echo ""
echo "==> Done: $OUTPUT"
ls -lh "$OUTPUT"
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tensors</title> <title>Tensors</title>
<script type="module" crossorigin src="/assets/index-CKJOpgtQ.js"></script> <script type="module" crossorigin src="/assets/index-DEHUU-Zz.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BQdjF_w0.css"> <link rel="stylesheet" crossorigin href="/assets/index-Ljwp9hgM.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
+24
View File
@@ -4,11 +4,13 @@ import { useAppStore } from '@/stores/app'
import GenerateView from '@/components/GenerateView.vue' import GenerateView from '@/components/GenerateView.vue'
import SearchView from '@/components/SearchView.vue' import SearchView from '@/components/SearchView.vue'
import GalleryView from '@/components/GalleryView.vue' import GalleryView from '@/components/GalleryView.vue'
import DownloadsPanel from '@/components/DownloadsPanel.vue'
const store = useAppStore() const store = useAppStore()
onMounted(() => { onMounted(() => {
store.loadModels() store.loadModels()
store.pollDownloads() // Check for any active downloads on load
}) })
</script> </script>
@@ -34,9 +36,31 @@ onMounted(() => {
prepend-icon="mdi-image-multiple" prepend-icon="mdi-image-multiple"
title="Gallery" title="Gallery"
/> />
<v-divider class="my-2" />
<v-list-item
@click="store.showDownloadsPanel = true"
title="Downloads"
>
<template #prepend>
<v-badge
v-if="store.hasActiveDownloads"
:content="store.activeDownloads.length"
color="primary"
offset-x="-2"
offset-y="-2"
>
<v-icon>mdi-download</v-icon>
</v-badge>
<v-icon v-else>mdi-download</v-icon>
</template>
</v-list-item>
</v-list> </v-list>
</v-navigation-drawer> </v-navigation-drawer>
<DownloadsPanel />
<v-main> <v-main>
<GenerateView v-if="store.currentView === 'generate'" /> <GenerateView v-if="store.currentView === 'generate'" />
<SearchView v-else-if="store.currentView === 'search'" /> <SearchView v-else-if="store.currentView === 'search'" />
@@ -0,0 +1,130 @@
<script setup lang="ts">
import { onMounted, onUnmounted, watch } from 'vue'
import { useAppStore } from '@/stores/app'
const store = useAppStore()
// Start polling when panel opens or when there are active downloads
watch(() => store.showDownloadsPanel, (show) => {
if (show) {
store.startDownloadPolling()
}
})
// Also poll on mount if there might be active downloads
onMounted(() => {
store.pollDownloads()
})
onUnmounted(() => {
// Don't stop polling - let it continue for background tracking
})
// Auto-stop polling when no active downloads for a while
watch(() => store.hasActiveDownloads, (hasActive) => {
if (!hasActive && !store.showDownloadsPanel) {
// Give a grace period before stopping
setTimeout(() => {
if (!store.hasActiveDownloads && !store.showDownloadsPanel) {
store.stopDownloadPolling()
}
}, 5000)
}
})
function formatBytes(bytes: number): string {
if (bytes >= 1024 * 1024 * 1024) return (bytes / (1024 * 1024 * 1024)).toFixed(1) + ' GB'
if (bytes >= 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
if (bytes >= 1024) return (bytes / 1024).toFixed(1) + ' KB'
return bytes + ' B'
}
function getStatusColor(status: string): string {
switch (status) {
case 'completed': return 'success'
case 'failed': return 'error'
case 'downloading': return 'primary'
default: return 'grey'
}
}
function getStatusIcon(status: string): string {
switch (status) {
case 'completed': return 'mdi-check-circle'
case 'failed': return 'mdi-alert-circle'
case 'downloading': return 'mdi-download'
default: return 'mdi-clock-outline'
}
}
</script>
<template>
<v-navigation-drawer
v-model="store.showDownloadsPanel"
location="right"
temporary
width="360"
>
<v-toolbar density="compact" color="surface">
<v-toolbar-title class="text-body-1">Downloads</v-toolbar-title>
<v-spacer />
<v-btn icon="mdi-close" variant="text" size="small" @click="store.showDownloadsPanel = false" />
</v-toolbar>
<v-list v-if="store.downloads.length > 0" density="compact">
<v-list-item
v-for="dl in store.downloads"
:key="dl.id"
:class="{ 'bg-surface-light': dl.status === 'downloading' }"
>
<template #prepend>
<v-icon :color="getStatusColor(dl.status)" class="mr-3">
{{ getStatusIcon(dl.status) }}
</v-icon>
</template>
<v-list-item-title class="text-body-2 font-weight-medium">
{{ dl.model_name || dl.filename }}
</v-list-item-title>
<v-list-item-subtitle class="text-caption">
{{ dl.version_name || '' }}
</v-list-item-subtitle>
<!-- Progress bar for active downloads -->
<div v-if="dl.status === 'downloading'" class="mt-2">
<v-progress-linear
:model-value="dl.progress || 0"
color="primary"
height="6"
rounded
/>
<div class="d-flex justify-space-between text-caption text-grey mt-1">
<span>{{ dl.downloaded_str || formatBytes(dl.downloaded || 0) }} / {{ dl.total_str || formatBytes(dl.total || 0) }}</span>
<span class="text-primary">{{ dl.speed_str || '' }}</span>
</div>
</div>
<!-- Queued status -->
<div v-else-if="dl.status === 'queued'" class="mt-2">
<v-progress-linear indeterminate color="grey" height="4" rounded />
<div class="text-caption text-grey mt-1">Queued...</div>
</div>
<!-- Completed status -->
<div v-else-if="dl.status === 'completed'" class="text-caption text-success mt-1">
Downloaded to {{ dl.filename }}
</div>
<!-- Failed status -->
<div v-else-if="dl.status === 'failed'" class="text-caption text-error mt-1">
{{ dl.error || 'Download failed' }}
</div>
</v-list-item>
</v-list>
<div v-else class="text-center text-grey pa-8">
<v-icon size="48" color="grey-darken-1">mdi-download-off</v-icon>
<p class="mt-4">No downloads</p>
</div>
</v-navigation-drawer>
</template>
+18 -63
View File
@@ -1,26 +1,19 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onUnmounted } from 'vue' import { ref, computed } from 'vue'
import type { CivitaiModel } from '@/types' import type { CivitaiModel } from '@/types'
import type { DownloadStatus } from '@/api/client'
import * as api from '@/api/client' import * as api from '@/api/client'
import { useAppStore } from '@/stores/app'
const props = defineProps<{ const props = defineProps<{
model: CivitaiModel model: CivitaiModel
}>() }>()
const store = useAppStore()
const showDialog = ref(false) const showDialog = ref(false)
const loadingDetails = ref(false) const loadingDetails = ref(false)
const modelDetails = ref<CivitaiModel | null>(null) const modelDetails = ref<CivitaiModel | null>(null)
// Download tracking
const activeDownload = ref<DownloadStatus | null>(null)
const downloadingVersionId = ref<number | null>(null)
let pollInterval: ReturnType<typeof setInterval> | null = null
onUnmounted(() => {
if (pollInterval) clearInterval(pollInterval)
})
const previewImage = computed(() => { const previewImage = computed(() => {
const version = props.model.modelVersions?.[0] const version = props.model.modelVersions?.[0]
return version?.images?.[0]?.url || '' return version?.images?.[0]?.url || ''
@@ -53,41 +46,12 @@ async function openDetails() {
} }
async function downloadVersion(versionId: number) { async function downloadVersion(versionId: number) {
if (downloadingVersionId.value === versionId) return // Already downloading if (store.isDownloading(versionId)) return // Already downloading
if (!confirm(`Download "${props.model.name}" to the server?`)) return if (!confirm(`Download "${props.model.name}" to the server?`)) return
try { const downloadId = await store.startDownload(versionId)
downloadingVersionId.value = versionId if (!downloadId) {
const response = await api.downloadModel(undefined, versionId) alert('Failed to start download')
// Start polling for progress
const downloadId = response.download_id
pollInterval = setInterval(async () => {
try {
const status = await api.getDownloadStatus(downloadId)
activeDownload.value = status
if (status.status === 'completed' || status.status === 'failed') {
if (pollInterval) clearInterval(pollInterval)
pollInterval = null
if (status.status === 'failed') {
alert('Download failed: ' + (status.error || 'Unknown error'))
}
// Keep showing completed state briefly, then clear
setTimeout(() => {
activeDownload.value = null
downloadingVersionId.value = null
}, 3000)
}
} catch (e) {
console.error('Failed to get download status:', e)
}
}, 500)
} catch (e: any) {
alert('Download failed: ' + e.message)
downloadingVersionId.value = null
} }
} }
</script> </script>
@@ -144,26 +108,17 @@ async function downloadVersion(versionId: number) {
</span> </span>
<v-spacer /> <v-spacer />
<!-- Download progress or button --> <!-- Download progress or button -->
<template v-if="downloadingVersionId === model.modelVersions?.[0]?.id && activeDownload"> <template v-if="store.isDownloading(model.modelVersions?.[0]?.id || 0)">
<div class="download-progress"> <div class="download-progress">
<v-progress-linear <v-progress-linear
:model-value="activeDownload.progress || 0" :model-value="store.getDownloadProgress(model.modelVersions?.[0]?.id || 0)?.progress || 0"
:indeterminate="activeDownload.status === 'queued'" :indeterminate="store.getDownloadProgress(model.modelVersions?.[0]?.id || 0)?.status === 'queued'"
:color="activeDownload.status === 'completed' ? 'success' : 'primary'" color="primary"
height="6" height="6"
rounded rounded
/> />
<span class="text-caption text-grey"> <span class="text-caption text-grey">
<template v-if="activeDownload.status === 'completed'"> {{ store.getDownloadProgress(model.modelVersions?.[0]?.id || 0)?.progress?.toFixed(0) || 0 }}%
Done!
</template>
<template v-else-if="activeDownload.status === 'queued'">
Queued...
</template>
<template v-else>
{{ activeDownload.downloaded_str }} / {{ activeDownload.total_str }}
<span class="text-primary ml-1">{{ activeDownload.speed_str }}</span>
</template>
</span> </span>
</div> </div>
</template> </template>
@@ -254,17 +209,17 @@ async function downloadVersion(versionId: number) {
<v-list-item-title>{{ version.name }}</v-list-item-title> <v-list-item-title>{{ version.name }}</v-list-item-title>
<v-list-item-subtitle>{{ version.baseModel }}</v-list-item-subtitle> <v-list-item-subtitle>{{ version.baseModel }}</v-list-item-subtitle>
<template #append> <template #append>
<template v-if="downloadingVersionId === version.id && activeDownload"> <template v-if="store.isDownloading(version.id)">
<div class="download-progress-dialog"> <div class="download-progress-dialog">
<v-progress-linear <v-progress-linear
:model-value="activeDownload.progress || 0" :model-value="store.getDownloadProgress(version.id)?.progress || 0"
:indeterminate="activeDownload.status === 'queued'" :indeterminate="store.getDownloadProgress(version.id)?.status === 'queued'"
:color="activeDownload.status === 'completed' ? 'success' : 'primary'" color="primary"
height="6" height="6"
rounded rounded
/> />
<span class="text-caption text-grey"> <span class="text-caption text-grey">
{{ activeDownload.progress?.toFixed(0) || 0 }}% {{ store.getDownloadProgress(version.id)?.progress?.toFixed(0) || 0 }}%
</span> </span>
</div> </div>
</template> </template>
+69
View File
@@ -1,12 +1,18 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import type { Model, LoRA, ResolutionPreset } from '@/types' import type { Model, LoRA, ResolutionPreset } from '@/types'
import type { DownloadStatus } from '@/api/client'
import * as api from '@/api/client' import * as api from '@/api/client'
export const useAppStore = defineStore('app', () => { export const useAppStore = defineStore('app', () => {
// Navigation // Navigation
const currentView = ref<'generate' | 'search' | 'gallery'>('generate') const currentView = ref<'generate' | 'search' | 'gallery'>('generate')
// Downloads
const downloads = ref<DownloadStatus[]>([])
const showDownloadsPanel = ref(false)
let downloadPollInterval: ReturnType<typeof setInterval> | null = null
// Models // Models
const models = ref<Model[]>([]) const models = ref<Model[]>([])
const loras = ref<LoRA[]>([]) const loras = ref<LoRA[]>([])
@@ -97,6 +103,57 @@ export const useAppStore = defineStore('app', () => {
} }
} }
// Downloads - computed
const activeDownloads = computed(() =>
downloads.value.filter(d => d.status === 'downloading' || d.status === 'queued')
)
const hasActiveDownloads = computed(() => activeDownloads.value.length > 0)
// Downloads - actions
async function pollDownloads() {
try {
const res = await api.getActiveDownloads()
downloads.value = res.downloads || []
} catch (e) {
console.error('Failed to poll downloads:', e)
}
}
function startDownloadPolling() {
if (downloadPollInterval) return
pollDownloads() // Initial fetch
downloadPollInterval = setInterval(pollDownloads, 1000)
}
function stopDownloadPolling() {
if (downloadPollInterval) {
clearInterval(downloadPollInterval)
downloadPollInterval = null
}
}
async function startDownload(versionId: number): Promise<string | null> {
try {
const response = await api.downloadModel(undefined, versionId)
startDownloadPolling()
showDownloadsPanel.value = true
return response.download_id
} catch (e: any) {
console.error('Failed to start download:', e)
return null
}
}
function isDownloading(versionId: number): boolean {
return downloads.value.some(
d => d.version_id === versionId && (d.status === 'downloading' || d.status === 'queued')
)
}
function getDownloadProgress(versionId: number): DownloadStatus | undefined {
return downloads.value.find(d => d.version_id === versionId)
}
async function switchModel(modelPath: string) { async function switchModel(modelPath: string) {
if (modelPath === activeModel.value) return if (modelPath === activeModel.value) return
@@ -169,8 +226,20 @@ export const useAppStore = defineStore('app', () => {
switchMessage, switchMessage,
switchError, switchError,
// Downloads
downloads,
activeDownloads,
hasActiveDownloads,
showDownloadsPanel,
// Actions // Actions
loadModels, loadModels,
switchModel, switchModel,
startDownload,
pollDownloads,
startDownloadPolling,
stopDownloadPolling,
isDownloading,
getDownloadProgress,
} }
}) })