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:
@@ -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
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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]]:
|
||||||
|
|||||||
+7
-7
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
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user