💬 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" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tensors</title>
<script type="module" crossorigin src="/assets/index-CKJOpgtQ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BQdjF_w0.css">
<script type="module" crossorigin src="/assets/index-DEHUU-Zz.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Ljwp9hgM.css">
</head>
<body>
<div id="app"></div>
+24
View File
@@ -4,11 +4,13 @@ import { useAppStore } from '@/stores/app'
import GenerateView from '@/components/GenerateView.vue'
import SearchView from '@/components/SearchView.vue'
import GalleryView from '@/components/GalleryView.vue'
import DownloadsPanel from '@/components/DownloadsPanel.vue'
const store = useAppStore()
onMounted(() => {
store.loadModels()
store.pollDownloads() // Check for any active downloads on load
})
</script>
@@ -34,9 +36,31 @@ onMounted(() => {
prepend-icon="mdi-image-multiple"
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-navigation-drawer>
<DownloadsPanel />
<v-main>
<GenerateView v-if="store.currentView === 'generate'" />
<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">
import { ref, computed, onUnmounted } from 'vue'
import { ref, computed } from 'vue'
import type { CivitaiModel } from '@/types'
import type { DownloadStatus } from '@/api/client'
import * as api from '@/api/client'
import { useAppStore } from '@/stores/app'
const props = defineProps<{
model: CivitaiModel
}>()
const store = useAppStore()
const showDialog = ref(false)
const loadingDetails = ref(false)
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 version = props.model.modelVersions?.[0]
return version?.images?.[0]?.url || ''
@@ -53,41 +46,12 @@ async function openDetails() {
}
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
try {
downloadingVersionId.value = versionId
const response = await api.downloadModel(undefined, versionId)
// 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
const downloadId = await store.startDownload(versionId)
if (!downloadId) {
alert('Failed to start download')
}
}
</script>
@@ -144,26 +108,17 @@ async function downloadVersion(versionId: number) {
</span>
<v-spacer />
<!-- 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">
<v-progress-linear
:model-value="activeDownload.progress || 0"
:indeterminate="activeDownload.status === 'queued'"
:color="activeDownload.status === 'completed' ? 'success' : 'primary'"
:model-value="store.getDownloadProgress(model.modelVersions?.[0]?.id || 0)?.progress || 0"
:indeterminate="store.getDownloadProgress(model.modelVersions?.[0]?.id || 0)?.status === 'queued'"
color="primary"
height="6"
rounded
/>
<span class="text-caption text-grey">
<template v-if="activeDownload.status === 'completed'">
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>
{{ store.getDownloadProgress(model.modelVersions?.[0]?.id || 0)?.progress?.toFixed(0) || 0 }}%
</span>
</div>
</template>
@@ -254,17 +209,17 @@ async function downloadVersion(versionId: number) {
<v-list-item-title>{{ version.name }}</v-list-item-title>
<v-list-item-subtitle>{{ version.baseModel }}</v-list-item-subtitle>
<template #append>
<template v-if="downloadingVersionId === version.id && activeDownload">
<template v-if="store.isDownloading(version.id)">
<div class="download-progress-dialog">
<v-progress-linear
:model-value="activeDownload.progress || 0"
:indeterminate="activeDownload.status === 'queued'"
:color="activeDownload.status === 'completed' ? 'success' : 'primary'"
:model-value="store.getDownloadProgress(version.id)?.progress || 0"
:indeterminate="store.getDownloadProgress(version.id)?.status === 'queued'"
color="primary"
height="6"
rounded
/>
<span class="text-caption text-grey">
{{ activeDownload.progress?.toFixed(0) || 0 }}%
{{ store.getDownloadProgress(version.id)?.progress?.toFixed(0) || 0 }}%
</span>
</div>
</template>
+69
View File
@@ -1,12 +1,18 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { Model, LoRA, ResolutionPreset } from '@/types'
import type { DownloadStatus } from '@/api/client'
import * as api from '@/api/client'
export const useAppStore = defineStore('app', () => {
// Navigation
const currentView = ref<'generate' | 'search' | 'gallery'>('generate')
// Downloads
const downloads = ref<DownloadStatus[]>([])
const showDownloadsPanel = ref(false)
let downloadPollInterval: ReturnType<typeof setInterval> | null = null
// Models
const models = ref<Model[]>([])
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) {
if (modelPath === activeModel.value) return
@@ -169,8 +226,20 @@ export const useAppStore = defineStore('app', () => {
switchMessage,
switchError,
// Downloads
downloads,
activeDownloads,
hasActiveDownloads,
showDownloadsPanel,
// Actions
loadModels,
switchModel,
startDownload,
pollDownloads,
startDownloadPolling,
stopDownloadPolling,
isDownloading,
getDownloadProgress,
}
})