Add Tengu deployment config and LoRA support

- Add app.yml for Tengu PaaS deployment
- Fix LoRA parameter passing (separate param, not in prompt)
- Add default quality/negative prompts
- Update UI for LoRA selection

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Adam Ladachowski
2026-02-14 08:53:08 +01:00
parent 120c4c68b0
commit ab2ef4258b
9 changed files with 87 additions and 22 deletions
+4
View File
@@ -0,0 +1,4 @@
runtime: python
build_cmd: uv pip install --system -e '.[server]'
cmd: tsr serve --host 0.0.0.0 --port 5000 --sd-server http://172.17.0.1:1234
port: 5000
+13
View File
@@ -22,6 +22,13 @@ logger = logging.getLogger(__name__)
# ============================================================================= # =============================================================================
class LoraConfig(PydanticBaseModel):
"""LoRA configuration for sd-server."""
path: str
multiplier: float = Field(default=1.0, ge=0.0, le=2.0)
class GenerateRequest(PydanticBaseModel): class GenerateRequest(PydanticBaseModel):
"""Request body for image generation.""" """Request body for image generation."""
@@ -37,6 +44,7 @@ class GenerateRequest(PydanticBaseModel):
batch_size: int = Field(default=1, ge=1, le=16) batch_size: int = Field(default=1, ge=1, le=16)
save_to_gallery: bool = True save_to_gallery: bool = True
return_base64: bool = False return_base64: bool = False
lora: LoraConfig | None = None
# ============================================================================= # =============================================================================
@@ -60,6 +68,9 @@ def _build_sd_request(req: GenerateRequest) -> dict[str, Any]:
body["sampler_name"] = req.sampler_name body["sampler_name"] = req.sampler_name
if req.scheduler: if req.scheduler:
body["scheduler"] = req.scheduler body["scheduler"] = req.scheduler
# sd-server expects LoRA as JSON array, not in prompt
if req.lora:
body["lora"] = [{"path": req.lora.path, "multiplier": req.lora.multiplier}]
return body return body
@@ -98,6 +109,8 @@ def _process_image(
"sampler": req.sampler_name, "sampler": req.sampler_name,
"scheduler": req.scheduler, "scheduler": req.scheduler,
"model": model, "model": model,
"lora": req.lora.path if req.lora else None,
"lora_weight": req.lora.multiplier if req.lora else None,
"generated_at": time.time(), "generated_at": time.time(),
} }
gallery_img = gallery.save_image(image_bytes, metadata=metadata, seed=seed) gallery_img = gallery.save_image(image_bytes, metadata=metadata, seed=seed)
+26 -2
View File
@@ -16,6 +16,28 @@ logger = logging.getLogger(__name__)
_HTTP_OK = 200 _HTTP_OK = 200
# Keywords for detecting base model category
_SD15_KEYWORDS = ("sd15", "sd1.5", "sd-1.5", "sd_1.5", "1.5", "sd-1-", "v1-5")
_LARGE_KEYWORDS = ("sdxl", "xl", "pony", "illustrious", "ilust", "noob", "animagine")
def _detect_model_category(name: str) -> str:
"""Detect model category from filename. Returns 'sd15' or 'large'."""
name_lower = name.lower()
# Check SD 1.5 keywords first
for kw in _SD15_KEYWORDS:
if kw in name_lower:
return "sd15"
# Check large model keywords
for kw in _LARGE_KEYWORDS:
if kw in name_lower:
return "large"
# Default to large (SDXL/Pony/Illustrious are more common now)
return "large"
# ============================================================================= # =============================================================================
# Helper Functions # Helper Functions
@@ -32,13 +54,15 @@ def scan_models(directory: Path, extensions: tuple[str, ...] = (".safetensors",
for ext in extensions: for ext in extensions:
for path in directory.rglob(f"*{ext}"): for path in directory.rglob(f"*{ext}"):
stat = path.stat() stat = path.stat()
name = path.stem
models.append( models.append(
{ {
"name": path.stem, "name": name,
"path": str(path), "path": str(path),
"filename": path.name, "filename": path.name,
"size_mb": round(stat.st_size / (1024 * 1024), 2), "size_mb": round(stat.st_size / (1024 * 1024), 2),
"modified": stat.st_mtime, "modified": stat.st_mtime,
"category": _detect_model_category(name),
} }
) )
@@ -50,7 +74,7 @@ def scan_models(directory: Path, extensions: tuple[str, ...] = (".safetensors",
def scan_loras(directory: Path | None = None) -> list[dict[str, Any]]: def scan_loras(directory: Path | None = None) -> list[dict[str, Any]]:
"""Scan for LoRA files.""" """Scan for LoRA files."""
lora_dir = directory or MODELS_DIR / "loras" lora_dir = directory or MODELS_DIR / "loras"
return scan_models(lora_dir, extensions=(".safetensors",)) return scan_models(lora_dir, extensions=(".safetensors", ".gguf"))
def scan_checkpoints(directory: Path | None = None) -> list[dict[str, Any]]: def scan_checkpoints(directory: Path | None = None) -> list[dict[str, Any]]:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tensors</title> <title>Tensors</title>
<script type="module" crossorigin src="/assets/index-BIJHSBBO.js"></script> <script type="module" crossorigin src="/assets/index-J_qzb7Jl.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-lqI0My76.css"> <link rel="stylesheet" crossorigin href="/assets/index-QncGJEyk.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
@@ -25,7 +25,7 @@ const modelItems = computed(() =>
const loraItems = computed(() => [ const loraItems = computed(() => [
{ title: 'None', value: '' }, { title: 'None', value: '' },
...store.loras.map(l => ({ title: l.name, value: l.path })) ...store.filteredLoras.map(l => ({ title: l.name, value: l.path }))
]) ])
@@ -33,6 +33,11 @@ async function handleModelChange(model: string) {
if (model && model !== store.activeModel) { if (model && model !== store.activeModel) {
try { try {
await store.switchModel(model) await store.switchModel(model)
// Reset LoRA if it's not compatible with the new model
const loraStillValid = store.filteredLoras.some(l => l.path === store.selectedLora)
if (!loraStillValid) {
store.selectedLora = ''
}
} catch (e) { } catch (e) {
console.error(e) console.error(e)
} }
@@ -46,17 +51,15 @@ async function generate() {
prompt.value = '' prompt.value = ''
generating.value = true generating.value = true
// Build final prompt with LoRA // Build final prompt with quality tags
let finalPrompt = currentPrompt const finalPrompt = `${store.defaultQualityTags}, ${currentPrompt}`
if (store.selectedLora) {
const loraName = store.loras.find(l => l.path === store.selectedLora)?.name // Get LoRA config if selected (sd-server expects LoRA as separate param with filename, not in prompt)
if (loraName) { const selectedLoraModel = store.selectedLora ? store.loras.find(l => l.path === store.selectedLora) : null
finalPrompt = `<lora:${loraName}:${store.loraWeight}> ${currentPrompt}` const loraConfig = selectedLoraModel ? { path: selectedLoraModel.filename, multiplier: store.loraWeight } : undefined
}
}
const { width, height } = store.resolution const { width, height } = store.resolution
const paramsStr = `${width}×${height}, ${store.steps} steps${store.batchSize > 1 ? `, batch ${store.batchSize}` : ''}${store.selectedLora ? ', +LoRA' : ''}` const paramsStr = `${width}×${height}, ${store.steps} steps${store.batchSize > 1 ? `, batch ${store.batchSize}` : ''}${selectedLoraModel ? `, +${selectedLoraModel.name}` : ''}`
const message = reactive<ChatMessage>({ const message = reactive<ChatMessage>({
prompt: currentPrompt, prompt: currentPrompt,
@@ -70,11 +73,13 @@ async function generate() {
for (let i = 0; i < store.batchSize; i++) { for (let i = 0; i < store.batchSize; i++) {
const result = await api.generate({ const result = await api.generate({
prompt: finalPrompt, prompt: finalPrompt,
negative_prompt: store.defaultNegativePrompt,
width, width,
height, height,
steps: store.steps, steps: store.steps,
seed: -1, seed: -1,
save_to_gallery: true, save_to_gallery: true,
lora: loraConfig,
}) })
message.images.push(...result.images) message.images.push(...result.images)
} }
+19
View File
@@ -20,6 +20,10 @@ export const useAppStore = defineStore('app', () => {
const steps = ref(20) const steps = ref(20)
const batchSize = ref(1) const batchSize = ref(1)
// Default quality prompts (applied automatically)
const defaultQualityTags = 'masterpiece, best quality, absurdres, highres'
const defaultNegativePrompt = 'bad anatomy, bad hands, missing fingers, extra fingers, extra digit, fewer digits, extra limbs, missing limbs, fused fingers, too many fingers, mutated hands, poorly drawn hands, poorly drawn face, mutation, deformed, ugly, blurry, bad proportions, gross proportions, malformed limbs, long neck, cropped, worst quality, low quality, normal quality, jpeg artifacts, signature, watermark, username, text, error'
// All resolution presets (base size × aspect ratio) // All resolution presets (base size × aspect ratio)
const resolutionPresets: ResolutionPreset[] = [ const resolutionPresets: ResolutionPreset[] = [
// 512 base // 512 base
@@ -48,6 +52,17 @@ export const useAppStore = defineStore('app', () => {
{ label: 'high', presets: resolutionPresets.filter(p => p.id.startsWith('1024-')) }, { label: 'high', presets: resolutionPresets.filter(p => p.id.startsWith('1024-')) },
] ]
// Computed: selected model's category
const selectedModelCategory = computed(() => {
const model = models.value.find(m => m.path === selectedModel.value || m.name === selectedModel.value)
return model?.category || 'large'
})
// Computed: LoRAs filtered by selected model category
const filteredLoras = computed(() => {
return loras.value.filter(l => l.category === selectedModelCategory.value)
})
// Loading states // Loading states
const loadingModels = ref(false) const loadingModels = ref(false)
const switchingModel = ref(false) const switchingModel = ref(false)
@@ -97,8 +112,10 @@ export const useAppStore = defineStore('app', () => {
// Models // Models
models, models,
loras, loras,
filteredLoras,
activeModel, activeModel,
selectedModel, selectedModel,
selectedModelCategory,
selectedLora, selectedLora,
loraWeight, loraWeight,
@@ -109,6 +126,8 @@ export const useAppStore = defineStore('app', () => {
steps, steps,
batchSize, batchSize,
resolution, resolution,
defaultQualityTags,
defaultNegativePrompt,
// Loading states // Loading states
loadingModels, loadingModels,