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({
{loras.length === 0 && (
- No LoRA adapters configured. Follow the{" "}
+ No LoRA adapters configured.{" "}
+ {onOpenLoRAsSettings ? (
+ <>
+
+ Click here
+ {" "}
+ 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();
+ }}
+ />
+
+ {isInstalling ? "Installing..." : "Install"}
+
+
+
+
+ {/* 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
+
+
+
onDelete(lora.name)}
+ variant="ghost"
+ size="icon"
+ disabled={isDeleting}
+ className="text-destructive hover:text-destructive hover:bg-destructive/10"
+ >
+
+
+
+ );
+ })}
+
+ ))}
+
+ )}
+
+
+ );
+}
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")