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 (

LoRA Adapters

-
- - -
-
- -
- { - setInstallUrl(e.target.value); - setInstallError(null); - }} - placeholder="Paste LoRA URL (HuggingFace, CivitAI...)" - disabled={disabled || isInstalling || isCloudPending} - className="h-7 text-xs flex-1" - onKeyDown={e => { - if (e.key === "Enter") handleInstallLoRA(); - }} - />
- {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 ( !isOpen && onClose()}> @@ -357,6 +391,12 @@ export function SettingsDialog({ > Plugins + + LoRAs +
@@ -395,6 +435,17 @@ export function SettingsDialog({ isInstalling={isInstalling} /> + + +
diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx index 55c8ad17a..bf48a4e7e 100644 --- a/frontend/src/components/SettingsPanel.tsx +++ b/frontend/src/components/SettingsPanel.tsx @@ -194,6 +194,7 @@ interface SettingsPanelProps { value: unknown ) => void; isCloudMode?: boolean; + onOpenLoRAsSettings?: () => void; } export function SettingsPanel({ @@ -247,6 +248,7 @@ export function SettingsPanel({ onPreprocessorSchemaFieldOverrideChange, onPostprocessorSchemaFieldOverrideChange, isCloudMode = false, + onOpenLoRAsSettings, }: SettingsPanelProps) { // Local slider state management hooks const noiseScaleSlider = useLocalSliderValue(noiseScale, onNoiseScaleChange); @@ -641,6 +643,7 @@ export function SettingsPanel({ isCloudMode, schemaFieldOverrides, onSchemaFieldOverrideChange, + onOpenLoRAsSettings, }; return ( <> @@ -790,6 +793,7 @@ export function SettingsPanel({ disabled={isLoading} isStreaming={isStreaming} loraMergeStrategy={loraMergeStrategy} + onOpenLoRAsSettings={onOpenLoRAsSettings} />
)} 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 ( !isOpen && onClose()}> @@ -443,9 +467,11 @@ export function SettingsDialog({ installUrl={loraInstallUrl} onInstallUrlChange={setLoraInstallUrl} onInstall={handleInstallLoRA} + onDelete={handleDeleteLoRA} onRefresh={refreshLoRAs} isLoading={isLoadingLoRAs} isInstalling={isInstallingLoRA} + deletingLoRAs={deletingLoRAs} /> diff --git a/frontend/src/components/settings/LoRAsTab.tsx b/frontend/src/components/settings/LoRAsTab.tsx index d6bcbd005..8fdfc0e39 100644 --- a/frontend/src/components/settings/LoRAsTab.tsx +++ b/frontend/src/components/settings/LoRAsTab.tsx @@ -1,4 +1,4 @@ -import { RefreshCw } from "lucide-react"; +import { RefreshCw, Trash2 } from "lucide-react"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; import type { LoRAFileInfo } from "@/lib/api"; @@ -8,9 +8,11 @@ interface LoRAsTabProps { installUrl: string; onInstallUrlChange: (url: string) => void; onInstall: (url: string) => void; + onDelete: (name: string) => void; onRefresh: () => void; isLoading?: boolean; isInstalling?: boolean; + deletingLoRAs?: Set; } export function LoRAsTab({ @@ -18,9 +20,11 @@ export function LoRAsTab({ installUrl, onInstallUrlChange, onInstall, + onDelete, onRefresh, isLoading = false, isInstalling = false, + deletingLoRAs = new Set(), }: LoRAsTabProps) { const handleInstall = () => { if (installUrl.trim()) { @@ -119,21 +123,33 @@ export function LoRAsTab({ {folder} )} - {groupedLoRAs[folder].map(lora => ( -
-
- - {lora.name} - - - {lora.size_mb.toFixed(1)} MB - + {groupedLoRAs[folder].map(lora => { + const isDeleting = deletingLoRAs.has(lora.name); + return ( +
+
+ + {lora.name} + + + {lora.size_mb.toFixed(1)} MB + +
+
-
- ))} + ); + })}
))} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index eed27dc5d..5bb5dd40f 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -396,6 +396,26 @@ export const installLoRAFile = async ( return result; }; +export interface LoRADeleteResponse { + success: boolean; + message: string; +} + +export const deleteLoRAFile = async ( + name: string +): Promise => { + const response = await fetch(`/api/v1/loras/${encodeURIComponent(name)}`, { + method: "DELETE", + }); + + if (!response.ok) { + const detail = await extractErrorDetail(response); + throw new Error(detail); + } + + return response.json(); +}; + export interface AssetFileInfo { name: string; path: string; diff --git a/src/scope/server/app.py b/src/scope/server/app.py index 4531edd33..291f0dee8 100644 --- a/src/scope/server/app.py +++ b/src/scope/server/app.py @@ -1012,6 +1012,64 @@ async def install_lora_file( raise HTTPException(status_code=500, detail=str(e)) from e +class LoRADeleteResponse(BaseModel): + success: bool + message: str + + +@app.delete("/api/v1/loras/{name}") +@cloud_proxy() +async def delete_lora_file( + name: str, + http_request: Request, + cloud_manager: "CloudConnectionManager" = Depends(get_cloud_connection_manager), +): + """Delete a LoRA file by name.""" + try: + lora_dir = get_lora_dir() + + # Search for the file with any supported extension + found_path = None + for ext in LORA_EXTENSIONS: + candidate = lora_dir / f"{name}{ext}" + if candidate.exists(): + found_path = candidate + break + + # Also check subdirectories + if not found_path: + for subdir in lora_dir.iterdir(): + if subdir.is_dir(): + for ext in LORA_EXTENSIONS: + candidate = subdir / f"{name}{ext}" + if candidate.exists(): + found_path = candidate + break + if found_path: + break + + if not found_path: + raise HTTPException( + status_code=404, + detail=f"LoRA file '{name}' not found", + ) + + # Delete the file + found_path.unlink() + logger.info(f"Deleted LoRA file: {found_path}") + + return LoRADeleteResponse( + success=True, + message=f"Successfully deleted '{name}'", + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"delete_lora_file: Error deleting LoRA: {e}") + raise HTTPException(status_code=500, detail=str(e)) from e + + @app.get("/api/v1/assets", response_model=AssetsResponse) @cloud_proxy() async def list_assets( From ea22d018499a6e1a9d8ccdcf423bb413754608ee Mon Sep 17 00:00:00 2001 From: Max Holland Date: Fri, 20 Feb 2026 16:33:42 +0000 Subject: [PATCH 12/14] add info tooltip Signed-off-by: Max Holland --- frontend/src/components/settings/ApiKeysTab.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/settings/ApiKeysTab.tsx b/frontend/src/components/settings/ApiKeysTab.tsx index f356654b8..f05836f8c 100644 --- a/frontend/src/components/settings/ApiKeysTab.tsx +++ b/frontend/src/components/settings/ApiKeysTab.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useCallback } from "react"; -import { ExternalLink, Save, Trash2 } from "lucide-react"; +import { ExternalLink, Info, Save, Trash2 } from "lucide-react"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; import { toast } from "sonner"; @@ -132,6 +132,14 @@ export function ApiKeysTab({ isActive }: ApiKeysTabProps) { )} + {keyInfo.id === "civitai" && ( + + + + )} {isEnvVar ? ( Date: Mon, 23 Feb 2026 12:16:07 +0000 Subject: [PATCH 13/14] Only safetensors allowed for security Signed-off-by: Max Holland --- src/scope/server/file_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/scope/server/file_utils.py b/src/scope/server/file_utils.py index 2d649e7ea..a4abd1c76 100644 --- a/src/scope/server/file_utils.py +++ b/src/scope/server/file_utils.py @@ -9,7 +9,7 @@ IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".bmp"} VIDEO_EXTENSIONS = {".mp4", ".avi", ".mov", ".mkv", ".webm"} -LORA_EXTENSIONS = {".safetensors", ".bin", ".pt"} +LORA_EXTENSIONS = {".safetensors"} def iter_files( From 58bc025ca7e549f5ce961f37710e09820c606083 Mon Sep 17 00:00:00 2001 From: Max Holland Date: Mon, 23 Feb 2026 13:34:57 +0000 Subject: [PATCH 14/14] lint Signed-off-by: Max Holland --- frontend/src/components/Header.tsx | 4 +++- frontend/src/components/SettingsDialog.tsx | 9 +-------- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 2fa334597..5cb1076d6 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -87,7 +87,9 @@ export function Header({ if (openSettingsTab === "plugins") { setPluginsOpen(true); } else { - setInitialTab(openSettingsTab as "general" | "account" | "api-keys" | "loras"); + setInitialTab( + openSettingsTab as "general" | "account" | "api-keys" | "loras" + ); setSettingsOpen(true); } onSettingsTabOpened?.(); diff --git a/frontend/src/components/SettingsDialog.tsx b/frontend/src/components/SettingsDialog.tsx index 08860d5eb..6c6d822f9 100644 --- a/frontend/src/components/SettingsDialog.tsx +++ b/frontend/src/components/SettingsDialog.tsx @@ -8,11 +8,7 @@ import { ReportBugDialog } from "./ReportBugDialog"; import { usePipelinesContext } from "@/contexts/PipelinesContext"; import { useLoRAsContext } from "@/contexts/LoRAsContext"; import { LoRAsTab } from "./settings/LoRAsTab"; -import { - installLoRAFile, - deleteLoRAFile, - getServerInfo, -} from "@/lib/api"; +import { installLoRAFile, deleteLoRAFile, getServerInfo } from "@/lib/api"; import { toast } from "sonner"; interface SettingsDialogProps { @@ -66,7 +62,6 @@ export function SettingsDialog({ } }, [open]); - // Refresh LoRAs when switching to LoRAs tab useEffect(() => { if (open && activeTab === "loras") { @@ -84,8 +79,6 @@ export function SettingsDialog({ setLogsDirectory(value); }; - - const handleInstallLoRA = async (url: string) => { setIsInstallingLoRA(true); const filename = url.split("/").pop()?.split("?")[0] || "LoRA file";