💬 Commit message: Update 2026-02-14 04:18:54, 32 files, 4255 lines

📁 Files changed: 32
📝 Lines changed: 4255

  • .coverage
  • __init__.py
  • index-BvuF0jag.css
  • index-CX4x_bxc.js
  • materialdesignicons-webfont-B7mPwVP_.ttf
  • materialdesignicons-webfont-CSr8KVlo.eot
  • materialdesignicons-webfont-Dp5v-WZN.woff2
  • materialdesignicons-webfont-PXm3-2wK.woff
  • index.html
  • vite.svg
  • .gitignore
  • extensions.json
  • README.md
  • index.html
  • package-lock.json
  • package.json
  • vite.svg
  • App.vue
  • client.ts
  • vue.svg
  • GalleryView.vue
  • GenerateView.vue
  • ModelCard.vue
  • SearchView.vue
  • main.ts
  • app.ts
  • index.ts
  • vite-env.d.ts
  • tsconfig.app.json
  • tsconfig.json
  • tsconfig.node.json
  • vite.config.ts
This commit is contained in:
Adam Ladachowski
2026-02-14 04:18:54 +01:00
parent 52b39a4f6b
commit 5721ce223b
32 changed files with 3060 additions and 1195 deletions
+14 -2
View File
@@ -10,6 +10,7 @@ from typing import TYPE_CHECKING
import httpx
from fastapi import FastAPI
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from tensors.server.civitai_routes import create_civitai_router
from tensors.server.db_routes import create_db_router
@@ -24,7 +25,7 @@ from tensors.server.routes import create_router
if TYPE_CHECKING:
from collections.abc import AsyncIterator
__all__ = ["ProcessManager", "ServerConfig", "create_app"]
__all__ = ["ProcessManager", "ServerConfig", "app", "create_app"]
logger = logging.getLogger(__name__)
@@ -50,13 +51,20 @@ def create_app(config: ServerConfig | None = None) -> FastAPI:
app = FastAPI(title="sd-server wrapper", lifespan=lifespan)
# Serve gallery UI at root
# Serve Vue UI static files
static_dir = Path(__file__).parent / "static"
assets_dir = static_dir / "assets"
if assets_dir.exists():
app.mount("/assets", StaticFiles(directory=assets_dir), name="assets")
@app.get("/", include_in_schema=False)
async def gallery_ui() -> FileResponse:
return FileResponse(static_dir / "index.html")
@app.get("/vite.svg", include_in_schema=False)
async def vite_icon() -> FileResponse:
return FileResponse(static_dir / "vite.svg")
app.include_router(create_civitai_router()) # Must be before catch-all proxy
app.include_router(create_db_router()) # Must be before catch-all proxy
app.include_router(create_gallery_router()) # Must be before catch-all proxy
@@ -66,3 +74,7 @@ def create_app(config: ServerConfig | None = None) -> FastAPI:
app.include_router(create_router(pm))
app.state.pm = pm
return app
# Module-level app instance for uvicorn
app = create_app()
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 it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+3
View File
@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}
+5
View File
@@ -0,0 +1,5 @@
# Vue 3 + TypeScript + Vite
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-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>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+1663
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
{
"name": "ui",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@mdi/font": "^7.4.47",
"pinia": "^3.0.4",
"vite-plugin-vuetify": "^2.1.3",
"vue": "^3.5.25",
"vuetify": "^3.11.8"
},
"devDependencies": {
"@types/node": "^24.10.1",
"@vitejs/plugin-vue": "^6.0.2",
"@vue/tsconfig": "^0.8.1",
"typescript": "~5.9.3",
"vite": "^7.3.1",
"vue-tsc": "^3.1.5"
}
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+52
View File
@@ -0,0 +1,52 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { useAppStore } from '@/stores/app'
import GenerateView from '@/components/GenerateView.vue'
import SearchView from '@/components/SearchView.vue'
import GalleryView from '@/components/GalleryView.vue'
const store = useAppStore()
onMounted(() => {
store.loadModels()
})
</script>
<template>
<v-app>
<v-navigation-drawer permanent rail>
<v-list density="compact" nav>
<v-list-item
:active="store.currentView === 'generate'"
@click="store.currentView = 'generate'"
prepend-icon="mdi-auto-fix"
title="Generate"
/>
<v-list-item
:active="store.currentView === 'search'"
@click="store.currentView = 'search'"
prepend-icon="mdi-magnify"
title="Search"
/>
<v-list-item
:active="store.currentView === 'gallery'"
@click="store.currentView = 'gallery'"
prepend-icon="mdi-image-multiple"
title="Gallery"
/>
</v-list>
</v-navigation-drawer>
<v-main>
<GenerateView v-if="store.currentView === 'generate'" />
<SearchView v-else-if="store.currentView === 'search'" />
<GalleryView v-else-if="store.currentView === 'gallery'" />
</v-main>
</v-app>
</template>
<style>
html, body {
overflow: hidden;
}
</style>
+102
View File
@@ -0,0 +1,102 @@
import type { Model, LoRA, GeneratedImage, GalleryImage, CivitaiModel } from '@/types'
const BASE_URL = ''
async function fetchJson<T>(url: string, options?: RequestInit): Promise<T> {
const response = await fetch(`${BASE_URL}${url}`, {
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers,
},
})
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }))
throw new Error(error.error || response.statusText)
}
return response.json()
}
// Models
export async function getModels(): Promise<{ models: Model[]; total: number }> {
return fetchJson('/api/models')
}
export async function getActiveModel(): Promise<{ loaded: boolean; model: string | null }> {
return fetchJson('/api/models/active')
}
export async function switchModel(model: string): Promise<{ ok: boolean }> {
return fetchJson('/api/models/switch', {
method: 'POST',
body: JSON.stringify({ model }),
})
}
export async function getLoras(): Promise<{ loras: LoRA[]; total: number }> {
return fetchJson('/api/models/loras')
}
// Generation
export interface GenerateParams {
prompt: string
negative_prompt?: string
width: number
height: number
steps: number
cfg_scale?: number
seed?: number
save_to_gallery?: boolean
}
export async function generate(params: GenerateParams): Promise<{ images: GeneratedImage[] }> {
return fetchJson('/api/generate', {
method: 'POST',
body: JSON.stringify(params),
})
}
// Gallery
export async function getImages(limit = 100): Promise<{ images: GalleryImage[] }> {
return fetchJson(`/api/images?limit=${limit}`)
}
export async function deleteImage(id: string): Promise<void> {
await fetchJson(`/api/images/${id}`, { method: 'DELETE' })
}
export function getImageUrl(id: string): string {
return `${BASE_URL}/api/images/${id}`
}
// CivitAI Search
export interface SearchParams {
query?: string
types?: string
baseModels?: string
sort?: string
limit?: number
}
export async function searchCivitai(params: SearchParams): Promise<{ items: CivitaiModel[] }> {
const searchParams = new URLSearchParams()
if (params.query) searchParams.set('query', params.query)
if (params.types) searchParams.set('types', params.types)
if (params.baseModels) searchParams.set('baseModels', params.baseModels)
if (params.sort) searchParams.set('sort', params.sort)
if (params.limit) searchParams.set('limit', String(params.limit))
return fetchJson(`/api/civitai/search?${searchParams}`)
}
export async function getCivitaiModel(id: number): Promise<CivitaiModel> {
return fetchJson(`/api/civitai/model/${id}`)
}
// Download
export async function downloadModel(modelId?: number, versionId?: number): Promise<{ ok: boolean }> {
return fetchJson('/api/download', {
method: 'POST',
body: JSON.stringify({ model_id: modelId, version_id: versionId }),
})
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

@@ -0,0 +1,169 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import * as api from '@/api/client'
import type { GalleryImage } from '@/types'
const images = ref<GalleryImage[]>([])
const loading = ref(true)
const selectedImage = ref<string | null>(null)
const showLightbox = ref(false)
async function loadImages() {
loading.value = true
try {
const data = await api.getImages(100)
images.value = data.images || []
} catch (e) {
console.error('Failed to load gallery:', e)
} finally {
loading.value = false
}
}
function openLightbox(id: string) {
selectedImage.value = id
showLightbox.value = true
}
function closeLightbox() {
showLightbox.value = false
selectedImage.value = null
}
function navigate(direction: 'prev' | 'next') {
if (!selectedImage.value) return
const currentIndex = images.value.findIndex(img => img.id === selectedImage.value)
if (currentIndex === -1) return
let newIndex: number
if (direction === 'prev') {
newIndex = currentIndex > 0 ? currentIndex - 1 : images.value.length - 1
} else {
newIndex = currentIndex < images.value.length - 1 ? currentIndex + 1 : 0
}
selectedImage.value = images.value[newIndex]?.id ?? null
}
async function deleteImage(id: string) {
if (!confirm('Delete this image?')) return
try {
await api.deleteImage(id)
images.value = images.value.filter(img => img.id !== id)
if (selectedImage.value === id) {
closeLightbox()
}
} catch (e: any) {
alert('Failed to delete: ' + e.message)
}
}
onMounted(loadImages)
</script>
<template>
<v-container fluid class="fill-height pa-0">
<div v-if="loading" class="d-flex align-center justify-center fill-height w-100">
<v-progress-circular indeterminate color="primary" size="64" />
</div>
<div v-else-if="images.length === 0" class="d-flex flex-column align-center justify-center fill-height w-100 text-grey">
<v-icon size="64" color="grey-darken-1">mdi-image-multiple</v-icon>
<p class="mt-4">No images yet</p>
</div>
<v-container v-else fluid class="pa-4 overflow-y-auto">
<v-row>
<v-col
v-for="img in images"
:key="img.id"
cols="6"
sm="4"
md="3"
lg="2"
>
<v-card
class="gallery-card"
@click="openLightbox(img.id)"
>
<v-img
:src="api.getImageUrl(img.id)"
aspect-ratio="1"
cover
class="bg-grey-darken-3"
>
<template #placeholder>
<div class="d-flex align-center justify-center fill-height">
<v-progress-circular indeterminate color="primary" size="24" />
</div>
</template>
</v-img>
</v-card>
</v-col>
</v-row>
</v-container>
<!-- Lightbox -->
<v-dialog
v-model="showLightbox"
fullscreen
transition="fade-transition"
>
<v-card class="bg-black d-flex flex-column">
<v-toolbar color="transparent" density="compact">
<v-spacer />
<v-btn
icon="mdi-delete"
variant="text"
@click="selectedImage && deleteImage(selectedImage)"
/>
<v-btn
icon="mdi-close"
variant="text"
@click="closeLightbox"
/>
</v-toolbar>
<div class="flex-grow-1 d-flex align-center justify-center position-relative">
<v-btn
icon="mdi-chevron-left"
variant="text"
size="x-large"
class="position-absolute"
style="left: 16px"
@click="navigate('prev')"
/>
<v-img
v-if="selectedImage"
:src="api.getImageUrl(selectedImage)"
max-height="90vh"
max-width="90vw"
contain
/>
<v-btn
icon="mdi-chevron-right"
variant="text"
size="x-large"
class="position-absolute"
style="right: 16px"
@click="navigate('next')"
/>
</div>
</v-card>
</v-dialog>
</v-container>
</template>
<style scoped>
.gallery-card {
cursor: pointer;
transition: transform 0.2s, border-color 0.2s;
border: 1px solid transparent;
}
.gallery-card:hover {
transform: scale(1.03);
border-color: rgb(var(--v-theme-primary));
}
</style>
@@ -0,0 +1,265 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useAppStore } from '@/stores/app'
import * as api from '@/api/client'
import type { GeneratedImage } from '@/types'
const store = useAppStore()
const prompt = ref('')
const generating = ref(false)
interface ChatMessage {
prompt: string
params: string
images: GeneratedImage[]
error?: string
loading: boolean
}
const messages = ref<ChatMessage[]>([])
const modelItems = computed(() =>
store.models.map(m => ({ title: m.name, value: m.path }))
)
const loraItems = computed(() => [
{ title: 'None', value: '' },
...store.loras.map(l => ({ title: l.name, value: l.path }))
])
const baseSizes = [
{ title: '512', value: 512 },
{ title: '768', value: 768 },
{ title: '1024', value: 1024 },
] as const
const aspectRatios = [
{ title: '3:4', value: '3:4' as const },
{ title: '1:1', value: '1:1' as const },
{ title: '4:3', value: '4:3' as const },
]
async function handleModelChange(model: string) {
if (model && model !== store.activeModel) {
try {
await store.switchModel(model)
} catch (e) {
console.error(e)
}
}
}
async function generate() {
if (!prompt.value.trim() || generating.value) return
const currentPrompt = prompt.value.trim()
prompt.value = ''
generating.value = true
// Build final prompt with LoRA
let finalPrompt = currentPrompt
if (store.selectedLora) {
const loraName = store.loras.find(l => l.path === store.selectedLora)?.name
if (loraName) {
finalPrompt = `<lora:${loraName}:${store.loraWeight}> ${currentPrompt}`
}
}
const { width, height } = store.resolution
const paramsStr = `${width}×${height}, ${store.steps} steps${store.batchSize > 1 ? `, batch ${store.batchSize}` : ''}${store.selectedLora ? ', +LoRA' : ''}`
const message: ChatMessage = {
prompt: currentPrompt,
params: paramsStr,
images: [],
loading: true,
}
messages.value.push(message)
try {
for (let i = 0; i < store.batchSize; i++) {
const result = await api.generate({
prompt: finalPrompt,
width,
height,
steps: store.steps,
seed: -1,
save_to_gallery: true,
})
message.images.push(...result.images)
}
} catch (e: any) {
message.error = e.message || 'Generation failed'
} finally {
message.loading = false
generating.value = false
}
}
</script>
<template>
<v-container fluid class="fill-height pa-0 d-flex flex-column">
<!-- Chat area -->
<v-container fluid class="flex-grow-1 overflow-y-auto pa-4">
<div v-if="messages.length === 0" class="text-center text-grey mt-16">
<v-icon size="64" color="grey-darken-1">mdi-auto-fix</v-icon>
<p class="mt-4">Enter a prompt to generate images</p>
</div>
<div v-for="(msg, idx) in messages" :key="idx" class="mb-6">
<v-chip color="primary" variant="tonal" class="mb-3">
<span class="font-weight-medium">{{ msg.prompt }}</span>
<span class="text-grey ml-2 text-caption">[{{ msg.params }}]</span>
</v-chip>
<div class="d-flex flex-wrap ga-3">
<template v-if="msg.loading">
<v-card
v-for="i in store.batchSize - msg.images.length"
:key="'loading-' + i"
width="200"
height="200"
class="d-flex align-center justify-center"
>
<v-progress-circular indeterminate color="primary" />
</v-card>
</template>
<v-card
v-for="img in msg.images"
:key="img.id"
width="200"
height="200"
class="overflow-hidden"
>
<v-img
:src="api.getImageUrl(img.id)"
cover
height="200"
/>
</v-card>
<v-alert v-if="msg.error" type="error" density="compact">
{{ msg.error }}
</v-alert>
</div>
</div>
</v-container>
<!-- Controls -->
<v-sheet class="border-t px-4 py-3">
<div class="d-flex flex-wrap align-center justify-center ga-4 mb-3">
<div class="d-flex align-center ga-2">
<span class="text-caption text-grey text-uppercase">Model</span>
<v-select
v-model="store.selectedModel"
:items="modelItems"
:loading="store.switchingModel"
:disabled="store.switchingModel || generating"
density="compact"
hide-details
style="min-width: 180px"
@update:model-value="handleModelChange"
/>
</div>
<div class="d-flex align-center ga-2">
<span class="text-caption text-grey text-uppercase">LoRA</span>
<v-select
v-model="store.selectedLora"
:items="loraItems"
:disabled="generating"
density="compact"
hide-details
style="min-width: 150px"
/>
<v-text-field
v-model.number="store.loraWeight"
type="number"
min="0"
max="2"
step="0.1"
density="compact"
hide-details
style="width: 70px"
:disabled="!store.selectedLora || generating"
/>
</div>
<div class="d-flex align-center ga-2">
<span class="text-caption text-grey text-uppercase">Size</span>
<v-btn-toggle v-model="store.baseSize" mandatory density="compact" :disabled="generating">
<v-btn v-for="s in baseSizes" :key="s.value" :value="s.value" size="small">
{{ s.title }}
</v-btn>
</v-btn-toggle>
</div>
<div class="d-flex align-center ga-2">
<span class="text-caption text-grey text-uppercase">Ratio</span>
<v-btn-toggle v-model="store.aspectRatio" mandatory density="compact" :disabled="generating">
<v-btn v-for="r in aspectRatios" :key="r.value" :value="r.value" size="small">
{{ r.title }}
</v-btn>
</v-btn-toggle>
</div>
<div class="d-flex align-center ga-2">
<span class="text-caption text-grey text-uppercase">Steps</span>
<v-text-field
v-model.number="store.steps"
type="number"
min="1"
max="50"
density="compact"
hide-details
style="width: 70px"
:disabled="generating"
/>
</div>
<div class="d-flex align-center ga-2">
<span class="text-caption text-grey text-uppercase">Batch</span>
<v-text-field
v-model.number="store.batchSize"
type="number"
min="1"
max="8"
density="compact"
hide-details
style="width: 70px"
:disabled="generating"
/>
</div>
</div>
<!-- Prompt input -->
<div class="d-flex ga-3 mx-auto" style="max-width: 800px">
<v-text-field
v-model="prompt"
placeholder="Describe what you want to generate..."
density="comfortable"
hide-details
:disabled="generating"
@keydown.enter="generate"
/>
<v-btn
color="secondary"
size="large"
:loading="generating"
:disabled="!prompt.trim()"
@click="generate"
>
Generate
</v-btn>
</div>
</v-sheet>
</v-container>
</template>
<style scoped>
.border-t {
border-top: 1px solid rgba(255, 255, 255, 0.12);
}
</style>
@@ -0,0 +1,227 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { CivitaiModel } from '@/types'
import * as api from '@/api/client'
const props = defineProps<{
model: CivitaiModel
}>()
const showDialog = ref(false)
const loadingDetails = ref(false)
const modelDetails = ref<CivitaiModel | null>(null)
const previewImage = computed(() => {
const version = props.model.modelVersions?.[0]
return version?.images?.[0]?.url || ''
})
const baseModel = computed(() => {
return props.model.modelVersions?.[0]?.baseModel || ''
})
const trainedWords = computed(() => {
return modelDetails.value?.modelVersions?.[0]?.trainedWords || []
})
function formatNumber(n: number): string {
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M'
if (n >= 1000) return (n / 1000).toFixed(1) + 'K'
return String(n)
}
async function openDetails() {
showDialog.value = true
loadingDetails.value = true
try {
modelDetails.value = await api.getCivitaiModel(props.model.id)
} catch (e) {
console.error('Failed to load model details:', e)
} finally {
loadingDetails.value = false
}
}
async function downloadVersion(versionId: number) {
if (!confirm(`Download "${props.model.name}" to the server?`)) return
try {
await api.downloadModel(undefined, versionId)
alert('Download started! Check server for progress.')
} catch (e: any) {
alert('Download failed: ' + e.message)
}
}
</script>
<template>
<v-card class="model-card" @click="openDetails">
<v-img
:src="previewImage"
height="180"
cover
class="bg-grey-darken-3"
>
<template #placeholder>
<div class="d-flex align-center justify-center fill-height">
<v-icon size="48" color="grey">mdi-image</v-icon>
</div>
</template>
</v-img>
<v-card-text class="pb-2">
<h4 class="text-body-1 font-weight-medium mb-2 text-truncate-2">
{{ model.name }}
</h4>
<div class="d-flex flex-wrap ga-1 mb-2">
<v-chip size="x-small" color="primary" variant="flat">
{{ model.type }}
</v-chip>
<v-chip v-if="baseModel" size="x-small" variant="outlined">
{{ baseModel }}
</v-chip>
<v-chip v-if="model.nsfw" size="x-small" color="error" variant="flat">
NSFW
</v-chip>
</div>
<div class="d-flex ga-3 text-caption text-grey">
<span class="d-flex align-center ga-1">
<v-icon size="14">mdi-download</v-icon>
{{ formatNumber(model.stats?.downloadCount || 0) }}
</span>
<span class="d-flex align-center ga-1">
<v-icon size="14">mdi-thumb-up</v-icon>
{{ formatNumber(model.stats?.thumbsUpCount || 0) }}
</span>
</div>
</v-card-text>
<v-divider />
<v-card-actions class="px-3">
<span class="text-caption text-grey">
by {{ model.creator?.username || 'Unknown' }}
</span>
<v-spacer />
<v-btn
size="small"
color="primary"
variant="flat"
@click.stop="downloadVersion(model.modelVersions?.[0]?.id || 0)"
>
Download
</v-btn>
</v-card-actions>
</v-card>
<!-- Detail Dialog -->
<v-dialog v-model="showDialog" max-width="600">
<v-card>
<v-card-title class="d-flex align-center">
{{ model.name }}
<v-spacer />
<v-btn icon="mdi-close" variant="text" @click="showDialog = false" />
</v-card-title>
<v-card-text>
<div v-if="loadingDetails" class="text-center py-8">
<v-progress-circular indeterminate color="primary" />
</div>
<template v-else-if="modelDetails">
<!-- Preview images -->
<div v-if="modelDetails.modelVersions?.[0]?.images?.length" class="mb-4">
<div class="d-flex ga-2 overflow-x-auto pb-2">
<v-img
v-for="(img, idx) in modelDetails.modelVersions[0].images.slice(0, 6)"
:key="idx"
:src="img.url"
width="120"
height="120"
cover
class="rounded flex-shrink-0"
/>
</div>
</div>
<!-- Info -->
<v-list density="compact">
<v-list-item>
<template #prepend><span class="text-grey mr-4">Type</span></template>
{{ modelDetails.type }}
</v-list-item>
<v-list-item>
<template #prepend><span class="text-grey mr-4">Creator</span></template>
{{ modelDetails.creator?.username || 'Unknown' }}
</v-list-item>
<v-list-item>
<template #prepend><span class="text-grey mr-4">Downloads</span></template>
{{ formatNumber(modelDetails.stats?.downloadCount || 0) }}
</v-list-item>
<v-list-item>
<template #prepend><span class="text-grey mr-4">Rating</span></template>
{{ formatNumber(modelDetails.stats?.thumbsUpCount || 0) }} likes
</v-list-item>
</v-list>
<!-- Trigger words -->
<div v-if="trainedWords.length" class="mt-4">
<h4 class="text-caption text-grey mb-2">TRIGGER WORDS</h4>
<v-chip
v-for="word in trainedWords"
:key="word"
size="small"
class="mr-1 mb-1"
>
{{ word }}
</v-chip>
</div>
<!-- Versions -->
<div v-if="modelDetails.modelVersions?.length" class="mt-4">
<h4 class="text-caption text-grey mb-2">VERSIONS</h4>
<v-list density="compact" class="bg-transparent">
<v-list-item
v-for="version in modelDetails.modelVersions.slice(0, 5)"
:key="version.id"
class="px-0"
>
<v-list-item-title>{{ version.name }}</v-list-item-title>
<v-list-item-subtitle>{{ version.baseModel }}</v-list-item-subtitle>
<template #append>
<v-btn
size="small"
color="primary"
variant="flat"
@click="downloadVersion(version.id)"
>
Download
</v-btn>
</template>
</v-list-item>
</v-list>
</div>
</template>
</v-card-text>
</v-card>
</v-dialog>
</template>
<style scoped>
.model-card {
cursor: pointer;
transition: transform 0.2s, border-color 0.2s;
}
.model-card:hover {
transform: translateY(-2px);
border-color: rgb(var(--v-theme-primary));
}
.text-truncate-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>
@@ -0,0 +1,144 @@
<script setup lang="ts">
import { ref } from 'vue'
import * as api from '@/api/client'
import type { CivitaiModel } from '@/types'
import ModelCard from './ModelCard.vue'
const query = ref('')
const modelType = ref('')
const baseModel = ref('')
const sortOrder = ref('Most Downloaded')
const loading = ref(false)
const results = ref<CivitaiModel[]>([])
const searched = ref(false)
const modelTypes = [
{ title: 'All Types', value: '' },
{ title: 'Checkpoint', value: 'Checkpoint' },
{ title: 'LoRA', value: 'LORA' },
{ title: 'LoCon', value: 'LoCon' },
{ title: 'Embedding', value: 'TextualInversion' },
{ title: 'VAE', value: 'VAE' },
{ title: 'ControlNet', value: 'Controlnet' },
]
const baseModels = [
{ title: 'All Base Models', value: '' },
{ title: 'SD 1.5', value: 'SD 1.5' },
{ title: 'SDXL', value: 'SDXL 1.0' },
{ title: 'Pony', value: 'Pony' },
{ title: 'Illustrious', value: 'Illustrious' },
{ title: 'Flux', value: 'Flux.1 D' },
]
const sortOptions = [
{ title: 'Most Downloaded', value: 'Most Downloaded' },
{ title: 'Highest Rated', value: 'Highest Rated' },
{ title: 'Newest', value: 'Newest' },
]
async function search() {
loading.value = true
searched.value = true
try {
const data = await api.searchCivitai({
query: query.value || undefined,
types: modelType.value || undefined,
baseModels: baseModel.value || undefined,
sort: sortOrder.value,
limit: 24,
})
results.value = data.items || []
} catch (e: any) {
console.error('Search failed:', e)
results.value = []
} finally {
loading.value = false
}
}
</script>
<template>
<v-container fluid class="fill-height pa-0 d-flex flex-column">
<!-- Search header -->
<v-sheet class="border-b pa-4">
<div class="d-flex flex-wrap align-center justify-center ga-3 mx-auto" style="max-width: 1000px">
<v-text-field
v-model="query"
placeholder="Search CivitAI models..."
prepend-inner-icon="mdi-magnify"
density="compact"
hide-details
clearable
style="min-width: 250px; flex: 1"
@keydown.enter="search"
/>
<v-select
v-model="modelType"
:items="modelTypes"
density="compact"
hide-details
style="min-width: 140px"
/>
<v-select
v-model="baseModel"
:items="baseModels"
density="compact"
hide-details
style="min-width: 150px"
/>
<v-select
v-model="sortOrder"
:items="sortOptions"
density="compact"
hide-details
style="min-width: 160px"
/>
<v-btn color="primary" :loading="loading" @click="search">
Search
</v-btn>
</div>
</v-sheet>
<!-- Results -->
<v-container fluid class="flex-grow-1 overflow-y-auto pa-4">
<div v-if="!searched" class="text-center text-grey mt-16">
<v-icon size="64" color="grey-darken-1">mdi-magnify</v-icon>
<p class="mt-4">Search for models on CivitAI</p>
</div>
<div v-else-if="loading" class="text-center mt-16">
<v-progress-circular indeterminate color="primary" size="64" />
<p class="mt-4 text-grey">Searching...</p>
</div>
<div v-else-if="results.length === 0" class="text-center text-grey mt-16">
<v-icon size="64" color="grey-darken-1">mdi-magnify-close</v-icon>
<p class="mt-4">No models found</p>
</div>
<v-row v-else>
<v-col
v-for="model in results"
:key="model.id"
cols="12"
sm="6"
md="4"
lg="3"
>
<ModelCard :model="model" />
</v-col>
</v-row>
</v-container>
</v-container>
</template>
<style scoped>
.border-b {
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
}
</style>
+43
View File
@@ -0,0 +1,43 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
// Vuetify
import 'vuetify/styles'
import '@mdi/font/css/materialdesignicons.css'
import { createVuetify } from 'vuetify'
const vuetify = createVuetify({
theme: {
defaultTheme: 'dark',
themes: {
dark: {
colors: {
background: '#0f0f0f',
surface: '#1a1a1a',
primary: '#4ade80',
secondary: '#dc2626',
error: '#dc2626',
}
}
}
},
defaults: {
VBtn: {
variant: 'flat',
},
VTextField: {
variant: 'outlined',
density: 'comfortable',
},
VSelect: {
variant: 'outlined',
density: 'comfortable',
},
}
})
const app = createApp(App)
app.use(createPinia())
app.use(vuetify)
app.mount('#app')
+105
View File
@@ -0,0 +1,105 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { Model, LoRA, AspectRatio, BaseSize } from '@/types'
import * as api from '@/api/client'
export const useAppStore = defineStore('app', () => {
// Navigation
const currentView = ref<'generate' | 'search' | 'gallery'>('generate')
// Models
const models = ref<Model[]>([])
const loras = ref<LoRA[]>([])
const activeModel = ref<string | null>(null)
const selectedModel = ref<string>('')
const selectedLora = ref<string>('')
const loraWeight = ref(0.8)
// Generation settings
const baseSize = ref<BaseSize>(768)
const aspectRatio = ref<AspectRatio>('1:1')
const steps = ref(20)
const batchSize = ref(1)
// Resolution computation
const resolutions: Record<BaseSize, Record<AspectRatio, [number, number]>> = {
512: { '1:1': [512, 512], '4:3': [512, 384], '3:4': [384, 512] },
768: { '1:1': [768, 768], '4:3': [768, 576], '3:4': [576, 768] },
1024: { '1:1': [1024, 1024], '4:3': [1024, 768], '3:4': [768, 1024] },
}
const resolution = computed(() => {
const [width, height] = resolutions[baseSize.value][aspectRatio.value]
return { width, height }
})
// Loading states
const loadingModels = ref(false)
const switchingModel = ref(false)
// Actions
async function loadModels() {
loadingModels.value = true
try {
const [modelsRes, lorasRes, activeRes] = await Promise.all([
api.getModels(),
api.getLoras(),
api.getActiveModel(),
])
models.value = modelsRes.models
loras.value = lorasRes.loras
activeModel.value = activeRes.model
if (activeRes.model) {
selectedModel.value = activeRes.model
}
} catch (error) {
console.error('Failed to load models:', error)
} finally {
loadingModels.value = false
}
}
async function switchModel(modelPath: string) {
if (modelPath === activeModel.value) return
switchingModel.value = true
try {
await api.switchModel(modelPath)
activeModel.value = modelPath
selectedModel.value = modelPath
} catch (error) {
console.error('Failed to switch model:', error)
throw error
} finally {
switchingModel.value = false
}
}
return {
// Navigation
currentView,
// Models
models,
loras,
activeModel,
selectedModel,
selectedLora,
loraWeight,
// Generation settings
baseSize,
aspectRatio,
steps,
batchSize,
resolution,
// Loading states
loadingModels,
switchingModel,
// Actions
loadModels,
switchModel,
}
})
+81
View File
@@ -0,0 +1,81 @@
export interface Model {
name: string
path: string
filename: string
size_mb: number
modified: number
}
export interface LoRA {
name: string
path: string
filename: string
size_mb: number
modified: number
}
export interface GeneratedImage {
id: string
path: string
seed: number
}
export interface GalleryImage {
id: string
filename: string
created: string
metadata?: ImageMetadata
}
export interface ImageMetadata {
prompt?: string
negative_prompt?: string
width?: number
height?: number
steps?: number
cfg_scale?: number
seed?: number
sampler?: string
}
export interface CivitaiModel {
id: number
name: string
description?: string
type: string
nsfw: boolean
creator?: {
username: string
image?: string
}
stats?: {
downloadCount: number
thumbsUpCount: number
}
modelVersions?: CivitaiVersion[]
}
export interface CivitaiVersion {
id: number
name: string
baseModel?: string
trainedWords?: string[]
images?: CivitaiImage[]
downloadUrl?: string
}
export interface CivitaiImage {
url: string
width: number
height: number
nsfwLevel: number
}
export interface Resolution {
width: number
height: number
label: string
}
export type AspectRatio = '3:4' | '1:1' | '4:3'
export type BaseSize = 512 | 768 | 1024
+12
View File
@@ -0,0 +1,12 @@
/// <reference types="vite/client" />
declare module 'vuetify/styles' {
const styles: string
export default styles
}
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<object, object, unknown>
export default component
}
+20
View File
@@ -0,0 +1,20 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"types": ["vite/client", "vuetify"],
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}
+28
View File
@@ -0,0 +1,28 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vuetify from 'vite-plugin-vuetify'
import { fileURLToPath, URL } from 'node:url'
export default defineConfig({
plugins: [
vue(),
vuetify({ autoImport: true }),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
},
build: {
outDir: '../static',
emptyOutDir: true,
},
server: {
proxy: {
'/api': {
target: 'http://junkpile:8081',
changeOrigin: true,
}
}
}
})