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):
"""Request body for image generation."""
@@ -37,6 +44,7 @@ class GenerateRequest(PydanticBaseModel):
batch_size: int = Field(default=1, ge=1, le=16)
save_to_gallery: bool = True
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
if 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
@@ -98,6 +109,8 @@ def _process_image(
"sampler": req.sampler_name,
"scheduler": req.scheduler,
"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(),
}
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
# 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
@@ -32,13 +54,15 @@ def scan_models(directory: Path, extensions: tuple[str, ...] = (".safetensors",
for ext in extensions:
for path in directory.rglob(f"*{ext}"):
stat = path.stat()
name = path.stem
models.append(
{
"name": path.stem,
"name": name,
"path": str(path),
"filename": path.name,
"size_mb": round(stat.st_size / (1024 * 1024), 2),
"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]]:
"""Scan for LoRA files."""
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]]:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tensors</title>
<script type="module" crossorigin src="/assets/index-BIJHSBBO.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-lqI0My76.css">
<script type="module" crossorigin src="/assets/index-J_qzb7Jl.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-QncGJEyk.css">
</head>
<body>
<div id="app"></div>
@@ -25,7 +25,7 @@ const modelItems = computed(() =>
const loraItems = computed(() => [
{ 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) {
try {
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) {
console.error(e)
}
@@ -46,17 +51,15 @@ async function generate() {
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}`
}
}
// Build final prompt with quality tags
const finalPrompt = `${store.defaultQualityTags}, ${currentPrompt}`
// Get LoRA config if selected (sd-server expects LoRA as separate param with filename, not in prompt)
const selectedLoraModel = store.selectedLora ? store.loras.find(l => l.path === store.selectedLora) : null
const loraConfig = selectedLoraModel ? { path: selectedLoraModel.filename, multiplier: store.loraWeight } : undefined
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>({
prompt: currentPrompt,
@@ -70,11 +73,13 @@ async function generate() {
for (let i = 0; i < store.batchSize; i++) {
const result = await api.generate({
prompt: finalPrompt,
negative_prompt: store.defaultNegativePrompt,
width,
height,
steps: store.steps,
seed: -1,
save_to_gallery: true,
lora: loraConfig,
})
message.images.push(...result.images)
}
+19
View File
@@ -20,6 +20,10 @@ export const useAppStore = defineStore('app', () => {
const steps = ref(20)
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)
const resolutionPresets: ResolutionPreset[] = [
// 512 base
@@ -48,6 +52,17 @@ export const useAppStore = defineStore('app', () => {
{ 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
const loadingModels = ref(false)
const switchingModel = ref(false)
@@ -97,8 +112,10 @@ export const useAppStore = defineStore('app', () => {
// Models
models,
loras,
filteredLoras,
activeModel,
selectedModel,
selectedModelCategory,
selectedLora,
loraWeight,
@@ -109,6 +126,8 @@ export const useAppStore = defineStore('app', () => {
steps,
batchSize,
resolution,
defaultQualityTags,
defaultNegativePrompt,
// Loading states
loadingModels,