From 9324d988177ab3b7841d8a0a50c7d85f16929b63 Mon Sep 17 00:00:00 2001
From: emranemran
Date: Wed, 11 Feb 2026 00:31:47 -0800
Subject: [PATCH 01/14] Add LoRA support in cloud relay mode
Enable LoRA browsing, selection, and download-by-URL in both relay and
direct cloud modes. Previously the LoRA UI was completely hidden in cloud
mode and the backend had no cloud proxy for the LoRA list endpoint.
Backend: add cloud proxy to GET /api/v1/lora/list, add POST
/api/v1/lora/download endpoint with filename inference, extension
validation, and duplicate detection. Add 5-min timeout for LoRA
downloads in fal_app.py.
Frontend: remove isCloudMode gates in SettingsPanel and ComplexFields,
switch LoRAManager to useApi hook for cloud-aware routing, add
downloadLoRAFile to api/cloudAdapter/useApi, add download URL input UI.
Co-Authored-By: Claude Opus 4.6
Signed-off-by: emranemran
---
frontend/src/components/ComplexFields.tsx | 2 +-
frontend/src/components/LoRAManager.tsx | 58 ++++++++++-
frontend/src/components/SettingsPanel.tsx | 2 +-
frontend/src/hooks/useApi.ts | 13 +++
frontend/src/lib/api.ts | 30 ++++++
frontend/src/lib/cloudAdapter.ts | 25 +++--
src/scope/cloud/fal_app.py | 6 +-
src/scope/server/app.py | 114 +++++++++++++++++++++-
8 files changed, 236 insertions(+), 14 deletions(-)
diff --git a/frontend/src/components/ComplexFields.tsx b/frontend/src/components/ComplexFields.tsx
index 77e162d32..0a18ae85d 100644
--- a/frontend/src/components/ComplexFields.tsx
+++ b/frontend/src/components/ComplexFields.tsx
@@ -210,7 +210,7 @@ export function SchemaComplexField({
);
}
- if (component === "lora" && !rendered.has("lora") && !ctx.isCloudMode) {
+ if (component === "lora" && !rendered.has("lora")) {
rendered.add("lora");
return (
diff --git a/frontend/src/components/LoRAManager.tsx b/frontend/src/components/LoRAManager.tsx
index 581d5ab18..0e640aa8b 100644
--- a/frontend/src/components/LoRAManager.tsx
+++ b/frontend/src/components/LoRAManager.tsx
@@ -8,11 +8,13 @@ import {
SelectTrigger,
SelectValue,
} from "./ui/select";
-import { Plus, X, RefreshCw } from "lucide-react";
+import { Plus, X, RefreshCw, Download } from "lucide-react";
import { LabelWithTooltip } from "./ui/label-with-tooltip";
import { PARAMETER_METADATA } from "../data/parameterMetadata";
import type { LoRAConfig, LoraMergeStrategy } from "../types";
-import { listLoRAFiles, type LoRAFileInfo } from "../lib/api";
+import type { LoRAFileInfo } from "../lib/api";
+import { useApi } from "../hooks/useApi";
+import { Input } from "./ui/input";
import { FilePicker } from "./ui/file-picker";
interface LoRAManagerProps {
@@ -30,9 +32,13 @@ export function LoRAManager({
isStreaming = false,
loraMergeStrategy = "permanent_merge",
}: LoRAManagerProps) {
+ const { listLoRAFiles, downloadLoRAFile } = useApi();
const [availableLoRAs, setAvailableLoRAs] = useState
([]);
const [isLoadingLoRAs, setIsLoadingLoRAs] = useState(false);
const [localScales, setLocalScales] = useState>({});
+ const [downloadUrl, setDownloadUrl] = useState("");
+ const [isDownloading, setIsDownloading] = useState(false);
+ const [downloadError, setDownloadError] = useState(null);
const loadAvailableLoRAs = async () => {
setIsLoadingLoRAs(true);
@@ -99,6 +105,23 @@ export function LoRAManager({
return { isDisabled, tooltipText };
};
+ const handleDownloadLoRA = async () => {
+ if (!downloadUrl.trim()) return;
+ setIsDownloading(true);
+ setDownloadError(null);
+ try {
+ await downloadLoRAFile({ url: downloadUrl.trim() });
+ setDownloadUrl("");
+ await loadAvailableLoRAs();
+ } catch (error) {
+ setDownloadError(
+ error instanceof Error ? error.message : "Download failed"
+ );
+ } finally {
+ setIsDownloading(false);
+ }
+ };
+
return (
@@ -131,6 +154,37 @@ export function LoRAManager({
+
+ {
+ setDownloadUrl(e.target.value);
+ setDownloadError(null);
+ }}
+ placeholder="Paste LoRA URL (HuggingFace, CivitAI...)"
+ disabled={disabled || isDownloading}
+ className="h-7 text-xs flex-1"
+ onKeyDown={e => {
+ if (e.key === "Enter") handleDownloadLoRA();
+ }}
+ />
+
+
+ {downloadError && (
+ {downloadError}
+ )}
+
{loras.length === 0 && (
No LoRA adapters configured. Follow the{" "}
diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx
index 3125e22ca..572dbe2e5 100644
--- a/frontend/src/components/SettingsPanel.tsx
+++ b/frontend/src/components/SettingsPanel.tsx
@@ -674,7 +674,7 @@ export function SettingsPanel({
)}
- {currentPipeline?.supportsLoRA && !isCloudMode && (
+ {currentPipeline?.supportsLoRA && (
=> {
+ if (isCloudMode && adapter) {
+ return adapter.api.downloadLoRAFile(data);
+ }
+ return api.downloadLoRAFile(data);
+ },
+ [adapter, isCloudMode]
+ );
+
// Asset APIs
const listAssets = useCallback(
async (type?: "image" | "video"): Promise => {
@@ -192,6 +204,7 @@ export function useApi() {
// LoRA
listLoRAFiles,
+ downloadLoRAFile,
// Assets
listAssets,
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index fbea0bc9e..51ed209db 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -268,6 +268,36 @@ export const listLoRAFiles = async (): Promise => {
return result;
};
+export interface LoRADownloadRequest {
+ url: string;
+ filename?: string;
+}
+
+export interface LoRADownloadResponse {
+ message: string;
+ file: LoRAFileInfo;
+}
+
+export const downloadLoRAFile = async (
+ data: LoRADownloadRequest
+): Promise => {
+ const response = await fetch("/api/v1/lora/download", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(data),
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ throw new Error(
+ `Download LoRA failed: ${response.status} ${response.statusText}: ${errorText}`
+ );
+ }
+
+ const result = await response.json();
+ return result;
+};
+
export interface AssetFileInfo {
name: string;
path: string;
diff --git a/frontend/src/lib/cloudAdapter.ts b/frontend/src/lib/cloudAdapter.ts
index 09a83dcf3..c9bba3a87 100644
--- a/frontend/src/lib/cloudAdapter.ts
+++ b/frontend/src/lib/cloudAdapter.ts
@@ -31,6 +31,8 @@ import type {
PipelineSchemasResponse,
HardwareInfoResponse,
LoRAFilesResponse,
+ LoRADownloadRequest,
+ LoRADownloadResponse,
AssetsResponse,
AssetFileInfo,
} from "./api";
@@ -350,14 +352,18 @@ export class CloudAdapter {
private async apiRequest(
method: "GET" | "POST" | "PATCH" | "DELETE",
path: string,
- body?: unknown
+ body?: unknown,
+ timeoutMs?: number
): Promise {
- const response = await this.sendAndWait({
- type: "api",
- method,
- path,
- body,
- });
+ const response = await this.sendAndWait(
+ {
+ type: "api",
+ method,
+ path,
+ body,
+ },
+ timeoutMs
+ );
if (response.status && response.status >= 400) {
throw new Error(
@@ -395,6 +401,11 @@ export class CloudAdapter {
listLoRAFiles: (): Promise =>
this.apiRequest("GET", "/api/v1/lora/list"),
+ downloadLoRAFile: (
+ data: LoRADownloadRequest
+ ): Promise =>
+ this.apiRequest("POST", "/api/v1/lora/download", data, 300000),
+
listAssets: (type?: "image" | "video"): Promise =>
this.apiRequest(
"GET",
diff --git a/src/scope/cloud/fal_app.py b/src/scope/cloud/fal_app.py
index 60ab70406..d1f460ca0 100644
--- a/src/scope/cloud/fal_app.py
+++ b/src/scope/cloud/fal_app.py
@@ -627,8 +627,12 @@ async def handle_api_request(payload: dict):
timeout=60.0, # Longer timeout for uploads
)
else:
+ # Use longer timeout for LoRA downloads
+ post_timeout = 300.0 if "/lora/download" in path else 30.0
response = await client.post(
- f"{SCOPE_BASE_URL}{path}", json=body, timeout=30.0
+ f"{SCOPE_BASE_URL}{path}",
+ json=body,
+ timeout=post_timeout,
)
elif method == "PATCH":
response = await client.patch(
diff --git a/src/scope/server/app.py b/src/scope/server/app.py
index 644806eca..2914fc8dc 100644
--- a/src/scope/server/app.py
+++ b/src/scope/server/app.py
@@ -869,8 +869,13 @@ class LoRAFilesResponse(BaseModel):
@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."""
+async def list_lora_files(
+ cloud_manager: "CloudConnectionManager" = Depends(get_cloud_connection_manager),
+):
+ """List available LoRA files in the models/lora directory and its subdirectories.
+
+ When cloud mode is active, lists LoRA files from the cloud server instead.
+ """
def process_lora_file(file_path: Path, lora_dir: Path) -> LoRAFileInfo:
"""Extract LoRA file metadata."""
@@ -887,6 +892,19 @@ def process_lora_file(file_path: Path, lora_dir: Path) -> LoRAFileInfo:
)
try:
+ # If cloud mode is active, proxy to cloud
+ if cloud_manager.is_connected:
+ logger.info("[CLOUD] Fetching LoRA list from cloud")
+ response = await cloud_manager.api_request(
+ method="GET",
+ path="/api/v1/lora/list",
+ timeout=30.0,
+ )
+ data = response.get("data", {})
+ return LoRAFilesResponse(
+ lora_files=[LoRAFileInfo(**f) for f in data.get("lora_files", [])]
+ )
+
lora_dir = get_models_dir() / "lora"
lora_files: list[LoRAFileInfo] = []
@@ -901,6 +919,98 @@ def process_lora_file(file_path: Path, lora_dir: Path) -> LoRAFileInfo:
raise HTTPException(status_code=500, detail=str(e)) from e
+class LoRADownloadRequest(BaseModel):
+ url: str
+ filename: str | None = None
+
+
+class LoRADownloadResponse(BaseModel):
+ message: str
+ file: LoRAFileInfo
+
+
+@app.post("/api/v1/lora/download", response_model=LoRADownloadResponse)
+async def download_lora_file(
+ request: LoRADownloadRequest,
+ cloud_manager: "CloudConnectionManager" = Depends(get_cloud_connection_manager),
+):
+ """Download a LoRA file from a URL (e.g. HuggingFace, CivitAI).
+
+ When cloud mode is active, the download happens on the cloud machine.
+ """
+ from urllib.parse import unquote, urlparse
+
+ from .download_models import http_get
+
+ try:
+ # If cloud mode is active, proxy to cloud
+ if cloud_manager.is_connected:
+ logger.info("[CLOUD] Proxying LoRA download to cloud")
+ response = await cloud_manager.api_request(
+ method="POST",
+ path="/api/v1/lora/download",
+ body=request.model_dump(),
+ timeout=300.0,
+ )
+ data = response.get("data", {})
+ return LoRADownloadResponse(
+ message=data.get("message", "Downloaded via cloud"),
+ file=LoRAFileInfo(**data.get("file", {})),
+ )
+
+ # Determine filename from URL if not provided
+ filename = request.filename
+ if not filename:
+ parsed = urlparse(request.url)
+ filename = unquote(parsed.path.split("/")[-1])
+
+ if not filename:
+ raise HTTPException(
+ status_code=400,
+ detail="Could not determine filename from URL. Please provide a filename.",
+ )
+
+ # Validate file extension
+ ext = Path(filename).suffix.lower()
+ if ext not in LORA_EXTENSIONS:
+ raise HTTPException(
+ status_code=400,
+ detail=f"Invalid file extension '{ext}'. Allowed: {', '.join(sorted(LORA_EXTENSIONS))}",
+ )
+
+ lora_dir = get_models_dir() / "lora"
+ dest_path = lora_dir / filename
+
+ if dest_path.exists():
+ raise HTTPException(
+ status_code=409,
+ detail=f"File '{filename}' already exists.",
+ )
+
+ # Download in a thread to avoid blocking the event loop
+ loop = asyncio.get_event_loop()
+ await loop.run_in_executor(None, http_get, request.url, dest_path)
+
+ size_mb = dest_path.stat().st_size / (1024 * 1024)
+ file_info = LoRAFileInfo(
+ name=dest_path.stem,
+ path=str(dest_path),
+ size_mb=round(size_mb, 2),
+ folder=None,
+ )
+
+ return LoRADownloadResponse(
+ message=f"Successfully downloaded '{filename}'",
+ file=file_info,
+ )
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"download_lora_file: Error downloading LoRA: {e}")
+ raise HTTPException(status_code=500, detail=str(e)) from e
+
+
@app.get("/api/v1/assets", response_model=AssetsResponse)
async def list_assets(
type: str | None = Query(None, description="Filter by asset type (image, video)"),
From 5894ae88e9169b39b77f20bb1c64a375e484bd9d Mon Sep 17 00:00:00 2001
From: emranemran
Date: Thu, 12 Feb 2026 23:34:56 -0800
Subject: [PATCH 02/14] Add download progress toast and block downloads until
cloud connects
LoRAManager: use useCloudStatus hook to disable download input/button
while cloud is connecting, show inline "Waiting for cloud connection..."
message. Use sonner toast.promise() for download progress feedback.
Backend: add response status checks in cloud proxy paths for /lora/list
and /lora/download to properly surface errors instead of crashing on
empty response data.
Co-Authored-By: Claude Opus 4.6
Signed-off-by: emranemran
---
frontend/src/components/LoRAManager.tsx | 30 +++++++++++++++++++++----
src/scope/server/app.py | 14 +++++++++++-
2 files changed, 39 insertions(+), 5 deletions(-)
diff --git a/frontend/src/components/LoRAManager.tsx b/frontend/src/components/LoRAManager.tsx
index 0e640aa8b..45dae8828 100644
--- a/frontend/src/components/LoRAManager.tsx
+++ b/frontend/src/components/LoRAManager.tsx
@@ -14,6 +14,8 @@ import { PARAMETER_METADATA } from "../data/parameterMetadata";
import type { LoRAConfig, LoraMergeStrategy } from "../types";
import type { LoRAFileInfo } from "../lib/api";
import { useApi } from "../hooks/useApi";
+import { useCloudStatus } from "../hooks/useCloudStatus";
+import { toast } from "sonner";
import { Input } from "./ui/input";
import { FilePicker } from "./ui/file-picker";
@@ -33,6 +35,9 @@ export function LoRAManager({
loraMergeStrategy = "permanent_merge",
}: LoRAManagerProps) {
const { listLoRAFiles, downloadLoRAFile } = useApi();
+ const { isConnected: isCloudConnected, isConnecting: isCloudConnecting } =
+ useCloudStatus();
+ const isCloudPending = isCloudConnecting && !isCloudConnected;
const [availableLoRAs, setAvailableLoRAs] = useState([]);
const [isLoadingLoRAs, setIsLoadingLoRAs] = useState(false);
const [localScales, setLocalScales] = useState>({});
@@ -109,8 +114,14 @@ export function LoRAManager({
if (!downloadUrl.trim()) return;
setIsDownloading(true);
setDownloadError(null);
+ const url = downloadUrl.trim();
+ const filename = url.split("/").pop()?.split("?")[0] || "LoRA file";
try {
- await downloadLoRAFile({ url: downloadUrl.trim() });
+ await toast.promise(downloadLoRAFile({ url }), {
+ loading: `Downloading ${filename}...`,
+ success: response => response.message,
+ error: err => err.message || "Download failed",
+ });
setDownloadUrl("");
await loadAvailableLoRAs();
} catch (error) {
@@ -162,7 +173,7 @@ export function LoRAManager({
setDownloadError(null);
}}
placeholder="Paste LoRA URL (HuggingFace, CivitAI...)"
- disabled={disabled || isDownloading}
+ disabled={disabled || isDownloading || isCloudPending}
className="h-7 text-xs flex-1"
onKeyDown={e => {
if (e.key === "Enter") handleDownloadLoRA();
@@ -172,15 +183,26 @@ export function LoRAManager({
size="sm"
variant="outline"
onClick={handleDownloadLoRA}
- disabled={disabled || isDownloading || !downloadUrl.trim()}
+ disabled={
+ disabled || isDownloading || !downloadUrl.trim() || isCloudPending
+ }
className="h-7 px-2"
- title="Download LoRA from URL"
+ title={
+ isCloudPending
+ ? "Waiting for cloud connection..."
+ : "Download LoRA from URL"
+ }
>
+ {isCloudPending && (
+
+ Waiting for cloud connection...
+
+ )}
{downloadError && (
{downloadError}
)}
diff --git a/src/scope/server/app.py b/src/scope/server/app.py
index 2914fc8dc..83f997a18 100644
--- a/src/scope/server/app.py
+++ b/src/scope/server/app.py
@@ -900,6 +900,12 @@ def process_lora_file(file_path: Path, lora_dir: Path) -> LoRAFileInfo:
path="/api/v1/lora/list",
timeout=30.0,
)
+ status = response.get("status", 200)
+ if status >= 400:
+ detail = response.get("error") or response.get("data", {}).get(
+ "detail", "Cloud LoRA list request failed"
+ )
+ raise HTTPException(status_code=status, detail=detail)
data = response.get("data", {})
return LoRAFilesResponse(
lora_files=[LoRAFileInfo(**f) for f in data.get("lora_files", [])]
@@ -952,10 +958,16 @@ async def download_lora_file(
body=request.model_dump(),
timeout=300.0,
)
+ status = response.get("status", 200)
+ if status >= 400:
+ detail = response.get("error") or response.get("data", {}).get(
+ "detail", "Cloud LoRA download failed"
+ )
+ raise HTTPException(status_code=status, detail=detail)
data = response.get("data", {})
return LoRADownloadResponse(
message=data.get("message", "Downloaded via cloud"),
- file=LoRAFileInfo(**data.get("file", {})),
+ file=LoRAFileInfo(**data["file"]),
)
# Determine filename from URL if not provided
From 22cca2205a14cbaf47a403acfb741685c8d7a4d3 Mon Sep 17 00:00:00 2001
From: Max Holland
Date: Mon, 16 Feb 2026 16:07:11 +0000
Subject: [PATCH 03/14] Clear selected lora on cloud connection state change
Signed-off-by: Max Holland
---
frontend/src/components/LoRAManager.tsx | 22 ++++++++++-
src/scope/cloud/fal_app.py | 1 +
src/scope/server/app.py | 6 +--
src/scope/server/models_config.py | 49 +++++++++++++++++++++++--
4 files changed, 70 insertions(+), 8 deletions(-)
diff --git a/frontend/src/components/LoRAManager.tsx b/frontend/src/components/LoRAManager.tsx
index 45dae8828..3688b2e80 100644
--- a/frontend/src/components/LoRAManager.tsx
+++ b/frontend/src/components/LoRAManager.tsx
@@ -1,4 +1,4 @@
-import { useState, useEffect } from "react";
+import { useState, useEffect, useRef } from "react";
import { Button } from "./ui/button";
import { SliderWithInput } from "./ui/slider-with-input";
import {
@@ -70,6 +70,26 @@ export function LoRAManager({
setLocalScales(newLocalScales);
}, [loras]);
+ // Track cloud connection state and clear LoRAs when it changes
+ // (switching between local/cloud means different LoRA file lists)
+ const prevCloudConnectedRef = useRef(null);
+
+ useEffect(() => {
+ // On first render, just store the initial state
+ if (prevCloudConnectedRef.current === null) {
+ prevCloudConnectedRef.current = isCloudConnected;
+ return;
+ }
+
+ // Clear LoRAs when cloud connection state changes (connected or disconnected)
+ if (prevCloudConnectedRef.current !== isCloudConnected) {
+ onLorasChange([]);
+ loadAvailableLoRAs();
+ }
+
+ prevCloudConnectedRef.current = isCloudConnected;
+ }, [isCloudConnected, onLorasChange]);
+
const handleAddLora = () => {
const newLora: LoRAConfig = {
id: crypto.randomUUID(),
diff --git a/src/scope/cloud/fal_app.py b/src/scope/cloud/fal_app.py
index c3c8a9637..e27ef241f 100644
--- a/src/scope/cloud/fal_app.py
+++ b/src/scope/cloud/fal_app.py
@@ -315,6 +315,7 @@ def setup(self):
scope_env["DAYDREAM_SCOPE_LOGS_DIR"] = "/data/logs"
# not shared between users
scope_env["DAYDREAM_SCOPE_ASSETS_DIR"] = ASSETS_DIR_PATH
+ scope_env["DAYDREAM_SCOPE_LORA_DIR"] = ASSETS_DIR_PATH + "/lora"
# Install kafka extra dependencies
print("Installing daydream-scope[kafka]...")
diff --git a/src/scope/server/app.py b/src/scope/server/app.py
index 51a304354..3ba5a5dc5 100644
--- a/src/scope/server/app.py
+++ b/src/scope/server/app.py
@@ -60,7 +60,7 @@
from .models_config import (
ensure_models_dir,
get_assets_dir,
- get_models_dir,
+ get_lora_dir,
models_are_downloaded,
)
from .pipeline_manager import PipelineManager
@@ -835,7 +835,7 @@ def process_lora_file(file_path: Path, lora_dir: Path) -> LoRAFileInfo:
lora_files=[LoRAFileInfo(**f) for f in data.get("lora_files", [])]
)
- lora_dir = get_models_dir() / "lora"
+ lora_dir = get_lora_dir()
lora_files: list[LoRAFileInfo] = []
for file_path in iter_files(lora_dir, LORA_EXTENSIONS):
@@ -914,7 +914,7 @@ async def download_lora_file(
detail=f"Invalid file extension '{ext}'. Allowed: {', '.join(sorted(LORA_EXTENSIONS))}",
)
- lora_dir = get_models_dir() / "lora"
+ lora_dir = get_lora_dir()
dest_path = lora_dir / filename
if dest_path.exists():
diff --git a/src/scope/server/models_config.py b/src/scope/server/models_config.py
index b4386ba56..6a5299e23 100644
--- a/src/scope/server/models_config.py
+++ b/src/scope/server/models_config.py
@@ -8,6 +8,10 @@
And assets storage location with support for:
- Default location: ~/.daydream-scope/assets (or sibling to models dir)
- Environment variable override: DAYDREAM_SCOPE_ASSETS_DIR
+
+And LoRA storage location with support for:
+- Default location: ~/.daydream-scope/models/lora (or subdirectory of models dir)
+- Environment variable override: DAYDREAM_SCOPE_LORA_DIR
"""
import logging
@@ -25,6 +29,9 @@
# Environment variable for overriding assets directory
ASSETS_DIR_ENV_VAR = "DAYDREAM_SCOPE_ASSETS_DIR"
+# Environment variable for overriding lora directory
+LORA_DIR_ENV_VAR = "DAYDREAM_SCOPE_LORA_DIR"
+
def get_models_dir() -> Path:
"""
@@ -51,7 +58,7 @@ def get_models_dir() -> Path:
def ensure_models_dir() -> Path:
"""
Get the models directory path and ensure it exists.
- Also ensures the models/lora subdirectory exists.
+ Also ensures the LoRA directory exists.
Returns:
Path: Absolute path to the models directory
@@ -59,9 +66,8 @@ def ensure_models_dir() -> Path:
models_dir = get_models_dir()
models_dir.mkdir(parents=True, exist_ok=True)
- # Ensure the lora subdirectory exists
- lora_dir = models_dir / "lora"
- lora_dir.mkdir(parents=True, exist_ok=True)
+ # Ensure the lora directory exists (uses DAYDREAM_SCOPE_LORA_DIR if set)
+ ensure_lora_dir()
return models_dir
@@ -104,6 +110,41 @@ def get_assets_dir() -> Path:
return assets_dir
+def get_lora_dir() -> Path:
+ """
+ Get the LoRA directory path.
+
+ Priority order:
+ 1. DAYDREAM_SCOPE_LORA_DIR environment variable
+ 2. Subdirectory of models directory (e.g., ~/.daydream-scope/models/lora)
+
+ Returns:
+ Path: Absolute path to the LoRA directory
+ """
+ # Check environment variable first
+ env_dir = os.environ.get(LORA_DIR_ENV_VAR)
+ if env_dir:
+ lora_dir = Path(env_dir).expanduser().resolve()
+ return lora_dir
+
+ # Default: subdirectory of models directory
+ models_dir = get_models_dir()
+ lora_dir = models_dir / "lora"
+ return lora_dir
+
+
+def ensure_lora_dir() -> Path:
+ """
+ Get the LoRA directory path and ensure it exists.
+
+ Returns:
+ Path: Absolute path to the LoRA directory
+ """
+ lora_dir = get_lora_dir()
+ lora_dir.mkdir(parents=True, exist_ok=True)
+ return lora_dir
+
+
def get_required_model_files(pipeline_id: str | None = None) -> list[Path]:
"""
Get the list of required model files that should exist for a given pipeline.
From a74f80c803f39daf0f958c7a501fd10dfe425aa5 Mon Sep 17 00:00:00 2001
From: Max Holland
Date: Tue, 17 Feb 2026 10:47:21 +0000
Subject: [PATCH 04/14] rename download -> install
Signed-off-by: Max Holland
---
frontend/src/components/LoRAManager.tsx | 54 ++++++++++++-------------
frontend/src/hooks/useApi.ts | 14 +++----
frontend/src/lib/api.ts | 14 +++----
frontend/src/lib/cloudAdapter.ts | 12 +++---
src/scope/cloud/fal_app.py | 4 +-
src/scope/server/app.py | 32 +++++++--------
6 files changed, 65 insertions(+), 65 deletions(-)
diff --git a/frontend/src/components/LoRAManager.tsx b/frontend/src/components/LoRAManager.tsx
index 3688b2e80..b5f5f3667 100644
--- a/frontend/src/components/LoRAManager.tsx
+++ b/frontend/src/components/LoRAManager.tsx
@@ -34,16 +34,16 @@ export function LoRAManager({
isStreaming = false,
loraMergeStrategy = "permanent_merge",
}: LoRAManagerProps) {
- const { listLoRAFiles, downloadLoRAFile } = useApi();
+ const { listLoRAFiles, installLoRAFile } = useApi();
const { isConnected: isCloudConnected, isConnecting: isCloudConnecting } =
useCloudStatus();
const isCloudPending = isCloudConnecting && !isCloudConnected;
const [availableLoRAs, setAvailableLoRAs] = useState([]);
const [isLoadingLoRAs, setIsLoadingLoRAs] = useState(false);
const [localScales, setLocalScales] = useState>({});
- const [downloadUrl, setDownloadUrl] = useState("");
- const [isDownloading, setIsDownloading] = useState(false);
- const [downloadError, setDownloadError] = useState(null);
+ const [installUrl, setInstallUrl] = useState("");
+ const [isInstalling, setIsInstalling] = useState(false);
+ const [installError, setInstallError] = useState(null);
const loadAvailableLoRAs = async () => {
setIsLoadingLoRAs(true);
@@ -130,26 +130,26 @@ export function LoRAManager({
return { isDisabled, tooltipText };
};
- const handleDownloadLoRA = async () => {
- if (!downloadUrl.trim()) return;
- setIsDownloading(true);
- setDownloadError(null);
- const url = downloadUrl.trim();
+ const handleInstallLoRA = async () => {
+ if (!installUrl.trim()) return;
+ setIsInstalling(true);
+ setInstallError(null);
+ const url = installUrl.trim();
const filename = url.split("/").pop()?.split("?")[0] || "LoRA file";
try {
- await toast.promise(downloadLoRAFile({ url }), {
- loading: `Downloading ${filename}...`,
+ await toast.promise(installLoRAFile({ url }), {
+ loading: `Installing ${filename}...`,
success: response => response.message,
- error: err => err.message || "Download failed",
+ error: err => err.message || "Install failed",
});
- setDownloadUrl("");
+ setInstallUrl("");
await loadAvailableLoRAs();
} catch (error) {
- setDownloadError(
- error instanceof Error ? error.message : "Download failed"
+ setInstallError(
+ error instanceof Error ? error.message : "Install failed"
);
} finally {
- setIsDownloading(false);
+ setIsInstalling(false);
}
};
@@ -187,34 +187,34 @@ export function LoRAManager({
{
- setDownloadUrl(e.target.value);
- setDownloadError(null);
+ setInstallUrl(e.target.value);
+ setInstallError(null);
}}
placeholder="Paste LoRA URL (HuggingFace, CivitAI...)"
- disabled={disabled || isDownloading || isCloudPending}
+ disabled={disabled || isInstalling || isCloudPending}
className="h-7 text-xs flex-1"
onKeyDown={e => {
- if (e.key === "Enter") handleDownloadLoRA();
+ if (e.key === "Enter") handleInstallLoRA();
}}
/>
@@ -223,8 +223,8 @@ export function LoRAManager({
Waiting for cloud connection...
)}
- {downloadError && (
- {downloadError}
+ {installError && (
+ {installError}
)}
{loras.length === 0 && (
diff --git a/frontend/src/hooks/useApi.ts b/frontend/src/hooks/useApi.ts
index ade7dacfe..4614e1520 100644
--- a/frontend/src/hooks/useApi.ts
+++ b/frontend/src/hooks/useApi.ts
@@ -12,8 +12,8 @@ import type {
PipelineSchemasResponse,
HardwareInfoResponse,
LoRAFilesResponse,
- LoRADownloadRequest,
- LoRADownloadResponse,
+ LoRAInstallRequest,
+ LoRAInstallResponse,
AssetsResponse,
AssetFileInfo,
WebRTCOfferRequest,
@@ -95,12 +95,12 @@ export function useApi() {
return api.listLoRAFiles();
}, [adapter, isCloudMode]);
- const downloadLoRAFile = useCallback(
- async (data: LoRADownloadRequest): Promise => {
+ const installLoRAFile = useCallback(
+ async (data: LoRAInstallRequest): Promise => {
if (isCloudMode && adapter) {
- return adapter.api.downloadLoRAFile(data);
+ return adapter.api.installLoRAFile(data);
}
- return api.downloadLoRAFile(data);
+ return api.installLoRAFile(data);
},
[adapter, isCloudMode]
);
@@ -204,7 +204,7 @@ export function useApi() {
// LoRA
listLoRAFiles,
- downloadLoRAFile,
+ installLoRAFile,
// Assets
listAssets,
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index 6e4840f87..b792842d7 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -366,20 +366,20 @@ export const listLoRAFiles = async (): Promise => {
return result;
};
-export interface LoRADownloadRequest {
+export interface LoRAInstallRequest {
url: string;
filename?: string;
}
-export interface LoRADownloadResponse {
+export interface LoRAInstallResponse {
message: string;
file: LoRAFileInfo;
}
-export const downloadLoRAFile = async (
- data: LoRADownloadRequest
-): Promise => {
- const response = await fetch("/api/v1/lora/download", {
+export const installLoRAFile = async (
+ data: LoRAInstallRequest
+): Promise => {
+ const response = await fetch("/api/v1/lora/install", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
@@ -388,7 +388,7 @@ export const downloadLoRAFile = async (
if (!response.ok) {
const errorText = await response.text();
throw new Error(
- `Download LoRA failed: ${response.status} ${response.statusText}: ${errorText}`
+ `Install LoRA failed: ${response.status} ${response.statusText}: ${errorText}`
);
}
diff --git a/frontend/src/lib/cloudAdapter.ts b/frontend/src/lib/cloudAdapter.ts
index c9bba3a87..dbcfd116f 100644
--- a/frontend/src/lib/cloudAdapter.ts
+++ b/frontend/src/lib/cloudAdapter.ts
@@ -31,8 +31,8 @@ import type {
PipelineSchemasResponse,
HardwareInfoResponse,
LoRAFilesResponse,
- LoRADownloadRequest,
- LoRADownloadResponse,
+ LoRAInstallRequest,
+ LoRAInstallResponse,
AssetsResponse,
AssetFileInfo,
} from "./api";
@@ -401,10 +401,10 @@ export class CloudAdapter {
listLoRAFiles: (): Promise =>
this.apiRequest("GET", "/api/v1/lora/list"),
- downloadLoRAFile: (
- data: LoRADownloadRequest
- ): Promise =>
- this.apiRequest("POST", "/api/v1/lora/download", data, 300000),
+ installLoRAFile: (
+ data: LoRAInstallRequest
+ ): Promise =>
+ this.apiRequest("POST", "/api/v1/lora/install", data, 300000),
listAssets: (type?: "image" | "video"): Promise =>
this.apiRequest(
diff --git a/src/scope/cloud/fal_app.py b/src/scope/cloud/fal_app.py
index e27ef241f..ea90f226f 100644
--- a/src/scope/cloud/fal_app.py
+++ b/src/scope/cloud/fal_app.py
@@ -652,8 +652,8 @@ async def handle_api_request(payload: dict):
timeout=60.0, # Longer timeout for uploads
)
else:
- # Use longer timeout for LoRA downloads
- post_timeout = 300.0 if "/lora/download" in path else 30.0
+ # Use longer timeout for LoRA installs
+ post_timeout = 300.0 if "/lora/install" in path else 30.0
response = await client.post(
f"{SCOPE_BASE_URL}{path}",
json=body,
diff --git a/src/scope/server/app.py b/src/scope/server/app.py
index 3ba5a5dc5..f683d8913 100644
--- a/src/scope/server/app.py
+++ b/src/scope/server/app.py
@@ -849,24 +849,24 @@ def process_lora_file(file_path: Path, lora_dir: Path) -> LoRAFileInfo:
raise HTTPException(status_code=500, detail=str(e)) from e
-class LoRADownloadRequest(BaseModel):
+class LoRAInstallRequest(BaseModel):
url: str
filename: str | None = None
-class LoRADownloadResponse(BaseModel):
+class LoRAInstallResponse(BaseModel):
message: str
file: LoRAFileInfo
-@app.post("/api/v1/lora/download", response_model=LoRADownloadResponse)
-async def download_lora_file(
- request: LoRADownloadRequest,
+@app.post("/api/v1/lora/install", response_model=LoRAInstallResponse)
+async def install_lora_file(
+ request: LoRAInstallRequest,
cloud_manager: "CloudConnectionManager" = Depends(get_cloud_connection_manager),
):
- """Download a LoRA file from a URL (e.g. HuggingFace, CivitAI).
+ """Install a LoRA file from a URL (e.g. HuggingFace, CivitAI).
- When cloud mode is active, the download happens on the cloud machine.
+ When cloud mode is active, the install happens on the cloud machine.
"""
from urllib.parse import unquote, urlparse
@@ -875,22 +875,22 @@ async def download_lora_file(
try:
# If cloud mode is active, proxy to cloud
if cloud_manager.is_connected:
- logger.info("[CLOUD] Proxying LoRA download to cloud")
+ logger.info("[CLOUD] Proxying LoRA install to cloud")
response = await cloud_manager.api_request(
method="POST",
- path="/api/v1/lora/download",
+ path="/api/v1/lora/install",
body=request.model_dump(),
timeout=300.0,
)
status = response.get("status", 200)
if status >= 400:
detail = response.get("error") or response.get("data", {}).get(
- "detail", "Cloud LoRA download failed"
+ "detail", "Cloud LoRA install failed"
)
raise HTTPException(status_code=status, detail=detail)
data = response.get("data", {})
- return LoRADownloadResponse(
- message=data.get("message", "Downloaded via cloud"),
+ return LoRAInstallResponse(
+ message=data.get("message", "Installed via cloud"),
file=LoRAFileInfo(**data["file"]),
)
@@ -923,7 +923,7 @@ async def download_lora_file(
detail=f"File '{filename}' already exists.",
)
- # Download in a thread to avoid blocking the event loop
+ # Install in a thread to avoid blocking the event loop
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, http_get, request.url, dest_path)
@@ -935,15 +935,15 @@ async def download_lora_file(
folder=None,
)
- return LoRADownloadResponse(
- message=f"Successfully downloaded '{filename}'",
+ return LoRAInstallResponse(
+ message=f"Successfully installed '{filename}'",
file=file_info,
)
except HTTPException:
raise
except Exception as e:
- logger.error(f"download_lora_file: Error downloading LoRA: {e}")
+ logger.error(f"install_lora_file: Error installing LoRA: {e}")
raise HTTPException(status_code=500, detail=str(e)) from e
From 6ef02b23a4b373a6635add4643ffd5bf5f39a661 Mon Sep 17 00:00:00 2001
From: Max Holland
Date: Tue, 17 Feb 2026 10:51:05 +0000
Subject: [PATCH 05/14] Tidy up
Signed-off-by: Max Holland
---
frontend/src/components/LoRAManager.tsx | 4 ++-
frontend/src/lib/api.ts | 4 +--
frontend/src/lib/cloudAdapter.ts | 8 ++---
src/scope/cloud/fal_app.py | 2 +-
src/scope/server/app.py | 48 ++++---------------------
5 files changed, 15 insertions(+), 51 deletions(-)
diff --git a/frontend/src/components/LoRAManager.tsx b/frontend/src/components/LoRAManager.tsx
index b5f5f3667..879ee752f 100644
--- a/frontend/src/components/LoRAManager.tsx
+++ b/frontend/src/components/LoRAManager.tsx
@@ -137,11 +137,13 @@ export function LoRAManager({
const url = installUrl.trim();
const filename = url.split("/").pop()?.split("?")[0] || "LoRA file";
try {
- await toast.promise(installLoRAFile({ url }), {
+ const installPromise = installLoRAFile({ url });
+ toast.promise(installPromise, {
loading: `Installing ${filename}...`,
success: response => response.message,
error: err => err.message || "Install failed",
});
+ await installPromise;
setInstallUrl("");
await loadAvailableLoRAs();
} catch (error) {
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index b792842d7..eed27dc5d 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -351,7 +351,7 @@ export interface LoRAFilesResponse {
}
export const listLoRAFiles = async (): Promise => {
- const response = await fetch("/api/v1/lora/list", {
+ const response = await fetch("/api/v1/loras", {
method: "GET",
});
@@ -379,7 +379,7 @@ export interface LoRAInstallResponse {
export const installLoRAFile = async (
data: LoRAInstallRequest
): Promise => {
- const response = await fetch("/api/v1/lora/install", {
+ const response = await fetch("/api/v1/loras", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
diff --git a/frontend/src/lib/cloudAdapter.ts b/frontend/src/lib/cloudAdapter.ts
index dbcfd116f..d979ebc44 100644
--- a/frontend/src/lib/cloudAdapter.ts
+++ b/frontend/src/lib/cloudAdapter.ts
@@ -399,12 +399,10 @@ export class CloudAdapter {
this.apiRequest("GET", "/api/v1/hardware/info"),
listLoRAFiles: (): Promise =>
- this.apiRequest("GET", "/api/v1/lora/list"),
+ this.apiRequest("GET", "/api/v1/loras"),
- installLoRAFile: (
- data: LoRAInstallRequest
- ): Promise =>
- this.apiRequest("POST", "/api/v1/lora/install", data, 300000),
+ installLoRAFile: (data: LoRAInstallRequest): Promise =>
+ this.apiRequest("POST", "/api/v1/loras", data, 300000),
listAssets: (type?: "image" | "video"): Promise =>
this.apiRequest(
diff --git a/src/scope/cloud/fal_app.py b/src/scope/cloud/fal_app.py
index ea90f226f..6c8e9ffba 100644
--- a/src/scope/cloud/fal_app.py
+++ b/src/scope/cloud/fal_app.py
@@ -653,7 +653,7 @@ async def handle_api_request(payload: dict):
)
else:
# Use longer timeout for LoRA installs
- post_timeout = 300.0 if "/lora/install" in path else 30.0
+ post_timeout = 300.0 if path == "/api/v1/loras" else 30.0
response = await client.post(
f"{SCOPE_BASE_URL}{path}",
json=body,
diff --git a/src/scope/server/app.py b/src/scope/server/app.py
index f683d8913..9ee2c71c3 100644
--- a/src/scope/server/app.py
+++ b/src/scope/server/app.py
@@ -792,8 +792,10 @@ class LoRAFilesResponse(BaseModel):
lora_files: list[LoRAFileInfo]
-@app.get("/api/v1/lora/list", response_model=LoRAFilesResponse)
+@app.get("/api/v1/loras", response_model=LoRAFilesResponse)
+@cloud_proxy()
async def list_lora_files(
+ http_request: Request,
cloud_manager: "CloudConnectionManager" = Depends(get_cloud_connection_manager),
):
"""List available LoRA files in the models/lora directory and its subdirectories.
@@ -816,25 +818,6 @@ def process_lora_file(file_path: Path, lora_dir: Path) -> LoRAFileInfo:
)
try:
- # If cloud mode is active, proxy to cloud
- if cloud_manager.is_connected:
- logger.info("[CLOUD] Fetching LoRA list from cloud")
- response = await cloud_manager.api_request(
- method="GET",
- path="/api/v1/lora/list",
- timeout=30.0,
- )
- status = response.get("status", 200)
- if status >= 400:
- detail = response.get("error") or response.get("data", {}).get(
- "detail", "Cloud LoRA list request failed"
- )
- raise HTTPException(status_code=status, detail=detail)
- data = response.get("data", {})
- return LoRAFilesResponse(
- lora_files=[LoRAFileInfo(**f) for f in data.get("lora_files", [])]
- )
-
lora_dir = get_lora_dir()
lora_files: list[LoRAFileInfo] = []
@@ -859,9 +842,11 @@ class LoRAInstallResponse(BaseModel):
file: LoRAFileInfo
-@app.post("/api/v1/lora/install", response_model=LoRAInstallResponse)
+@app.post("/api/v1/loras", response_model=LoRAInstallResponse)
+@cloud_proxy(timeout=300.0)
async def install_lora_file(
request: LoRAInstallRequest,
+ http_request: Request,
cloud_manager: "CloudConnectionManager" = Depends(get_cloud_connection_manager),
):
"""Install a LoRA file from a URL (e.g. HuggingFace, CivitAI).
@@ -873,27 +858,6 @@ async def install_lora_file(
from .download_models import http_get
try:
- # If cloud mode is active, proxy to cloud
- if cloud_manager.is_connected:
- logger.info("[CLOUD] Proxying LoRA install to cloud")
- response = await cloud_manager.api_request(
- method="POST",
- path="/api/v1/lora/install",
- body=request.model_dump(),
- timeout=300.0,
- )
- status = response.get("status", 200)
- if status >= 400:
- detail = response.get("error") or response.get("data", {}).get(
- "detail", "Cloud LoRA install failed"
- )
- raise HTTPException(status_code=status, detail=detail)
- data = response.get("data", {})
- return LoRAInstallResponse(
- message=data.get("message", "Installed via cloud"),
- file=LoRAFileInfo(**data["file"]),
- )
-
# Determine filename from URL if not provided
filename = request.filename
if not filename:
From f35e8318f94185a4a42e041f129ae22d9bb64039 Mon Sep 17 00:00:00 2001
From: Max Holland
Date: Thu, 19 Feb 2026 11:44:37 +0000
Subject: [PATCH 06/14] Move installation to a settings dialog tab
Signed-off-by: Max Holland
---
docs/lora.md | 23 ++-
frontend/src/App.tsx | 11 +-
frontend/src/components/ComplexFields.tsx | 2 +
frontend/src/components/Header.tsx | 4 +-
frontend/src/components/LoRAManager.tsx | 157 ++++--------------
frontend/src/components/SettingsDialog.tsx | 53 +++++-
frontend/src/components/SettingsPanel.tsx | 4 +
frontend/src/components/settings/LoRAsTab.tsx | 144 ++++++++++++++++
frontend/src/contexts/LoRAsContext.tsx | 21 +++
frontend/src/hooks/useLoRAFiles.ts | 55 ++++++
frontend/src/pages/StreamPage.tsx | 1 +
11 files changed, 338 insertions(+), 137 deletions(-)
create mode 100644 frontend/src/components/settings/LoRAsTab.tsx
create mode 100644 frontend/src/contexts/LoRAsContext.tsx
create mode 100644 frontend/src/hooks/useLoRAFiles.ts
diff --git a/docs/lora.md b/docs/lora.md
index a0a7c4f01..0cfd66e70 100644
--- a/docs/lora.md
+++ b/docs/lora.md
@@ -10,7 +10,7 @@ The pipelines in Scope support using one or multiple LoRAs to customize concepts
- The `memflow` pipeline is compatible with Wan2.1-T2V-1.3B LoRAs.
- The `krea-realtime-video` pipeline is compatible with Wan2.1-T2V-14B LoRAs.
-## Downloading LoRAs
+## Installing LoRAs
Scope supports using LoRAs that can be downloaded from popular hubs such as [HuggingFace](https://huggingface.co/) or [CivitAI](https://civitai.com/).
@@ -25,7 +25,22 @@ A few LoRAs that you can start with for `krea-realtime-video`:
- [Film Noir](https://huggingface.co/Remade-AI/Film-Noir)
- [Pixar](https://huggingface.co/Remade-AI/Pixar)
-### Local
+### Using the Settings Dialog
+
+The easiest way to install LoRAs is through the Settings dialog:
+
+1. Click the **Settings** icon (gear) in the header
+2. Select the **LoRAs** tab
+3. Paste a LoRA URL from HuggingFace or CivitAI into the input field
+4. Click **Install**
+
+The LoRA will be downloaded and saved to your LoRA directory automatically. Once installed, you can select it from the LoRA Adapters section in the Settings panel.
+
+### Manual Installation
+
+For manual installation, follow the steps below.
+
+#### Local
If you are running Scope locally you can simply download the LoRA files to your computer and move them to the proper directory.
@@ -41,9 +56,9 @@ Click the download button and move the file to the `~/.daydream-scope/models/lor
Click the download button and move the file to the `~/.daydream-scope/models/lora` folder.
-### Cloud
+#### Cloud
-If you are running the Scope server on a remote machine in the cloud, then we recommend you progamatically download the LoRA files to the remote machine.
+If you are running the Scope server on a remote machine in the cloud, then we recommend you programmatically download the LoRA files to the remote machine.
**HuggingFace**
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index d39ae07bc..1c8de9439 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
import { StreamPage } from "./pages/StreamPage";
import { Toaster } from "./components/ui/sonner";
import { PipelinesProvider } from "./contexts/PipelinesContext";
+import { LoRAsProvider } from "./contexts/LoRAsContext";
import { CloudProvider } from "./lib/cloudContext";
import { CloudStatusProvider } from "./hooks/useCloudStatus";
import { handleOAuthCallback, initElectronAuthListener } from "./lib/auth";
@@ -95,10 +96,12 @@ function App() {
return (
-
-
-
-
+
+
+
+
+
+
);
diff --git a/frontend/src/components/ComplexFields.tsx b/frontend/src/components/ComplexFields.tsx
index 0a18ae85d..a94ff39cb 100644
--- a/frontend/src/components/ComplexFields.tsx
+++ b/frontend/src/components/ComplexFields.tsx
@@ -79,6 +79,7 @@ export interface SchemaComplexFieldContext {
value: unknown,
isRuntimeParam?: boolean
) => void;
+ onOpenLoRAsSettings?: () => void;
}
export interface SchemaComplexFieldProps {
@@ -220,6 +221,7 @@ export function SchemaComplexField({
disabled={ctx.isLoading ?? false}
isStreaming={ctx.isStreaming ?? false}
loraMergeStrategy={ctx.loraMergeStrategy ?? "permanent_merge"}
+ onOpenLoRAsSettings={ctx.onOpenLoRAsSettings}
/>
);
diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx
index 27941f9eb..b2bcbb869 100644
--- a/frontend/src/components/Header.tsx
+++ b/frontend/src/components/Header.tsx
@@ -23,7 +23,7 @@ export function Header({
}: HeaderProps) {
const [settingsOpen, setSettingsOpen] = useState(false);
const [initialTab, setInitialTab] = useState<
- "general" | "account" | "api-keys" | "plugins"
+ "general" | "account" | "api-keys" | "plugins" | "loras"
>("general");
const [initialPluginPath, setInitialPluginPath] = useState("");
@@ -83,7 +83,7 @@ export function Header({
useEffect(() => {
if (openSettingsTab) {
setInitialTab(
- openSettingsTab as "general" | "account" | "api-keys" | "plugins"
+ openSettingsTab as "general" | "account" | "api-keys" | "plugins" | "loras"
);
setSettingsOpen(true);
onSettingsTabOpened?.();
diff --git a/frontend/src/components/LoRAManager.tsx b/frontend/src/components/LoRAManager.tsx
index 879ee752f..5fd4dc05b 100644
--- a/frontend/src/components/LoRAManager.tsx
+++ b/frontend/src/components/LoRAManager.tsx
@@ -8,15 +8,12 @@ import {
SelectTrigger,
SelectValue,
} from "./ui/select";
-import { Plus, X, RefreshCw, Download } from "lucide-react";
+import { Plus, X } from "lucide-react";
import { LabelWithTooltip } from "./ui/label-with-tooltip";
import { PARAMETER_METADATA } from "../data/parameterMetadata";
import type { LoRAConfig, LoraMergeStrategy } from "../types";
-import type { LoRAFileInfo } from "../lib/api";
-import { useApi } from "../hooks/useApi";
+import { useLoRAsContext } from "../contexts/LoRAsContext";
import { useCloudStatus } from "../hooks/useCloudStatus";
-import { toast } from "sonner";
-import { Input } from "./ui/input";
import { FilePicker } from "./ui/file-picker";
interface LoRAManagerProps {
@@ -25,6 +22,7 @@ interface LoRAManagerProps {
disabled?: boolean;
isStreaming?: boolean;
loraMergeStrategy?: LoraMergeStrategy;
+ onOpenLoRAsSettings?: () => void;
}
export function LoRAManager({
@@ -33,33 +31,11 @@ export function LoRAManager({
disabled,
isStreaming = false,
loraMergeStrategy = "permanent_merge",
+ onOpenLoRAsSettings,
}: LoRAManagerProps) {
- const { listLoRAFiles, installLoRAFile } = useApi();
- const { isConnected: isCloudConnected, isConnecting: isCloudConnecting } =
- useCloudStatus();
- const isCloudPending = isCloudConnecting && !isCloudConnected;
- const [availableLoRAs, setAvailableLoRAs] = useState([]);
- const [isLoadingLoRAs, setIsLoadingLoRAs] = useState(false);
+ const { loraFiles: availableLoRAs } = useLoRAsContext();
+ const { isConnected: isCloudConnected } = useCloudStatus();
const [localScales, setLocalScales] = useState>({});
- const [installUrl, setInstallUrl] = useState("");
- const [isInstalling, setIsInstalling] = useState(false);
- const [installError, setInstallError] = useState(null);
-
- 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(() => {
@@ -70,21 +46,19 @@ export function LoRAManager({
setLocalScales(newLocalScales);
}, [loras]);
- // Track cloud connection state and clear LoRAs when it changes
+ // Track cloud connection state and clear configured LoRAs when it changes
// (switching between local/cloud means different LoRA file lists)
const prevCloudConnectedRef = useRef(null);
useEffect(() => {
- // On first render, just store the initial state
if (prevCloudConnectedRef.current === null) {
prevCloudConnectedRef.current = isCloudConnected;
return;
}
- // Clear LoRAs when cloud connection state changes (connected or disconnected)
+ // Clear configured LoRAs when cloud connection state changes
if (prevCloudConnectedRef.current !== isCloudConnected) {
onLorasChange([]);
- loadAvailableLoRAs();
}
prevCloudConnectedRef.current = isCloudConnected;
@@ -130,117 +104,48 @@ export function LoRAManager({
return { isDisabled, tooltipText };
};
- const handleInstallLoRA = async () => {
- if (!installUrl.trim()) return;
- setIsInstalling(true);
- setInstallError(null);
- const url = installUrl.trim();
- const filename = url.split("/").pop()?.split("?")[0] || "LoRA file";
- try {
- const installPromise = installLoRAFile({ url });
- toast.promise(installPromise, {
- loading: `Installing ${filename}...`,
- success: response => response.message,
- error: err => err.message || "Install failed",
- });
- await installPromise;
- setInstallUrl("");
- await loadAvailableLoRAs();
- } catch (error) {
- setInstallError(
- error instanceof Error ? error.message : "Install failed"
- );
- } finally {
- setIsInstalling(false);
- }
- };
-
return (
-
-
- {isCloudPending && (
-
- Waiting for cloud connection...
-
- )}
- {installError && (
-
{installError}
- )}
{loras.length === 0 && (
- No LoRA adapters configured. Follow the{" "}
+ No LoRA adapters configured.{" "}
+ {onOpenLoRAsSettings ? (
+ <>
+ {" "}
+ to install LoRAs or follow the{" "}
+ >
+ ) : (
+ "Follow the "
+ )}
docs
{" "}
- to add LoRA files.
+ for manual installation.
)}
diff --git a/frontend/src/components/SettingsDialog.tsx b/frontend/src/components/SettingsDialog.tsx
index ea81b870b..ecd4523f3 100644
--- a/frontend/src/components/SettingsDialog.tsx
+++ b/frontend/src/components/SettingsDialog.tsx
@@ -4,9 +4,11 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs";
import { AccountTab } from "./settings/AccountTab";
import { ApiKeysTab } from "./settings/ApiKeysTab";
import { GeneralTab } from "./settings/GeneralTab";
+import { LoRAsTab } from "./settings/LoRAsTab";
import { PluginsTab } from "./settings/PluginsTab";
import { ReportBugDialog } from "./ReportBugDialog";
import { usePipelinesContext } from "@/contexts/PipelinesContext";
+import { useLoRAsContext } from "@/contexts/LoRAsContext";
import type { InstalledPlugin } from "@/types/settings";
import {
listPlugins,
@@ -15,6 +17,7 @@ import {
restartServer,
waitForServer,
getServerInfo,
+ installLoRAFile,
type FailedPluginInfo,
} from "@/lib/api";
import { toast } from "sonner";
@@ -22,7 +25,7 @@ import { toast } from "sonner";
interface SettingsDialogProps {
open: boolean;
onClose: () => void;
- initialTab?: "general" | "account" | "api-keys" | "plugins";
+ initialTab?: "general" | "account" | "api-keys" | "plugins" | "loras";
initialPluginPath?: string;
onPipelinesRefresh?: () => Promise
;
cloudDisabled?: boolean;
@@ -64,6 +67,7 @@ export function SettingsDialog({
cloudDisabled,
}: SettingsDialogProps) {
const { refetch: refetchPipelines } = usePipelinesContext();
+ const { loraFiles, isLoading: isLoadingLoRAs, refresh: refreshLoRAs } = useLoRAsContext();
const [modelsDirectory, setModelsDirectory] = useState(
"~/.daydream-scope/models"
);
@@ -75,6 +79,9 @@ export function SettingsDialog({
const [isLoadingPlugins, setIsLoadingPlugins] = useState(false);
const [isInstalling, setIsInstalling] = useState(false);
const [activeTab, setActiveTab] = useState(initialTab);
+ // LoRA install state (files come from context)
+ const [loraInstallUrl, setLoraInstallUrl] = useState("");
+ const [isInstallingLoRA, setIsInstallingLoRA] = useState(false);
const [version, setVersion] = useState("");
const [gitCommit, setGitCommit] = useState("");
// Track install/update/uninstall operations to suppress spurious error toasts
@@ -139,6 +146,13 @@ export function SettingsDialog({
}
}, [open, activeTab, fetchPlugins]);
+ // Refresh LoRAs when switching to LoRAs tab
+ useEffect(() => {
+ if (open && activeTab === "loras") {
+ refreshLoRAs();
+ }
+ }, [open, activeTab, refreshLoRAs]);
+
const handleModelsDirectoryChange = (value: string) => {
console.log("Models directory changed:", value);
setModelsDirectory(value);
@@ -320,6 +334,26 @@ export function SettingsDialog({
}
};
+ const handleInstallLoRA = async (url: string) => {
+ setIsInstallingLoRA(true);
+ const filename = url.split("/").pop()?.split("?")[0] || "LoRA file";
+ try {
+ const installPromise = installLoRAFile({ url });
+ toast.promise(installPromise, {
+ loading: `Installing ${filename}...`,
+ success: response => response.message,
+ error: err => err.message || "Install failed",
+ });
+ await installPromise;
+ setLoraInstallUrl("");
+ await refreshLoRAs();
+ } catch (error) {
+ console.error("Failed to install LoRA:", error);
+ } finally {
+ setIsInstallingLoRA(false);
+ }
+ };
+
return (
)}
diff --git a/frontend/src/components/settings/LoRAsTab.tsx b/frontend/src/components/settings/LoRAsTab.tsx
new file mode 100644
index 000000000..d6bcbd005
--- /dev/null
+++ b/frontend/src/components/settings/LoRAsTab.tsx
@@ -0,0 +1,144 @@
+import { RefreshCw } from "lucide-react";
+import { Button } from "../ui/button";
+import { Input } from "../ui/input";
+import type { LoRAFileInfo } from "@/lib/api";
+
+interface LoRAsTabProps {
+ loraFiles: LoRAFileInfo[];
+ installUrl: string;
+ onInstallUrlChange: (url: string) => void;
+ onInstall: (url: string) => void;
+ onRefresh: () => void;
+ isLoading?: boolean;
+ isInstalling?: boolean;
+}
+
+export function LoRAsTab({
+ loraFiles,
+ installUrl,
+ onInstallUrlChange,
+ onInstall,
+ onRefresh,
+ isLoading = false,
+ isInstalling = false,
+}: LoRAsTabProps) {
+ const handleInstall = () => {
+ if (installUrl.trim()) {
+ onInstall(installUrl.trim());
+ }
+ };
+
+ // Group LoRA files by folder
+ const groupedLoRAs = loraFiles.reduce(
+ (acc, lora) => {
+ const folder = lora.folder || "Root";
+ if (!acc[folder]) {
+ acc[folder] = [];
+ }
+ acc[folder].push(lora);
+ return acc;
+ },
+ {} as Record
+ );
+
+ const sortedFolders = Object.keys(groupedLoRAs).sort((a, b) => {
+ if (a === "Root") return -1;
+ if (b === "Root") return 1;
+ return a.localeCompare(b);
+ });
+
+ return (
+
+ {/* Install Section */}
+
+
+ onInstallUrlChange(e.target.value)}
+ placeholder="LoRA URL (HuggingFace or CivitAI)"
+ className="flex-1"
+ onKeyDown={e => {
+ if (e.key === "Enter") handleInstall();
+ }}
+ />
+
+
+
+
+ {/* Installed LoRAs Section */}
+
+
+
+ Installed LoRAs
+
+
+
+
+ {isLoading ? (
+
Loading LoRAs...
+ ) : loraFiles.length === 0 ? (
+
+
No LoRA files found.
+
+ Install LoRAs using the URL input above, or follow the{" "}
+
+ documentation
+ {" "}
+ for manual installation.
+
+
+ ) : (
+
+ {sortedFolders.map(folder => (
+
+ {sortedFolders.length > 1 && (
+
+ {folder}
+
+ )}
+ {groupedLoRAs[folder].map(lora => (
+
+
+
+ {lora.name}
+
+
+ {lora.size_mb.toFixed(1)} MB
+
+
+
+ ))}
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/frontend/src/contexts/LoRAsContext.tsx b/frontend/src/contexts/LoRAsContext.tsx
new file mode 100644
index 000000000..b04a8e61d
--- /dev/null
+++ b/frontend/src/contexts/LoRAsContext.tsx
@@ -0,0 +1,21 @@
+import { createContext, useContext, type ReactNode } from "react";
+import { useLoRAFiles, type UseLoRAFilesReturn } from "@/hooks/useLoRAFiles";
+
+const LoRAsContext = createContext(null);
+
+export function LoRAsProvider({ children }: { children: ReactNode }) {
+ const loraFilesState = useLoRAFiles();
+ return (
+
+ {children}
+
+ );
+}
+
+export function useLoRAsContext() {
+ const context = useContext(LoRAsContext);
+ if (!context) {
+ throw new Error("useLoRAsContext must be used within LoRAsProvider");
+ }
+ return context;
+}
diff --git a/frontend/src/hooks/useLoRAFiles.ts b/frontend/src/hooks/useLoRAFiles.ts
new file mode 100644
index 000000000..d0d20be2f
--- /dev/null
+++ b/frontend/src/hooks/useLoRAFiles.ts
@@ -0,0 +1,55 @@
+import { useState, useCallback, useRef, useEffect } from "react";
+import { useApi } from "./useApi";
+import { useCloudStatus } from "./useCloudStatus";
+import type { LoRAFileInfo } from "@/lib/api";
+
+export interface UseLoRAFilesReturn {
+ loraFiles: LoRAFileInfo[];
+ isLoading: boolean;
+ refresh: () => Promise;
+}
+
+export function useLoRAFiles(): UseLoRAFilesReturn {
+ const { listLoRAFiles } = useApi();
+ const { isConnected: isCloudConnected } = useCloudStatus();
+ const [loraFiles, setLoraFiles] = useState([]);
+ const [isLoading, setIsLoading] = useState(false);
+ const prevCloudConnectedRef = useRef(null);
+
+ const refresh = useCallback(async () => {
+ setIsLoading(true);
+ try {
+ const response = await listLoRAFiles();
+ setLoraFiles(response.lora_files);
+ } catch (error) {
+ console.error("Failed to load LoRA files:", error);
+ } finally {
+ setIsLoading(false);
+ }
+ }, [listLoRAFiles]);
+
+ // Initial load
+ useEffect(() => {
+ refresh();
+ }, [refresh]);
+
+ // Refresh when cloud connection state changes
+ useEffect(() => {
+ if (prevCloudConnectedRef.current === null) {
+ prevCloudConnectedRef.current = isCloudConnected;
+ return;
+ }
+
+ if (prevCloudConnectedRef.current !== isCloudConnected) {
+ refresh();
+ }
+
+ prevCloudConnectedRef.current = isCloudConnected;
+ }, [isCloudConnected, refresh]);
+
+ return {
+ loraFiles,
+ isLoading,
+ refresh,
+ };
+}
diff --git a/frontend/src/pages/StreamPage.tsx b/frontend/src/pages/StreamPage.tsx
index 11b366d3a..d43268a36 100644
--- a/frontend/src/pages/StreamPage.tsx
+++ b/frontend/src/pages/StreamPage.tsx
@@ -1707,6 +1707,7 @@ export function StreamPage() {
}
}}
isCloudMode={isCloudMode}
+ onOpenLoRAsSettings={() => setOpenSettingsTab("loras")}
/>
From 25aa7b73db6429addd0d6772ba02ee0bb95c0eae Mon Sep 17 00:00:00 2001
From: Max Holland
Date: Thu, 19 Feb 2026 12:17:33 +0000
Subject: [PATCH 07/14] Fix civitai install
Signed-off-by: Max Holland
---
frontend/src/components/Header.tsx | 7 ++-
frontend/src/components/SettingsDialog.tsx | 6 +-
src/scope/server/app.py | 66 +++++++++++++++++++++-
3 files changed, 75 insertions(+), 4 deletions(-)
diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx
index b2bcbb869..167e54f1e 100644
--- a/frontend/src/components/Header.tsx
+++ b/frontend/src/components/Header.tsx
@@ -83,7 +83,12 @@ export function Header({
useEffect(() => {
if (openSettingsTab) {
setInitialTab(
- openSettingsTab as "general" | "account" | "api-keys" | "plugins" | "loras"
+ openSettingsTab as
+ | "general"
+ | "account"
+ | "api-keys"
+ | "plugins"
+ | "loras"
);
setSettingsOpen(true);
onSettingsTabOpened?.();
diff --git a/frontend/src/components/SettingsDialog.tsx b/frontend/src/components/SettingsDialog.tsx
index ecd4523f3..6264b5250 100644
--- a/frontend/src/components/SettingsDialog.tsx
+++ b/frontend/src/components/SettingsDialog.tsx
@@ -67,7 +67,11 @@ export function SettingsDialog({
cloudDisabled,
}: SettingsDialogProps) {
const { refetch: refetchPipelines } = usePipelinesContext();
- const { loraFiles, isLoading: isLoadingLoRAs, refresh: refreshLoRAs } = useLoRAsContext();
+ const {
+ loraFiles,
+ isLoading: isLoadingLoRAs,
+ refresh: refreshLoRAs,
+ } = useLoRAsContext();
const [modelsDirectory, setModelsDirectory] = useState(
"~/.daydream-scope/models"
);
diff --git a/src/scope/server/app.py b/src/scope/server/app.py
index 9ee2c71c3..36a6dea18 100644
--- a/src/scope/server/app.py
+++ b/src/scope/server/app.py
@@ -842,6 +842,9 @@ class LoRAInstallResponse(BaseModel):
file: LoRAFileInfo
+ALLOWED_LORA_HOSTS = {"civitai.com", "huggingface.co"}
+
+
@app.post("/api/v1/loras", response_model=LoRAInstallResponse)
@cloud_proxy(timeout=300.0)
async def install_lora_file(
@@ -853,18 +856,77 @@ async def install_lora_file(
When cloud mode is active, the install happens on the cloud machine.
"""
+ import re
from urllib.parse import unquote, urlparse
+ import httpx
+
from .download_models import http_get
try:
+ # Validate hostname is from allowed sources
+ parsed = urlparse(request.url)
+ hostname = parsed.hostname or ""
+ # Allow the domain itself or any subdomain (e.g., cdn.civitai.com)
+ is_allowed = any(
+ hostname == allowed or hostname.endswith(f".{allowed}")
+ for allowed in ALLOWED_LORA_HOSTS
+ )
+ if not is_allowed:
+ raise HTTPException(
+ status_code=400,
+ detail=f"URL must be from {' or '.join(sorted(ALLOWED_LORA_HOSTS))}",
+ )
+
+ # CivitAI requires a token for programmatic downloads
+ is_civitai = hostname == "civitai.com" or hostname.endswith(".civitai.com")
+ if is_civitai:
+ from urllib.parse import parse_qs
+
+ query_params = parse_qs(parsed.query)
+ if "token" not in query_params:
+ raise HTTPException(
+ status_code=400,
+ detail="CivitAI requires an API token for programmatic downloads. "
+ "Add your token to the URL: &token=YOUR_TOKEN. "
+ "Get your API key at https://civitai.com/user/account",
+ )
+
# Determine filename from URL if not provided
filename = request.filename
if not filename:
- parsed = urlparse(request.url)
filename = unquote(parsed.path.split("/")[-1])
+ # If still no filename (or it doesn't look like a file), try Content-Disposition
+ if not filename or "." not in filename:
+ # Use streaming GET instead of HEAD (some servers return 403 for HEAD)
+ with httpx.Client(follow_redirects=True, timeout=10.0) as client:
+ with client.stream("GET", request.url) as response:
+ if response.status_code == 401 or response.status_code == 403:
+ raise HTTPException(
+ status_code=response.status_code,
+ detail="Access denied. Check that the URL is correct and includes any required authentication.",
+ )
+ if response.status_code == 404:
+ raise HTTPException(
+ status_code=404,
+ detail="File not found. Check that the URL is correct.",
+ )
+ if response.status_code >= 400:
+ raise HTTPException(
+ status_code=response.status_code,
+ detail=f"Failed to fetch URL: HTTP {response.status_code}",
+ )
+ content_disp = response.headers.get("content-disposition", "")
+ # Parse filename from Content-Disposition header
+ # e.g., 'attachment; filename="model.safetensors"'
+ match = re.search(
+ r'filename[*]?=["\']?([^"\';]+)["\']?', content_disp
+ )
+ if match:
+ filename = unquote(match.group(1).strip())
+ # Don't read the body - just close the connection
- if not filename:
+ if not filename or "." not in filename:
raise HTTPException(
status_code=400,
detail="Could not determine filename from URL. Please provide a filename.",
From 76002729f50845132249166ad72d897b549106db Mon Sep 17 00:00:00 2001
From: Max Holland
Date: Thu, 19 Feb 2026 12:33:59 +0000
Subject: [PATCH 08/14] review comments
Signed-off-by: Max Holland
---
frontend/src/components/SettingsDialog.tsx | 12 +++++-------
1 file changed, 5 insertions(+), 7 deletions(-)
diff --git a/frontend/src/components/SettingsDialog.tsx b/frontend/src/components/SettingsDialog.tsx
index 6264b5250..f5a4c7860 100644
--- a/frontend/src/components/SettingsDialog.tsx
+++ b/frontend/src/components/SettingsDialog.tsx
@@ -341,17 +341,15 @@ export function SettingsDialog({
const handleInstallLoRA = async (url: string) => {
setIsInstallingLoRA(true);
const filename = url.split("/").pop()?.split("?")[0] || "LoRA file";
+ const toastId = toast.loading(`Installing ${filename}...`);
try {
- const installPromise = installLoRAFile({ url });
- toast.promise(installPromise, {
- loading: `Installing ${filename}...`,
- success: response => response.message,
- error: err => err.message || "Install failed",
- });
- await installPromise;
+ const response = await installLoRAFile({ url });
+ toast.success(response.message, { id: toastId });
setLoraInstallUrl("");
await refreshLoRAs();
} catch (error) {
+ const message = error instanceof Error ? error.message : "Install failed";
+ toast.error(message, { id: toastId });
console.error("Failed to install LoRA:", error);
} finally {
setIsInstallingLoRA(false);
From 4401056e5f6a0e5d915c4f961f524e7da8d45c58 Mon Sep 17 00:00:00 2001
From: Max Holland
Date: Thu, 19 Feb 2026 15:14:14 +0000
Subject: [PATCH 09/14] Manage civit API key through settings dialog
Signed-off-by: Max Holland
---
docs/lora.md | 20 ++++
docs/server.md | 11 +-
src/scope/server/app.py | 186 ++++++++++++++++++++++--------
src/scope/server/models_config.py | 49 ++++++++
4 files changed, 210 insertions(+), 56 deletions(-)
diff --git a/docs/lora.md b/docs/lora.md
index 0cfd66e70..f7d17743e 100644
--- a/docs/lora.md
+++ b/docs/lora.md
@@ -36,6 +36,26 @@ The easiest way to install LoRAs is through the Settings dialog:
The LoRA will be downloaded and saved to your LoRA directory automatically. Once installed, you can select it from the LoRA Adapters section in the Settings panel.
+#### CivitAI API Token
+
+CivitAI requires an API token for programmatic downloads. You can configure this in one of two ways:
+
+**Option 1: Settings Dialog**
+
+1. Click the **Settings** icon (gear) in the header
+2. Select the **API Keys** tab
+3. Enter your CivitAI API token and click **Save**
+
+**Option 2: Environment Variable**
+
+```bash
+export CIVITAI_API_TOKEN=your_civitai_token_here
+```
+
+> **Note:** The environment variable takes precedence over a token stored through the UI.
+
+Get your API key at [civitai.com/user/account](https://civitai.com/user/account).
+
### Manual Installation
For manual installation, follow the steps below.
diff --git a/docs/server.md b/docs/server.md
index 88d4fe9a4..65c8e5d5b 100644
--- a/docs/server.md
+++ b/docs/server.md
@@ -247,11 +247,12 @@ Options:
### Environment Variables
-| Variable | Description |
-| ----------------- | ------------------------------------------------------------- |
-| `PIPELINE` | Default pipeline to pre-warm on startup |
-| `HF_TOKEN` | Hugging Face token for downloading models and Cloudflare TURN |
-| `VERBOSE_LOGGING` | Enable verbose logging for debugging |
+| Variable | Description |
+| -------------------- | ------------------------------------------------------------- |
+| `PIPELINE` | Default pipeline to pre-warm on startup |
+| `HF_TOKEN` | Hugging Face token for downloading models and Cloudflare TURN |
+| `CIVITAI_API_TOKEN` | CivitAI API token for downloading LoRAs from CivitAI |
+| `VERBOSE_LOGGING` | Enable verbose logging for debugging |
### Available Pipelines
diff --git a/src/scope/server/app.py b/src/scope/server/app.py
index 36a6dea18..5ba2cc677 100644
--- a/src/scope/server/app.py
+++ b/src/scope/server/app.py
@@ -846,7 +846,6 @@ class LoRAInstallResponse(BaseModel):
@app.post("/api/v1/loras", response_model=LoRAInstallResponse)
-@cloud_proxy(timeout=300.0)
async def install_lora_file(
request: LoRAInstallRequest,
http_request: Request,
@@ -855,19 +854,73 @@ async def install_lora_file(
"""Install a LoRA file from a URL (e.g. HuggingFace, CivitAI).
When cloud mode is active, the install happens on the cloud machine.
+ Token injection for CivitAI URLs happens locally before proxying.
"""
+ from urllib.parse import parse_qs, urlparse
+
+ from .models_config import get_civitai_token
+
+ # Inject CivitAI token if needed (before cloud proxying)
+ url = request.url
+ parsed = urlparse(url)
+ hostname = parsed.hostname or ""
+ is_civitai = hostname == "civitai.com" or hostname.endswith(".civitai.com")
+
+ if is_civitai:
+ query_params = parse_qs(parsed.query)
+ if "token" not in query_params:
+ stored_token = get_civitai_token()
+ if stored_token:
+ separator = "&" if parsed.query else "?"
+ url = f"{url}{separator}token={stored_token}"
+ else:
+ raise HTTPException(
+ status_code=400,
+ detail="CivitAI requires an API token for programmatic downloads. "
+ "Add your CivitAI API key in Settings > API Keys. "
+ "Get your API key at https://civitai.com/user/account",
+ )
+
+ # If connected to cloud, proxy with the (potentially modified) URL
+ if cloud_manager.is_connected:
+ logger.info("Proxying LoRA install to cloud")
+ body = {"url": url, "filename": request.filename}
+ try:
+ response = await cloud_manager.api_request(
+ method="POST",
+ path="/api/v1/loras",
+ body=body,
+ timeout=300.0,
+ )
+ except Exception as e:
+ logger.error(f"Cloud proxy request failed: {e}")
+ raise HTTPException(
+ status_code=502,
+ detail=f"Cloud request failed: {e}",
+ ) from e
+
+ status = response.get("status", 200)
+ if status >= 400:
+ raise HTTPException(
+ status_code=status,
+ detail=response.get("error", "Cloud request failed"),
+ )
+ return response.get("data", {})
+
+ # Local installation
import re
- from urllib.parse import unquote, urlparse
+ from urllib.parse import unquote
import httpx
from .download_models import http_get
try:
- # Validate hostname is from allowed sources
- parsed = urlparse(request.url)
+ # Re-parse URL (may have been modified with token)
+ parsed = urlparse(url)
hostname = parsed.hostname or ""
- # Allow the domain itself or any subdomain (e.g., cdn.civitai.com)
+
+ # Validate hostname is from allowed sources
is_allowed = any(
hostname == allowed or hostname.endswith(f".{allowed}")
for allowed in ALLOWED_LORA_HOSTS
@@ -878,20 +931,6 @@ async def install_lora_file(
detail=f"URL must be from {' or '.join(sorted(ALLOWED_LORA_HOSTS))}",
)
- # CivitAI requires a token for programmatic downloads
- is_civitai = hostname == "civitai.com" or hostname.endswith(".civitai.com")
- if is_civitai:
- from urllib.parse import parse_qs
-
- query_params = parse_qs(parsed.query)
- if "token" not in query_params:
- raise HTTPException(
- status_code=400,
- detail="CivitAI requires an API token for programmatic downloads. "
- "Add your token to the URL: &token=YOUR_TOKEN. "
- "Get your API key at https://civitai.com/user/account",
- )
-
# Determine filename from URL if not provided
filename = request.filename
if not filename:
@@ -900,7 +939,7 @@ async def install_lora_file(
if not filename or "." not in filename:
# Use streaming GET instead of HEAD (some servers return 403 for HEAD)
with httpx.Client(follow_redirects=True, timeout=10.0) as client:
- with client.stream("GET", request.url) as response:
+ with client.stream("GET", url) as response:
if response.status_code == 401 or response.status_code == 403:
raise HTTPException(
status_code=response.status_code,
@@ -951,7 +990,7 @@ async def install_lora_file(
# Install in a thread to avoid blocking the event loop
loop = asyncio.get_event_loop()
- await loop.run_in_executor(None, http_get, request.url, dest_path)
+ await loop.run_in_executor(None, http_get, url, dest_path)
size_mb = dest_path.stat().st_size / (1024 * 1024)
file_info = LoRAFileInfo(
@@ -1503,25 +1542,40 @@ async def list_api_keys():
from huggingface_hub import get_token
- token = get_token()
- env_var_set = bool(os.environ.get("HF_TOKEN"))
+ from .models_config import get_civitai_token, get_civitai_token_source
- if token:
- source = "env_var" if env_var_set else "stored"
+ # HuggingFace
+ hf_token = get_token()
+ hf_env_var_set = bool(os.environ.get("HF_TOKEN"))
+ if hf_token:
+ hf_source = "env_var" if hf_env_var_set else "stored"
else:
- source = None
+ hf_source = None
hf_key = ApiKeyInfo(
id="huggingface",
name="HuggingFace",
description="Required for downloading gated models",
- is_set=token is not None,
- source=source,
+ is_set=hf_token is not None,
+ source=hf_source,
env_var="HF_TOKEN",
key_url="https://huggingface.co/settings/tokens",
)
- return ApiKeysListResponse(keys=[hf_key])
+ # CivitAI
+ civitai_token = get_civitai_token()
+
+ civitai_key = ApiKeyInfo(
+ id="civitai",
+ name="CivitAI",
+ description="Required for downloading LoRAs from CivitAI",
+ is_set=civitai_token is not None,
+ source=get_civitai_token_source(),
+ env_var="CIVITAI_API_TOKEN",
+ key_url="https://civitai.com/user/account",
+ )
+
+ return ApiKeysListResponse(keys=[hf_key, civitai_key])
@app.put("/api/v1/keys/{service_id}", response_model=ApiKeySetResponse)
@@ -1529,20 +1583,32 @@ async def set_api_key(service_id: str, request: ApiKeySetRequest):
"""Set/save an API key for a service."""
import os
- if service_id != "huggingface":
- raise HTTPException(status_code=404, detail=f"Unknown service: {service_id}")
+ from .models_config import CIVITAI_TOKEN_ENV_VAR, set_civitai_token
- if os.environ.get("HF_TOKEN"):
- raise HTTPException(
- status_code=409,
- detail="HF_TOKEN environment variable is already set. Remove it to manage this key from the UI.",
- )
+ if service_id == "huggingface":
+ if os.environ.get("HF_TOKEN"):
+ raise HTTPException(
+ status_code=409,
+ detail="HF_TOKEN environment variable is already set. Remove it to manage this key from the UI.",
+ )
+
+ from huggingface_hub import login
- from huggingface_hub import login
+ login(token=request.value, add_to_git_credential=False)
+ return ApiKeySetResponse(success=True, message="HuggingFace token saved")
- login(token=request.value, add_to_git_credential=False)
+ elif service_id == "civitai":
+ if os.environ.get(CIVITAI_TOKEN_ENV_VAR):
+ raise HTTPException(
+ status_code=409,
+ detail="CIVITAI_API_TOKEN environment variable is already set. Remove it to manage this key from the UI.",
+ )
- return ApiKeySetResponse(success=True, message="HuggingFace token saved")
+ set_civitai_token(request.value)
+ return ApiKeySetResponse(success=True, message="CivitAI token saved")
+
+ else:
+ raise HTTPException(status_code=404, detail=f"Unknown service: {service_id}")
@app.delete("/api/v1/keys/{service_id}", response_model=ApiKeyDeleteResponse)
@@ -1550,22 +1616,40 @@ async def delete_api_key(service_id: str):
"""Remove a stored API key for a service."""
import os
- if service_id != "huggingface":
- raise HTTPException(status_code=404, detail=f"Unknown service: {service_id}")
+ from .models_config import (
+ CIVITAI_TOKEN_ENV_VAR,
+ clear_civitai_token,
+ get_civitai_token_source,
+ )
- # Check current source
- env_var_set = bool(os.environ.get("HF_TOKEN"))
- if env_var_set:
- raise HTTPException(
- status_code=409,
- detail="Cannot remove token set via HF_TOKEN environment variable. Unset the environment variable instead.",
- )
+ if service_id == "huggingface":
+ env_var_set = bool(os.environ.get("HF_TOKEN"))
+ if env_var_set:
+ raise HTTPException(
+ status_code=409,
+ detail="Cannot remove token set via HF_TOKEN environment variable. Unset the environment variable instead.",
+ )
- from huggingface_hub import logout
+ from huggingface_hub import logout
- logout()
+ logout()
+ return ApiKeyDeleteResponse(success=True, message="HuggingFace token removed")
- return ApiKeyDeleteResponse(success=True, message="HuggingFace token removed")
+ elif service_id == "civitai":
+ source = get_civitai_token_source()
+ if source == "env_var":
+ raise HTTPException(
+ status_code=409,
+ detail="Cannot remove token set via CIVITAI_API_TOKEN environment variable. Unset the environment variable instead.",
+ )
+ if source != "stored":
+ raise HTTPException(status_code=404, detail="No CivitAI token to remove")
+
+ clear_civitai_token()
+ return ApiKeyDeleteResponse(success=True, message="CivitAI token removed")
+
+ else:
+ raise HTTPException(status_code=404, detail=f"Unknown service: {service_id}")
@app.get("/api/v1/logs/current")
diff --git a/src/scope/server/models_config.py b/src/scope/server/models_config.py
index 6a5299e23..f8c3959b6 100644
--- a/src/scope/server/models_config.py
+++ b/src/scope/server/models_config.py
@@ -32,6 +32,9 @@
# Environment variable for overriding lora directory
LORA_DIR_ENV_VAR = "DAYDREAM_SCOPE_LORA_DIR"
+# Environment variable for CivitAI API token
+CIVITAI_TOKEN_ENV_VAR = "CIVITAI_API_TOKEN"
+
def get_models_dir() -> Path:
"""
@@ -226,3 +229,49 @@ def models_are_downloaded(pipeline_id: str) -> bool:
return False
return True
+
+
+# In-memory storage for CivitAI token (set via API)
+_civitai_token: str | None = None
+
+
+def get_civitai_token() -> str | None:
+ """
+ Get the CivitAI API token.
+
+ Priority:
+ 1. CIVITAI_API_TOKEN environment variable
+ 2. In-memory token (set via API)
+
+ Returns:
+ str | None: The CivitAI API token, or None if not set
+ """
+ return os.environ.get(CIVITAI_TOKEN_ENV_VAR) or _civitai_token
+
+
+def get_civitai_token_source() -> str | None:
+ """
+ Get the source of the CivitAI token.
+
+ Returns:
+ "env_var" if from environment, "stored" if in memory, None if not set
+ """
+ if os.environ.get(CIVITAI_TOKEN_ENV_VAR):
+ return "env_var"
+ if _civitai_token:
+ return "stored"
+ return None
+
+
+def set_civitai_token(token: str) -> None:
+ """Set the CivitAI token in memory."""
+ global _civitai_token
+ _civitai_token = token
+ logger.info("CivitAI token set in memory")
+
+
+def clear_civitai_token() -> None:
+ """Clear the in-memory CivitAI token."""
+ global _civitai_token
+ _civitai_token = None
+ logger.info("CivitAI token cleared from memory")
From 4cb29600d35e37dc3eb4c608c9c7ddcdf50addb8 Mon Sep 17 00:00:00 2001
From: Max Holland
Date: Thu, 19 Feb 2026 15:30:40 +0000
Subject: [PATCH 10/14] save api key to disk
Signed-off-by: Max Holland
---
src/scope/server/app.py | 1 -
src/scope/server/models_config.py | 50 ++++++++++++++++++++++---------
2 files changed, 36 insertions(+), 15 deletions(-)
diff --git a/src/scope/server/app.py b/src/scope/server/app.py
index 5ba2cc677..4531edd33 100644
--- a/src/scope/server/app.py
+++ b/src/scope/server/app.py
@@ -1617,7 +1617,6 @@ async def delete_api_key(service_id: str):
import os
from .models_config import (
- CIVITAI_TOKEN_ENV_VAR,
clear_civitai_token,
get_civitai_token_source,
)
diff --git a/src/scope/server/models_config.py b/src/scope/server/models_config.py
index f8c3959b6..f2b5cdad3 100644
--- a/src/scope/server/models_config.py
+++ b/src/scope/server/models_config.py
@@ -231,8 +231,26 @@ def models_are_downloaded(pipeline_id: str) -> bool:
return True
-# In-memory storage for CivitAI token (set via API)
-_civitai_token: str | None = None
+# CivitAI token file location
+CIVITAI_TOKEN_FILE = "~/.daydream-scope/civitai_token"
+
+
+def _get_civitai_token_file() -> Path:
+ """Get the path to the CivitAI token file."""
+ return Path(CIVITAI_TOKEN_FILE).expanduser().resolve()
+
+
+def _read_civitai_token_file() -> str | None:
+ """Read the CivitAI token from the token file."""
+ token_file = _get_civitai_token_file()
+ if token_file.exists():
+ try:
+ token = token_file.read_text().strip()
+ if token:
+ return token
+ except Exception as e:
+ logger.warning(f"Failed to read CivitAI token file: {e}")
+ return None
def get_civitai_token() -> str | None:
@@ -241,12 +259,12 @@ def get_civitai_token() -> str | None:
Priority:
1. CIVITAI_API_TOKEN environment variable
- 2. In-memory token (set via API)
+ 2. Stored token file (~/.daydream-scope/civitai_token)
Returns:
str | None: The CivitAI API token, or None if not set
"""
- return os.environ.get(CIVITAI_TOKEN_ENV_VAR) or _civitai_token
+ return os.environ.get(CIVITAI_TOKEN_ENV_VAR) or _read_civitai_token_file()
def get_civitai_token_source() -> str | None:
@@ -254,24 +272,28 @@ def get_civitai_token_source() -> str | None:
Get the source of the CivitAI token.
Returns:
- "env_var" if from environment, "stored" if in memory, None if not set
+ "env_var" if from environment, "stored" if from file, None if not set
"""
if os.environ.get(CIVITAI_TOKEN_ENV_VAR):
return "env_var"
- if _civitai_token:
+ if _read_civitai_token_file():
return "stored"
return None
def set_civitai_token(token: str) -> None:
- """Set the CivitAI token in memory."""
- global _civitai_token
- _civitai_token = token
- logger.info("CivitAI token set in memory")
+ """Save the CivitAI token to the token file."""
+ token_file = _get_civitai_token_file()
+ token_file.parent.mkdir(parents=True, exist_ok=True)
+ token_file.write_text(token)
+ # Set restrictive permissions (owner read/write only)
+ token_file.chmod(0o600)
+ logger.info("CivitAI token saved to file")
def clear_civitai_token() -> None:
- """Clear the in-memory CivitAI token."""
- global _civitai_token
- _civitai_token = None
- logger.info("CivitAI token cleared from memory")
+ """Delete the CivitAI token file."""
+ token_file = _get_civitai_token_file()
+ if token_file.exists():
+ token_file.unlink()
+ logger.info("CivitAI token file deleted")
From b665aa2b47a5a11ea3d834c4f2549c083ee6efdc Mon Sep 17 00:00:00 2001
From: Max Holland
Date: Fri, 20 Feb 2026 14:39:47 +0000
Subject: [PATCH 11/14] Add delete function
Signed-off-by: Max Holland
---
frontend/src/components/SettingsDialog.tsx | 26 +++++++++
frontend/src/components/settings/LoRAsTab.tsx | 46 ++++++++++-----
frontend/src/lib/api.ts | 20 +++++++
src/scope/server/app.py | 58 +++++++++++++++++++
4 files changed, 135 insertions(+), 15 deletions(-)
diff --git a/frontend/src/components/SettingsDialog.tsx b/frontend/src/components/SettingsDialog.tsx
index f5a4c7860..facff5537 100644
--- a/frontend/src/components/SettingsDialog.tsx
+++ b/frontend/src/components/SettingsDialog.tsx
@@ -18,6 +18,7 @@ import {
waitForServer,
getServerInfo,
installLoRAFile,
+ deleteLoRAFile,
type FailedPluginInfo,
} from "@/lib/api";
import { toast } from "sonner";
@@ -86,6 +87,7 @@ export function SettingsDialog({
// LoRA install state (files come from context)
const [loraInstallUrl, setLoraInstallUrl] = useState("");
const [isInstallingLoRA, setIsInstallingLoRA] = useState(false);
+ const [deletingLoRAs, setDeletingLoRAs] = useState>(new Set());
const [version, setVersion] = useState("");
const [gitCommit, setGitCommit] = useState("");
// Track install/update/uninstall operations to suppress spurious error toasts
@@ -356,6 +358,28 @@ export function SettingsDialog({
}
};
+ const handleDeleteLoRA = async (name: string) => {
+ setDeletingLoRAs(prev => new Set(prev).add(name));
+ try {
+ const response = await deleteLoRAFile(name);
+ if (response.success) {
+ toast.success(response.message);
+ await refreshLoRAs();
+ }
+ } catch (error) {
+ toast.error(
+ error instanceof Error ? error.message : "Failed to delete LoRA"
+ );
+ console.error("Failed to delete LoRA:", error);
+ } finally {
+ setDeletingLoRAs(prev => {
+ const next = new Set(prev);
+ next.delete(name);
+ return next;
+ });
+ }
+ };
+
return (