Skip to content

Commit e389d44

Browse files
committed
trainer: settings fix, lorsa download & default graph
1 parent 5f16e69 commit e389d44

9 files changed

Lines changed: 204 additions & 23 deletions

File tree

.gitignore

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,8 @@ docs/
1515
.claude
1616

1717
nohup.out
18-
Inline-Studio.code-workspace
18+
Inline-Studio.code-workspace
19+
20+
# Local generation sweeps (LoRA regression renders, contact sheets)
21+
outputs/
22+
core/scripts/skin*

core/src/inline_core/server/app.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,30 @@ async def media(media_path: str, request: Request) -> Response:
368368
return Response("Not found", status_code=404)
369369
return FileResponse(target) # Range-aware; Content-Type guessed from the extension
370370

371+
@app.get("/download/lora/{run_id}")
372+
async def download_lora(run_id: str) -> Response:
373+
# Stream a finished run's LoRA as an attachment. The browser has no filesystem, so
374+
# "copy the path" is useless there - a download is the only way to get the file out.
375+
from ..config import models_dir
376+
from ..studio import training_store as ts
377+
378+
try:
379+
run = ts.get_run(studio_store.conn(), run_id)
380+
except Exception: # noqa: BLE001 - an unknown run id is a 404, not a 500
381+
return Response("Not found", status_code=404)
382+
rel = (run.get("outputLoraPath") or "").lstrip("/") if run else ""
383+
if not rel:
384+
return Response("This run has no LoRA file yet", status_code=404)
385+
root = (models_dir() / "loras").resolve()
386+
target = (models_dir() / rel).resolve()
387+
if root != target.parent: # only files directly under loras/, no traversal
388+
return Response("Forbidden", status_code=403)
389+
if not target.is_file():
390+
return Response("Not found", status_code=404)
391+
return FileResponse(
392+
target, filename=target.name, media_type="application/octet-stream"
393+
)
394+
371395
@app.post("/upload")
372396
async def upload(request: Request) -> Response:
373397
from ..studio import assets as ax

core/tests/test_lora_download.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
"""The Trainer's LoRA download route: the browser has no filesystem, so a finished run's
2+
.safetensors is fetched over GET /download/lora/{run_id} rather than by copying a path."""
3+
4+
from __future__ import annotations
5+
6+
import sqlite3
7+
8+
import pytest
9+
from fastapi.testclient import TestClient
10+
11+
from inline_core.graph.registry import build_default_registry
12+
from inline_core.server.app import create_app
13+
from inline_core.studio import training_store as ts
14+
from inline_core.studio.store import StudioStore
15+
16+
17+
@pytest.fixture
18+
def client(tmp_path, monkeypatch):
19+
# The route resolves the file under config.models_dir(); point that at the test models root so
20+
# it agrees with where a run's LoRA would actually be written.
21+
models = tmp_path / "models"
22+
(models / "loras").mkdir(parents=True)
23+
monkeypatch.setenv("INLINE_MODELS_DIR", str(models))
24+
25+
store = StudioStore(tmp_path / "appdata", tmp_path / "workspace")
26+
app = create_app(
27+
registry=build_default_registry(),
28+
studio_store=store,
29+
asset_dir=str(tmp_path / "assets"),
30+
models_root=str(models),
31+
takes_dir=str(tmp_path / "takes"),
32+
)
33+
with TestClient(app) as c:
34+
yield c, store, models
35+
36+
37+
def _finished_run(store: StudioStore, rel: str) -> str:
38+
# The project connection lives on the server thread (opened by project:create), so write the
39+
# run row over our own connection to the same project.db to avoid SQLite's thread affinity.
40+
conn = sqlite3.connect(str(store.folder() / "project.db"), isolation_level=None)
41+
conn.row_factory = sqlite3.Row
42+
dataset = ts.create_dataset(conn, "chars", "sks")
43+
run = ts.create_run(conn, dataset["id"], "my-run", {"baseMode": "raw"})
44+
ts.update_run(conn, run["id"], {"status": "done", "outputLoraPath": rel})
45+
conn.close()
46+
return run["id"]
47+
48+
49+
def test_download_streams_the_lora_as_an_attachment(client) -> None:
50+
c, store, models = client
51+
args = [{"name": "F", "parentDir": None}]
52+
assert c.post("/rpc", json={"channel": "project:create", "args": args}).json()["ok"] is True
53+
54+
(models / "loras" / "my-run.safetensors").write_bytes(b"LORA-BYTES")
55+
run_id = _finished_run(store, "loras/my-run.safetensors")
56+
57+
res = c.get(f"/download/lora/{run_id}")
58+
assert res.status_code == 200
59+
assert res.content == b"LORA-BYTES"
60+
assert "attachment" in res.headers.get("content-disposition", "")
61+
assert "my-run.safetensors" in res.headers.get("content-disposition", "")
62+
63+
64+
def test_unknown_run_is_404(client) -> None:
65+
c, _store, _models = client
66+
c.post("/rpc", json={"channel": "project:create", "args": [{"name": "F", "parentDir": None}]})
67+
assert c.get("/download/lora/does-not-exist").status_code == 404
68+
69+
70+
def test_path_traversal_is_refused(client) -> None:
71+
c, store, _models = client
72+
c.post("/rpc", json={"channel": "project:create", "args": [{"name": "F", "parentDir": None}]})
73+
# A run whose stored path tries to escape loras/ must not serve an arbitrary file.
74+
run_id = _finished_run(store, "loras/../../secret.txt")
75+
assert c.get(f"/download/lora/{run_id}").status_code in (403, 404)

