Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,57 @@ class DownloadModelsRequest(BaseModel):
pipeline_id: str


class LoRAFileInfo(BaseModel):
"""Metadata for an available LoRA file on disk."""

name: str
path: str
size_mb: float
folder: str | None = None


class LoRAFilesResponse(BaseModel):
"""Response containing all discoverable LoRA files."""

lora_files: list[LoRAFileInfo]


@app.get("/api/v1/lora/list", response_model=LoRAFilesResponse)
async def list_lora_files():
"""List available LoRA files in the models/lora directory and its subdirectories."""

def process_lora_file(file_path: Path, lora_dir: Path) -> LoRAFileInfo:
"""Extract LoRA file metadata."""
size_mb = file_path.stat().st_size / (1024 * 1024)
relative_path = file_path.relative_to(lora_dir)
folder = (
str(relative_path.parent) if relative_path.parent != Path(".") else None
)
return LoRAFileInfo(
name=file_path.stem,
path=str(file_path),
size_mb=round(size_mb, 2),
folder=folder,
)

try:
lora_dir = Path("models/lora")
lora_files: list[LoRAFileInfo] = []

if lora_dir.exists() and lora_dir.is_dir():
for pattern in ("*.safetensors", "*.bin", "*.pt"):
for file_path in lora_dir.rglob(pattern):
if file_path.is_file():
lora_files.append(process_lora_file(file_path, lora_dir))

lora_files.sort(key=lambda x: (x.folder or "", x.name))
return LoRAFilesResponse(lora_files=lora_files)

except Exception as e: # pragma: no cover - defensive logging
logger.error(f"list_lora_files: Error listing LoRA files: {e}")
raise HTTPException(status_code=500, detail=str(e)) from e


@app.get("/api/v1/models/status")
async def get_model_status(pipeline_id: str):
"""Check if models for a pipeline are downloaded."""
Expand Down
200 changes: 200 additions & 0 deletions frontend/src/components/LoRAManager.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
import { useState, useEffect } from "react";
import { Button } from "./ui/button";
import { SliderWithInput } from "./ui/slider-with-input";
import { Plus, X, RefreshCw } from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "./ui/tooltip";
import type { LoRAConfig, LoraMergeStrategy } from "../types";
import { listLoRAFiles, type LoRAFileInfo } from "../lib/api";
import { FilePicker } from "./ui/file-picker";

interface LoRAManagerProps {
loras: LoRAConfig[];
onLorasChange: (loras: LoRAConfig[]) => void;
disabled?: boolean;
isStreaming?: boolean;
loraMergeStrategy?: LoraMergeStrategy;
}

