💬 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:
@@ -10,6 +10,7 @@ from typing import TYPE_CHECKING
|
|||||||
import httpx
|
import httpx
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
from tensors.server.civitai_routes import create_civitai_router
|
from tensors.server.civitai_routes import create_civitai_router
|
||||||
from tensors.server.db_routes import create_db_router
|
from tensors.server.db_routes import create_db_router
|
||||||
@@ -24,7 +25,7 @@ from tensors.server.routes import create_router
|
|||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
|
|
||||||
__all__ = ["ProcessManager", "ServerConfig", "create_app"]
|
__all__ = ["ProcessManager", "ServerConfig", "app", "create_app"]
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -50,13 +51,20 @@ def create_app(config: ServerConfig | None = None) -> FastAPI:
|
|||||||
|
|
||||||
app = FastAPI(title="sd-server wrapper", lifespan=lifespan)
|
app = FastAPI(title="sd-server wrapper", lifespan=lifespan)
|
||||||
|
|
||||||
# Serve gallery UI at root
|
# Serve Vue UI static files
|
||||||
static_dir = Path(__file__).parent / "static"
|
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)
|
@app.get("/", include_in_schema=False)
|
||||||
async def gallery_ui() -> FileResponse:
|
async def gallery_ui() -> FileResponse:
|
||||||
return FileResponse(static_dir / "index.html")
|
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_civitai_router()) # Must be before catch-all proxy
|
||||||
app.include_router(create_db_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
|
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.include_router(create_router(pm))
|
||||||
app.state.pm = pm
|
app.state.pm = pm
|
||||||
return app
|
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
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -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 |
@@ -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
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["Vue.volar"]
|
||||||
|
}
|
||||||
@@ -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).
|
||||||
@@ -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>
|
||||||
Generated
+1663
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 |
@@ -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>
|
||||||
@@ -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 }),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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')
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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
|
||||||
Vendored
+12
@@ -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
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user