src/renderer/store/trainerBoardStore.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import type { MoodboardConnector, MoodboardItem } from '@shared/types'
1111
import type { MoodboardItemPatch } from '@shared/ipc'
1212
import { studio } from '@/lib/studio'
1313
import { ipcErrorMessage } from '../lib/ipcError'
14+
import { DATASET_HANDLE, RUN_HANDLE } from '../views/Trainer/nodes/handles'
1415

1516
const SURFACE = 'trainer' as const
1617

@@ -22,7 +23,11 @@ interface TrainerBoardState {
2223
connectors: MoodboardConnector[]
2324
loading: boolean
2425
error: string | null
26+
/** Guards the one-time default-graph seed so a re-load (or StrictMode double-mount) can't re-seed. */
27+
seeded: boolean
2528
load: () => Promise<void>
29+
/** Create the default Load Dataset -> Train LoRA -> Graph pipeline, wired up. */
30+
seedDefaultGraph: () => Promise<void>
2631
addNode: (kind: TrainerNodeKind, x: number, y: number) => Promise<MoodboardItem | null>
2732
updateItem: (id: string, patch: MoodboardItemPatch) => Promise<void>
2833
/** Merge into an item's `data` (dataset/run/hyperparam selections live there). */
@@ -62,18 +67,35 @@ export const useTrainerBoardStore = create<TrainerBoardState>((set, get) => ({
6267
connectors: [],
6368
loading: false,
6469
error: null,
70+
seeded: false,
6571

6672
load: async () => {
6773
set({ loading: true })
6874
try {
6975
const res = await studio().moodboard.list(SURFACE)
7076
if (!res.ok) return set({ error: res.error, loading: false })
7177
set({ items: res.value.items, connectors: res.value.connectors, loading: false })
78+
// A new/empty project gets a ready-to-use pipeline: Load Dataset -> Train LoRA -> Graph.
79+
// Set the guard before awaiting so a concurrent load can't seed a second time.
80+
if (!get().seeded && res.value.items.length === 0 && res.value.connectors.length === 0) {
81+
set({ seeded: true })
82+
await get().seedDefaultGraph()
83+
}
7284
} catch (e) {
7385
set({ error: ipcErrorMessage(e), loading: false })
7486
}
7587
},
7688

89+
seedDefaultGraph: async () => {
90+
const dataset = await addFor('trainDataset', 60, 150)
91+
const trainer = await addFor('trainer', 440, 150)
92+
const graph = await addFor('lossGraph', 820, 150)
93+
if (!dataset.ok || !trainer.ok || !graph.ok) return
94+
set((s) => ({ items: [...s.items, dataset.value, trainer.value, graph.value] }))
95+
await get().connect(dataset.value.id, trainer.value.id, DATASET_HANDLE, DATASET_HANDLE)
96+
await get().connect(trainer.value.id, graph.value.id, RUN_HANDLE, RUN_HANDLE)
97+
},
98+
7799
addNode: async (kind, x, y) => {
78100
try {
79101
const res = await addFor(kind, x, y)

src/renderer/views/Trainer/OutputsPanel.tsx

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,8 @@
66
* the LoRA loader node's dropdown already covers that.)
77
*/
88
import type { TrainingRun } from '@shared/types'
9-
import { studio } from '@/lib/studio'
109
import { useTrainingStore } from '../../store/trainingStore'
11-
import { PlayIcon } from '../../components/icons'
10+
import { DownloadIcon, PlayIcon } from '../../components/icons'
1211

1312
function when(ms: number): string {
1413
const d = new Date(ms)
@@ -31,12 +30,13 @@ function DoneRow({ run }: { run: TrainingRun }): React.JSX.Element {
3130
rank {hp.rank} · {run.totalSteps} steps · {hp.resolution}px · {when(run.updatedAt)}
3231
</span>
3332
<div className="flex gap-2 pt-0.5">
34-
<button
35-
onClick={() => void studio().clipboard.writeText(run.outputLoraPath ?? '')}
36-
className="rounded border border-border px-1.5 py-0.5 text-[10px] text-zinc-300 hover:bg-panel"
33+
<a
34+
href={`${window.location.origin}/download/lora/${run.id}`}
35+
download={loraName(run)}
36+
className="flex items-center gap-1 rounded border border-border px-1.5 py-0.5 text-[10px] text-emerald-300 hover:bg-panel"
3737
>
38-
Copy path
39-
</button>
38+
<DownloadIcon className="h-3 w-3" /> Download .safetensors
39+
</a>
4040
</div>
4141
</div>
4242
)

src/renderer/views/Trainer/TrainerCanvas.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,11 @@ import {
1919
type OnConnect,
2020
} from '@xyflow/react'
2121
import type { MoodboardItem } from '@shared/types'
22+
import { studio } from '@/lib/studio'
2223
import { useTrainerBoardStore, type TrainerNodeKind } from '../../store/trainerBoardStore'
24+
import { useModelRequirementsStore } from '../../store/modelRequirementsStore'
2325
import { BoardActionsContext } from '../Moodboard/nodes/boardActions'
26+
import { ModelRequirementsModal } from '../Moodboard/nodes/ModelRequirementsModal'
2427
import { ResourceNode } from '../Moodboard/nodes/ResourceNode'
2528
import {
2629
CaptionGlyph,
@@ -129,6 +132,18 @@ function Canvas(): React.JSX.Element {
129132
void load()
130133
}, [load])
131134

135+
// Model-download progress for the Trainer node's "missing base model" popup. Wired here (not only
136+
// in MoodboardPanel) because that panel is unmounted while the Trainer tab is showing.
137+
useEffect(() => {
138+
const req = useModelRequirementsStore.getState()
139+
const unsubs = [
140+
studio().events.onModelDownloadProgress((e) => req.onProgress(e)),
141+
studio().events.onModelDownloadDone((e) => req.onDone(e)),
142+
studio().events.onModelDownloadError((e) => req.onError(e)),
143+
]
144+
return () => unsubs.forEach((u) => u())
145+
}, [])
146+
132147
/** Drop a new node into the visible area, laid out on a grid so repeated adds never land on top
133148
* of each other (nodes are ~300px wide, so the step has to clear a whole card). */
134149
const addAtViewport = (kind: TrainerNodeKind): void => {
@@ -210,6 +225,7 @@ function Canvas(): React.JSX.Element {
210225
<Background variant={BackgroundVariant.Dots} gap={16} size={1} className="opacity-40" />
211226
</ReactFlow>
212227
</BoardActionsContext.Provider>
228+
<ModelRequirementsModal />
213229
</div>
214230
)
215231
}

src/renderer/views/Trainer/nodes/TrainerNode.tsx

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,27 @@ import { Handle, Position, type NodeProps } from '@xyflow/react'
1111
import type { TrainingRun } from '@shared/types'
1212
import { useTrainingStore } from '../../../store/trainingStore'
1313
import { useTrainerBoardStore } from '../../../store/trainerBoardStore'
14+
import { useCoreNodesStore } from '../../../store/coreNodesStore'
15+
import { activeDownload, useModelRequirementsStore } from '../../../store/modelRequirementsStore'
1416
import { NodeFrame } from '../../Moodboard/nodes/NodeFrame'
15-
import { AdjustIcon, NodeBadge, NodeBadgeRow, WandIcon } from '../../Moodboard/nodes/NodeBadge'
17+
import {
18+
AdjustIcon,
19+
AlertIcon,
20+
NodeBadge,
21+
NodeBadgeRow,
22+
WandIcon,
23+
} from '../../Moodboard/nodes/NodeBadge'
1624
import { NodeRunToolbar } from '../../Moodboard/nodes/NodeRunToolbar'
1725
import { DATASET_HANDLE, RUN_HANDLE, wiredDatasetId } from './handles'
1826

27+
/** The training arch + base maps to the generation node type whose weights it trains on, so the
28+
* Trainer node can reuse that node's requirements check + download flow. */
29+
function requirementType(arch: string, baseMode: string): string {
30+
if (arch === 'krea2')
31+
return baseMode === 'turbo_adapter' ? 'krea/krea-2-turbo' : 'krea/krea-2-raw'
32+
return 'alibaba/z-image-turbo'
33+
}
34+
1935
const DEFAULT_HP = {
2036
baseMode: 'deturbo' as const,
2137
rank: 16,
@@ -81,6 +97,22 @@ export function TrainerNode({ id, selected }: NodeProps): React.JSX.Element {
8197
)
8298
const control = controlFor(run)
8399
const hp = { ...DEFAULT_HP, ...(item?.data.hyperparams ?? {}) }
100+
101+
// Same "missing models" hint the Generate / Core nodes show: resolve the base this run needs to
102+
// its generation node type, check its requirements, and blink a chip that opens the download popup.
103+
const arch = (item?.data.hyperparams as { arch?: string } | undefined)?.arch ?? 'z-image'
104+
const reqType = requirementType(arch, hp.baseMode)
105+
const registryVersion = useCoreNodesStore((s) => s.registryVersion)
106+
const loadReqs = useModelRequirementsStore((s) => s.load)
107+
const openReqs = useModelRequirementsStore((s) => s.open)
108+
const reqs = useModelRequirementsStore((s) => s.byType[reqType])
109+
const downloadsForType = useModelRequirementsStore((s) => s.downloads[reqType])
110+
useEffect(() => {
111+
void loadReqs(reqType)
112+
}, [reqType, registryVersion, loadReqs])
113+
const modelsMissing = reqs ? !reqs.allPresent : false
114+
const download = downloadsForType ? activeDownload(downloadsForType, reqs) : null
115+
84116
const fraction = progress?.fraction ?? run?.progressFraction ?? 0
85117
const step = progress?.step ?? run?.step ?? 0
86118
const totalSteps = progress?.totalSteps || run?.totalSteps || hp.steps
@@ -118,6 +150,20 @@ export function TrainerNode({ id, selected }: NodeProps): React.JSX.Element {
118150
<NodeBadge tone="info" accent={busy ? 'text-emerald-400' : undefined}>
119151
rank {hp.rank}
120152
</NodeBadge>
153+
{(modelsMissing || download) && (
154+
<button
155+
onClick={() => openReqs(reqType)}
156+
title={download ? 'Downloading base model…' : 'Base model missing - click to download'}
157+
className={`nodrag flex h-6 items-center gap-1 rounded-full border px-2 text-[10px] font-medium shadow-sm backdrop-blur ${
158+
download
159+
? 'border-emerald-500/40 bg-emerald-500/10 text-emerald-300'
160+
: 'animate-pulse border-amber-500/40 bg-amber-500/10 text-amber-300 hover:animate-none hover:bg-amber-500/20'
161+
}`}
162+
>
163+
<AlertIcon className="h-3.5 w-3.5" />
164+
{download ? `${Math.round(download.fraction * 100)}%` : 'Base model'}
165+
</button>
166+
)}
121167
</NodeBadgeRow>
122168
<NodeFrame id={id} selected={!!selected} padded={false} subtleSelect minWidth={260}>
123169
<div className="flex h-full flex-col">

src/renderer/views/Workspace/Workspace.tsx

Lines changed: 7 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -110,20 +110,13 @@ export function Workspace({ project }: { project: Project }): React.JSX.Element
110110
</header>
111111

112112
<main className="flex min-h-0 flex-1">
113-
{activeTab === 'studio' ? (
114-
<>
115-
<div className="relative min-h-0 flex-1">
116-
<MoodboardPanel />
117-
</div>
118-
{settingsOpen && (
119-
<div className="min-h-0 w-80 shrink-0">
120-
<SettingsPanel onClose={() => setSettingsOpen(false)} />
121-
</div>
122-
)}
123-
</>
124-
) : (
125-
<div className="min-h-0 flex-1">
126-
<TrainerPanel />
113+
<div className="relative min-h-0 flex-1">
114+
{activeTab === 'studio' ? <MoodboardPanel /> : <TrainerPanel />}
115+
</div>
116+
{/* App settings are global, so the drawer is shared by every tab, not just Studio. */}
117+
{settingsOpen && (
118+
<div className="min-h-0 w-80 shrink-0">
119+
<SettingsPanel onClose={() => setSettingsOpen(false)} />
127120
</div>
128121
)}
129122
</main>

vite.config.spa.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export default defineConfig({
3535
'/rpc': CORE,
3636
'/upload': CORE,
3737
'/media': CORE,
38+
'/download': CORE,
3839
'/v1': CORE,
3940
'/studio': CORE,
4041
'/events': { target: CORE_WS, ws: true },

0 commit comments

Comments
 (0)