export function LoRAManager({
loras,
onLorasChange,
disabled,
isStreaming = false,
loraMergeStrategy = "permanent_merge",
}: LoRAManagerProps) {
const [availableLoRAs, setAvailableLoRAs] = useState<LoRAFileInfo[]>([]);
const [isLoadingLoRAs, setIsLoadingLoRAs] = useState(false);
const [localScales, setLocalScales] = useState<Record<string, number>>({});

const loadAvailableLoRAs = async () => {
setIsLoadingLoRAs(true);
try {
const response = await listLoRAFiles();
setAvailableLoRAs(response.lora_files);
} catch (error) {
console.error("loadAvailableLoRAs: Failed to load LoRA files:", error);
} finally {
setIsLoadingLoRAs(false);
}
};

useEffect(() => {
loadAvailableLoRAs();
}, []);

// Sync localScales from loras prop when it changes from outside
useEffect(() => {
const newLocalScales: Record<string, number> = {};
loras.forEach(lora => {
newLocalScales[lora.id] = lora.scale;
});
setLocalScales(newLocalScales);
}, [loras]);

const handleAddLora = () => {
const newLora: LoRAConfig = {
id: crypto.randomUUID(),
path: "",
scale: 1.0,
};
onLorasChange([...loras, newLora]);
};

const handleRemoveLora = (id: string) => {
onLorasChange(loras.filter(lora => lora.id !== id));
};

const handleLoraChange = (id: string, updates: Partial<LoRAConfig>) => {
onLorasChange(
loras.map(lora => (lora.id === id ? { ...lora, ...updates } : lora))
);
};

const handleLocalScaleChange = (id: string, scale: number) => {
setLocalScales(prev => ({ ...prev, [id]: scale }));
};

const handleScaleCommit = (id: string, scale: number) => {
handleLoraChange(id, { scale });
};

return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium">LoRA Adapters</h3>
<div className="flex gap-1">
<Button
size="sm"
variant="outline"
onClick={loadAvailableLoRAs}
disabled={disabled || isLoadingLoRAs}
className="h-6 px-2"
title="Refresh LoRA list"
>
<RefreshCw
className={`h-3 w-3 ${isLoadingLoRAs ? "animate-spin" : ""}`}
/>
</Button>
<Button
size="sm"
variant="outline"
onClick={handleAddLora}
disabled={disabled || isStreaming}
className="h-6 px-2"
title={
isStreaming ? "Cannot add LoRAs while streaming" : "Add LoRA"
}
>
<Plus className="h-3 w-3" />
</Button>
</div>
</div>

{loras.length === 0 && (
<p className="text-xs text-muted-foreground">
No LoRA adapters configured. Add LoRA files to models/lora directory.
</p>
)}

<div className="space-y-2">
{loras.map(lora => (
<div
key={lora.id}
className="rounded-lg border bg-card p-3 space-y-2"
>
<div className="flex items-center justify-between gap-2">
<div className="flex-1 min-w-0">
<FilePicker
value={lora.path}
onChange={path => handleLoraChange(lora.id, { path })}
files={availableLoRAs}
disabled={disabled || isStreaming}
placeholder="Select LoRA file"
emptyMessage="No LoRA files found"
/>
</div>
<Button
size="sm"
variant="ghost"
onClick={() => handleRemoveLora(lora.id)}
disabled={disabled || isStreaming}
className="h-6 w-6 p-0 shrink-0"
title={
isStreaming
? "Cannot remove LoRAs while streaming"
: "Remove LoRA"
}
>
<X className="h-3 w-3" />
</Button>
</div>

<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground w-12">Scale:</span>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex-1 min-w-0">
<SliderWithInput
value={localScales[lora.id] ?? lora.scale}
onValueChange={value => {
handleLocalScaleChange(lora.id, value);
}}
onValueCommit={value => {
handleScaleCommit(lora.id, value);
}}
min={-10}
max={10}
step={0.1}
incrementAmount={0.1}
disabled={
disabled ||
(isStreaming &&
loraMergeStrategy === "permanent_merge")
}
className="flex-1"
valueFormatter={v => Math.round(v * 10) / 10}
/>
</div>
</TooltipTrigger>
<TooltipContent>
<p className="text-xs">
{isStreaming && loraMergeStrategy === "permanent_merge"
? "Runtime adjustment is disabled with Permanent Merge strategy. LoRA scales are fixed at load time."
: "Adjust LoRA strength. Updates automatically when you release the slider or use +/- buttons. 0.0 = no effect, 1.0 = full strength"}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
))}
</div>
</div>
);
}
2 changes: 2 additions & 0 deletions frontend/src/components/PromptTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,8 @@ export function PromptTimeline({
manageCache: settings.manageCache,
quantization: settings.quantization,
kvCacheAttentionBias: settings.kvCacheAttentionBias,
loras: settings.loras,
loraMergeStrategy: settings.loraMergeStrategy,
// Exclude paused state as it's runtime-specific
}
: undefined,
Expand Down
85 changes: 81 additions & 4 deletions frontend/src/components/SettingsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,13 @@ import { Button } from "./ui/button";
import { Toggle } from "./ui/toggle";
import { SliderWithInput } from "./ui/slider-with-input";
import { Hammer, Info, Minus, Plus, RotateCcw } from "lucide-react";
import { PIPELINES } from "../data/pipelines";
import { PIPELINES, pipelineSupportsLoRA } from "../data/pipelines";
import { PARAMETER_METADATA } from "../data/parameterMetadata";
import { DenoisingStepsSlider } from "./DenoisingStepsSlider";
import { getDefaultDenoisingSteps, getDefaultResolution } from "../lib/utils";
import { useLocalSliderValue } from "../hooks/useLocalSliderValue";
import type { PipelineId } from "../types";
import type { PipelineId, LoRAConfig, LoraMergeStrategy } from "../types";
import { LoRAManager } from "./LoRAManager";

const MIN_DIMENSION = 16;

Expand Down Expand Up @@ -55,6 +56,10 @@ interface SettingsPanelProps {
kvCacheAttentionBias?: number;
onKvCacheAttentionBiasChange?: (bias: number) => void;
onResetCache?: () => void;
loras?: LoRAConfig[];
onLorasChange: (loras: LoRAConfig[]) => void;
loraMergeStrategy?: LoraMergeStrategy;
onLoraMergeStrategyChange?: (strategy: LoraMergeStrategy) => void;
}

export function SettingsPanel({
Expand All @@ -80,6 +85,10 @@ export function SettingsPanel({
kvCacheAttentionBias = 0.3,
onKvCacheAttentionBiasChange,
onResetCache,
loras = [],
onLorasChange,
loraMergeStrategy = "permanent_merge",
onLoraMergeStrategyChange,
}: SettingsPanelProps) {
// Use pipeline-specific default if resolution is not provided
const effectiveResolution = resolution || getDefaultResolution(pipelineId);
Expand Down Expand Up @@ -290,13 +299,81 @@ export function SettingsPanel({
</Card>
)}

{pipelineSupportsLoRA(pipelineId) && (
<div className="space-y-4">
<LoRAManager
loras={loras}
onLorasChange={onLorasChange}
disabled={isDownloading}
isStreaming={isStreaming}
loraMergeStrategy={loraMergeStrategy}
/>

{loras.length > 0 && (
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<LabelWithTooltip
label={PARAMETER_METADATA.loraMergeStrategy.label}
tooltip={PARAMETER_METADATA.loraMergeStrategy.tooltip}
className="text-sm text-foreground"
/>
<Select
value={loraMergeStrategy}
onValueChange={value => {
onLoraMergeStrategyChange?.(value as LoraMergeStrategy);
}}
disabled={isStreaming}
>
<SelectTrigger className="w-[180px] h-7">
<SelectValue />
</SelectTrigger>
<SelectContent>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<SelectItem value="permanent_merge">
Permanent Merge
</SelectItem>
</TooltipTrigger>
<TooltipContent side="right" className="max-w-xs">
<p className="text-xs">
Maximum performance, no runtime updates. LoRA
scales are permanently merged into model weights
at load time. Ideal for when you already know what
scale to use.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<SelectItem value="runtime_peft">
Runtime PEFT
</SelectItem>
</TooltipTrigger>
<TooltipContent side="right" className="max-w-xs">
<p className="text-xs">
Lower performance, instant runtime updates. LoRA
scales can be adjusted during streaming without
reloading the model. Faster initialization.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</SelectContent>
</Select>
</div>
</div>
)}
</div>
)}

{(pipelineId === "longlive" ||
pipelineId === "streamdiffusionv2" ||
pipelineId === "krea-realtime-video") && (
<div className="space-y-4">
<div className="space-y-2">
<h3 className="text-sm font-medium">Parameters</h3>

<div className="space-y-2">
<div className="space-y-1">
<div className="flex items-center gap-2">
Expand Down
Loading
Loading