diff --git a/docs/lora.md b/docs/lora.md index 58c3276e3..ec8bc4997 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,42 @@ 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. + +#### 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. + +#### 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 +76,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/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/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 77e162d32..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 { @@ -210,7 +211,7 @@ export function SchemaComplexField({ ); } - if (component === "lora" && !rendered.has("lora") && !ctx.isCloudMode) { + if (component === "lora" && !rendered.has("lora")) { rendered.add("lora"); return (
@@ -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 580e77b99..5cb1076d6 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -25,7 +25,7 @@ export function Header({ const [settingsOpen, setSettingsOpen] = useState(false); const [pluginsOpen, setPluginsOpen] = useState(false); const [initialTab, setInitialTab] = useState< - "general" | "account" | "api-keys" + "general" | "account" | "api-keys" | "loras" >("general"); const [initialPluginPath, setInitialPluginPath] = useState(""); @@ -87,7 +87,9 @@ export function Header({ if (openSettingsTab === "plugins") { setPluginsOpen(true); } else { - setInitialTab(openSettingsTab as "general" | "account" | "api-keys"); + setInitialTab( + openSettingsTab as "general" | "account" | "api-keys" | "loras" + ); setSettingsOpen(true); } onSettingsTabOpened?.(); diff --git a/frontend/src/components/LoRAManager.tsx b/frontend/src/components/LoRAManager.tsx index 581d5ab18..5fd4dc05b 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 { @@ -8,11 +8,12 @@ import { SelectTrigger, SelectValue, } from "./ui/select"; -import { Plus, X, RefreshCw } 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 { listLoRAFiles, type LoRAFileInfo } from "../lib/api"; +import { useLoRAsContext } from "../contexts/LoRAsContext"; +import { useCloudStatus } from "../hooks/useCloudStatus"; import { FilePicker } from "./ui/file-picker"; interface LoRAManagerProps { @@ -21,6 +22,7 @@ interface LoRAManagerProps { disabled?: boolean; isStreaming?: boolean; loraMergeStrategy?: LoraMergeStrategy; + onOpenLoRAsSettings?: () => void; } export function LoRAManager({ @@ -29,27 +31,12 @@ export function LoRAManager({ disabled, isStreaming = false, loraMergeStrategy = "permanent_merge", + onOpenLoRAsSettings, }: LoRAManagerProps) { - const [availableLoRAs, setAvailableLoRAs] = useState([]); - const [isLoadingLoRAs, setIsLoadingLoRAs] = useState(false); + const { loraFiles: availableLoRAs } = useLoRAsContext(); + const { isConnected: isCloudConnected } = useCloudStatus(); const [localScales, setLocalScales] = useState>({}); - const loadAvailableLoRAs = async () => { - setIsLoadingLoRAs(true); - try { - const response = await listLoRAFiles(); - setAvailableLoRAs(response.lora_files); - } catch (error) { - console.error("loadAvailableLoRAs: Failed to load LoRA files:", error); - } finally { - setIsLoadingLoRAs(false); - } - }; - - useEffect(() => { - loadAvailableLoRAs(); - }, []); - // Sync localScales from loras prop when it changes from outside useEffect(() => { const newLocalScales: Record = {}; @@ -59,6 +46,24 @@ export function LoRAManager({ setLocalScales(newLocalScales); }, [loras]); + // 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(() => { + if (prevCloudConnectedRef.current === null) { + prevCloudConnectedRef.current = isCloudConnected; + return; + } + + // Clear configured LoRAs when cloud connection state changes + if (prevCloudConnectedRef.current !== isCloudConnected) { + onLorasChange([]); + } + + prevCloudConnectedRef.current = isCloudConnected; + }, [isCloudConnected, onLorasChange]); + const handleAddLora = () => { const newLora: LoRAConfig = { id: crypto.randomUUID(), @@ -103,46 +108,44 @@ export function LoRAManager({

LoRA Adapters

-
- - -
+
{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 053e2e6e1..6c6d822f9 100644 --- a/frontend/src/components/SettingsDialog.tsx +++ b/frontend/src/components/SettingsDialog.tsx @@ -6,12 +6,15 @@ import { ApiKeysTab } from "./settings/ApiKeysTab"; import { GeneralTab } from "./settings/GeneralTab"; import { ReportBugDialog } from "./ReportBugDialog"; import { usePipelinesContext } from "@/contexts/PipelinesContext"; -import { getServerInfo } from "@/lib/api"; +import { useLoRAsContext } from "@/contexts/LoRAsContext"; +import { LoRAsTab } from "./settings/LoRAsTab"; +import { installLoRAFile, deleteLoRAFile, getServerInfo } from "@/lib/api"; +import { toast } from "sonner"; interface SettingsDialogProps { open: boolean; onClose: () => void; - initialTab?: "general" | "account" | "api-keys"; + initialTab?: "general" | "account" | "api-keys" | "loras"; onPipelinesRefresh?: () => Promise; cloudDisabled?: boolean; } @@ -24,12 +27,21 @@ export function SettingsDialog({ cloudDisabled, }: SettingsDialogProps) { const { refetch: refetchPipelines } = usePipelinesContext(); + const { + loraFiles, + isLoading: isLoadingLoRAs, + refresh: refreshLoRAs, + } = useLoRAsContext(); const [modelsDirectory, setModelsDirectory] = useState( "~/.daydream-scope/models" ); const [logsDirectory, setLogsDirectory] = useState("~/.daydream-scope/logs"); const [reportBugOpen, setReportBugOpen] = useState(false); const [activeTab, setActiveTab] = useState(initialTab); + // 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(""); @@ -50,6 +62,13 @@ export function SettingsDialog({ } }, [open]); + // 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); @@ -60,6 +79,46 @@ export function SettingsDialog({ setLogsDirectory(value); }; + const handleInstallLoRA = async (url: string) => { + setIsInstallingLoRA(true); + const filename = url.split("/").pop()?.split("?")[0] || "LoRA file"; + const toastId = toast.loading(`Installing ${filename}...`); + try { + 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); + } + }; + + 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()}> @@ -88,6 +147,12 @@ export function SettingsDialog({ > API Keys + + LoRAs +
@@ -111,6 +176,19 @@ export function SettingsDialog({ + + +
diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx index 56e46adaa..cf85e1fa5 100644 --- a/frontend/src/components/SettingsPanel.tsx +++ b/frontend/src/components/SettingsPanel.tsx @@ -359,6 +359,7 @@ interface SettingsPanelProps { isRuntimeParam?: boolean ) => void; isCloudMode?: boolean; + onOpenLoRAsSettings?: () => void; } export function SettingsPanel({ @@ -410,6 +411,7 @@ export function SettingsPanel({ onPreprocessorSchemaFieldOverrideChange, onPostprocessorSchemaFieldOverrideChange, isCloudMode = false, + onOpenLoRAsSettings, }: SettingsPanelProps) { // Local slider state management hooks const noiseScaleSlider = useLocalSliderValue(noiseScale, onNoiseScaleChange); @@ -746,6 +748,7 @@ export function SettingsPanel({ isCloudMode, schemaFieldOverrides, onSchemaFieldOverrideChange, + onOpenLoRAsSettings, }; return ( <> @@ -887,7 +890,7 @@ export function SettingsPanel({
)} - {currentPipeline?.supportsLoRA && !isCloudMode && ( + {currentPipeline?.supportsLoRA && (
)} 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 ? ( void; + onInstall: (url: string) => void; + onDelete: (name: string) => void; + onRefresh: () => void; + isLoading?: boolean; + isInstalling?: boolean; + deletingLoRAs?: Set; +} + +export function LoRAsTab({ + loraFiles, + installUrl, + onInstallUrlChange, + onInstall, + onDelete, + onRefresh, + isLoading = false, + isInstalling = false, + deletingLoRAs = new Set(), +}: 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 => { + const isDeleting = deletingLoRAs.has(lora.name); + return ( +
+
+ + {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/useApi.ts b/frontend/src/hooks/useApi.ts index 8918c2fc6..4614e1520 100644 --- a/frontend/src/hooks/useApi.ts +++ b/frontend/src/hooks/useApi.ts @@ -12,6 +12,8 @@ import type { PipelineSchemasResponse, HardwareInfoResponse, LoRAFilesResponse, + LoRAInstallRequest, + LoRAInstallResponse, AssetsResponse, AssetFileInfo, WebRTCOfferRequest, @@ -93,6 +95,16 @@ export function useApi() { return api.listLoRAFiles(); }, [adapter, isCloudMode]); + const installLoRAFile = useCallback( + async (data: LoRAInstallRequest): Promise => { + if (isCloudMode && adapter) { + return adapter.api.installLoRAFile(data); + } + return api.installLoRAFile(data); + }, + [adapter, isCloudMode] + ); + // Asset APIs const listAssets = useCallback( async (type?: "image" | "video"): Promise => { @@ -192,6 +204,7 @@ export function useApi() { // LoRA listLoRAFiles, + installLoRAFile, // Assets listAssets, 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/lib/api.ts b/frontend/src/lib/api.ts index 0d1251b72..5bb5dd40f 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", }); @@ -366,6 +366,56 @@ export const listLoRAFiles = async (): Promise => { return result; }; +export interface LoRAInstallRequest { + url: string; + filename?: string; +} + +export interface LoRAInstallResponse { + message: string; + file: LoRAFileInfo; +} + +export const installLoRAFile = async ( + data: LoRAInstallRequest +): Promise => { + const response = await fetch("/api/v1/loras", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error( + `Install LoRA failed: ${response.status} ${response.statusText}: ${errorText}` + ); + } + + const result = await response.json(); + 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/frontend/src/lib/cloudAdapter.ts b/frontend/src/lib/cloudAdapter.ts index 09a83dcf3..d979ebc44 100644 --- a/frontend/src/lib/cloudAdapter.ts +++ b/frontend/src/lib/cloudAdapter.ts @@ -31,6 +31,8 @@ import type { PipelineSchemasResponse, HardwareInfoResponse, LoRAFilesResponse, + LoRAInstallRequest, + LoRAInstallResponse, 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( @@ -393,7 +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/loras", data, 300000), listAssets: (type?: "image" | "video"): Promise => this.apiRequest( diff --git a/frontend/src/pages/StreamPage.tsx b/frontend/src/pages/StreamPage.tsx index 85230f5d6..efcf4adbc 100644 --- a/frontend/src/pages/StreamPage.tsx +++ b/frontend/src/pages/StreamPage.tsx @@ -1763,6 +1763,7 @@ export function StreamPage() { } }} isCloudMode={isCloudMode} + onOpenLoRAsSettings={() => setOpenSettingsTab("loras")} />
diff --git a/src/scope/cloud/fal_app.py b/src/scope/cloud/fal_app.py index 959104c7f..d8c6ef9c6 100644 --- a/src/scope/cloud/fal_app.py +++ b/src/scope/cloud/fal_app.py @@ -312,6 +312,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]...") @@ -648,8 +649,12 @@ async def handle_api_request(payload: dict): timeout=60.0, # Longer timeout for uploads ) else: + # Use longer timeout for LoRA installs + post_timeout = 300.0 if path == "/api/v1/loras" 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 ac8080f58..291f0dee8 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 @@ -792,9 +792,16 @@ class LoRAFilesResponse(BaseModel): lora_files: list[LoRAFileInfo] -@app.get("/api/v1/lora/list", response_model=LoRAFilesResponse) -async def list_lora_files(): - """List available LoRA files in the models/lora directory and its subdirectories.""" +@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. + + 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.""" @@ -811,7 +818,7 @@ def process_lora_file(file_path: Path, lora_dir: Path) -> LoRAFileInfo: ) try: - 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): @@ -825,6 +832,244 @@ def process_lora_file(file_path: Path, lora_dir: Path) -> LoRAFileInfo: raise HTTPException(status_code=500, detail=str(e)) from e +class LoRAInstallRequest(BaseModel): + url: str + filename: str | None = None + + +class LoRAInstallResponse(BaseModel): + message: str + file: LoRAFileInfo + + +ALLOWED_LORA_HOSTS = {"civitai.com", "huggingface.co"} + + +@app.post("/api/v1/loras", response_model=LoRAInstallResponse) +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). + + 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 + + import httpx + + from .download_models import http_get + + try: + # Re-parse URL (may have been modified with token) + parsed = urlparse(url) + hostname = parsed.hostname or "" + + # Validate hostname is from allowed sources + 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))}", + ) + + # Determine filename from URL if not provided + filename = request.filename + if not filename: + 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", 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 or "." not in 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_lora_dir() + dest_path = lora_dir / filename + + if dest_path.exists(): + raise HTTPException( + status_code=409, + detail=f"File '{filename}' already exists.", + ) + + # Install in a thread to avoid blocking the event loop + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, http_get, 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 LoRAInstallResponse( + message=f"Successfully installed '{filename}'", + file=file_info, + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"install_lora_file: Error installing LoRA: {e}") + 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( @@ -1355,25 +1600,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) @@ -1381,20 +1641,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) + login(token=request.value, add_to_git_credential=False) + return ApiKeySetResponse(success=True, message="HuggingFace token saved") - return ApiKeySetResponse(success=True, message="HuggingFace token saved") + 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.", + ) + + 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) @@ -1402,22 +1674,39 @@ 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 ( + 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/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( diff --git a/src/scope/server/models_config.py b/src/scope/server/models_config.py index b4386ba56..f2b5cdad3 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,12 @@ # 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" + +# Environment variable for CivitAI API token +CIVITAI_TOKEN_ENV_VAR = "CIVITAI_API_TOKEN" + def get_models_dir() -> Path: """ @@ -51,7 +61,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 +69,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 +113,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. @@ -185,3 +229,71 @@ def models_are_downloaded(pipeline_id: str) -> bool: return False return True + + +# 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: + """ + Get the CivitAI API token. + + Priority: + 1. CIVITAI_API_TOKEN environment variable + 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 _read_civitai_token_file() + + +def get_civitai_token_source() -> str | None: + """ + Get the source of the CivitAI token. + + Returns: + "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 _read_civitai_token_file(): + return "stored" + return None + + +def set_civitai_token(token: str) -> None: + """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: + """Delete the CivitAI token file.""" + token_file = _get_civitai_token_file() + if token_file.exists(): + token_file.unlink() + logger.info("CivitAI token file deleted")