The personal-library problem
If you spend any time generating images with diffusion models on Windows, the models/ folder turns into archaeology surprisingly fast. The pattern repeats across every install: an SSD where the system lives and that’s almost full; a fast NVMe dedicated to AI workloads; an HDD for the overflow. Fifteen sub-folders organised by model type — checkpoints, loras, embeddings, vae, controlnet, upscale_models, ipadapter, clip_vision, and so on. Hundreds of .safetensors files with names like lora_v3_final_FINAL_real.safetensors that meant something six months ago and mean nothing now.
The data exists to make this organised — the platforms that host the models (CivitAI in particular) expose rich metadata, sample images with full generation prompts, version histories, license information. But the data sits over there, behind a browser, while your library sits over here, on disk. Bridging the two without coupling the tool to a particular generator front-end is exactly the gap AI Models Manager fills.
The shape of the app is four top-level tabs (Welcome / Run / Settings / More Tools) with the Run tab subdividing into seven workflow panes: Discover (scan CivitAI by filters, batch-grab metadata), Gallery (browse the images you’ve downloaded, click-through to full metadata), Users (organised by creator), Logs (the real-time log pane), Models (the actual .safetensors downloader, with HuggingFace fallback for big files), Cart (a serial download queue with pause/resume), and Import/Export (the multi-disk collection workflow).
The path-conversion trick that makes it portable
Three things get persisted to disk as JSON files under the DataPath root:
archived/— one JSON per model the user has scanned, with the full metadata snapshot from the CivitAI API plus references to local artifacts.users/— one JSON per CivitAI creator the user has grabbed content from, indexing the images and metadata stored locally.external/— collections exported from another machine or disk and imported here. The user sees “you already have this LoRA on the NAS” without having to plug the NAS in.
The naive implementation stores absolute paths in those JSONs — “image lives at D:/IA/data/images/foo.png”. It works fine until the user moves D:/IA/data to E:/AI/data (drive reorganisation, NAS migration, switching to a faster SSD). At that point every absolute path in every JSON points at a non-existent location and the library appears empty.
The PathConverter service rewrites every absolute path through two operations:
ToRelative(absolutePath)strips the currentDataPathprefix at serialisation time. The stored value becomes something like"images/foo.png"— independent of where the root lives on disk today.ToAbsolute(relativePath)prepends the currentDataPathat deserialisation time. If the user moved the root, the new value is the new absolute path. If the root didn’t move, the value is identical to what was stored before.
One subtlety: there’s a long tail of older JSON files written before the PathConverter existed, with full absolute paths still inside. The ToAbsolute function detects whether the input is already absolute (drive letter or backslash root) and returns it unchanged in that case. The migration is graceful — old data keeps working as long as the original path is still valid, and gets quietly upgraded the first time the user re-saves the entry.
This is what lets a user move their DataPath from a 1 TB SSD to a 4 TB SSD, or from a local drive to a NAS-mounted folder, without rebuilding any index. The app doesn’t even ask — it just works.
Cursor pagination, and why the offset alternative wouldn’t cut it
The CivitAI API uses cursor-based pagination: each response includes a metadata.nextCursor token, and the caller passes that token back as a query parameter on the next request. There is no “page 1, page 2, page 3” numbering.
This is the right design for a feed that moves in real time. New models get added to the platform every few minutes; if the API used offset pagination (“give me items 100-199 of the most-downloaded LoRAs”), the same offset would return different items between two requests, and a long scan would either skip new items or return the same one twice depending on which side of the cursor the new arrival landed on. Cursor pagination is stable: the cursor pins a position in the feed, and following it gives you a coherent, monotonic walk.
AI Models Manager’s Discover scan stores the most-recent cursor in _lastCursor. If a scan is interrupted (the user cancels, the network drops, the API rate-limits), restarting picks up exactly where it stopped rather than re-fetching from the top. For a 50-page scan of LoRAs, that’s the difference between “I have to restart from the beginning every time” and “I can pause whenever I want.”
Thumbnails — async load, disk cache, lazy
A model gallery with 50 000 thumbnails is unusable if every image is fetched synchronously over the network every time the user scrolls. The AsyncImage control wraps WPF’s image binding with a three-step protocol:
- Compute a hash from the image URL. Look in
thumbs_cache/<hash>.pngon disk. - If the cached file exists, decode it directly into the bound
BitmapImage— instant, no network. - If not, fetch the source URL asynchronously, resize to the configured
ThumbnailSize, write the result into the cache, then bind the decoded image.
None of this blocks the UI thread. The decode uses BitmapImage.CacheOption = OnLoad so the file handle closes immediately after read (the file can be deleted by the user without WPF complaining). The cache itself lives under DataPath/thumbs_cache/ and survives across sessions — most users only pay the network cost the first time they see an image. For a library that grows to tens of thousands of entries, the cache size is bounded by the configured thumbnail size; at 256 px it’s about 20 KB per image, so 50 000 thumbnails is roughly a gigabyte of cache for a session that touches everything.
The deliberately manual composition root
The app instantiates ten services and nine ViewModels at startup. The standard pattern for a project this size is to wire them through a dependency-injection container — Microsoft.Extensions.DependencyInjection or one of the older third-party options. AI Models Manager doesn’t. App.OnStartup is about eighty lines of explicit new statements, in a deliberate order:
ConfigManager → AppConfig → LogService → PathConverter → DataService
→ FolderStatsCache → CivitAIApi → DownloadService → ThumbnailService
→ ImageCache → HuggingFaceApi → QueueManager
→ 7 sub-VMs → MainViewModel → ShellViewModel → MainWindow
The trade-off is honest. With a DI container, adding a new service is a one-line registration in a setup method. Without, it’s an edit to App.OnStartup in the right position. For a small app — nine ViewModels, ten services — the cost of that edit is negligible compared to the benefits of the explicit version:
- When something fails at startup, you see exactly which line in
App.OnStartupthrew, in a single global try/catch thatMessageBox’es the stack trace and exits cleanly. No “registration order” mystery to debug. - Startup is genuinely instant — no assembly scanning, no reflection-based dependency graph walk.
- The composition is self-documenting. New contributors can read the file top-to-bottom and see the dependency order.
This pattern doesn’t scale to enterprise-size projects. For a personal-use desktop tool with ten services, it’s the right choice.
NSFW — double opt-in with age verification
A platform like CivitAI hosts content of every kind, including content that requires age-of-majority confirmation before being displayed. By default, AI Models Manager treats this as off — the NSFW level sent to the API is "None", and the UI never surfaces flagged content.
Enabling NSFW filtering off requires two distinct actions:
- The user checks the “Include NSFW” checkbox in Settings.
- A modal appears that lists three confirmations the user must agree to: that they are 18 years of age or older; that they understand explicit content may be displayed; that they accept responsibility for the content they view and download.
If the user clicks “No” on the modal, the checkbox snaps back to off — no UI state survives the refusal. If the user clicks “Yes”, the configured NSFW level becomes "X" (CivitAI’s maximum) and is persisted to config.json. The double-action pattern is more friction than a single checkbox, deliberately — the friction is the point. A user who flipped the checkbox by accident has a clear off-ramp.
Folder stats cache — the 50 000-image performance trap
Counting the files in a folder and adding up their sizes is an O(n) walk. For a folder of fifty thousand images, that’s a few seconds on a slow disk — long enough that switching tabs in the Gallery view, which triggers a re-count of each source, becomes noticeably slow. Worse, the count happens every time the user switches tabs, so the cumulative time across a session is unbounded.
The FolderStatsCache persists per-folder stats (file count, total size, last-checked timestamp) to disk between sessions and only invalidates a cache entry when a change is detected on the source folder. The first time a folder is touched, the cost is paid; every subsequent access is instant. The cache is written to disk at MainWindow.Closed rather than on every change, which keeps the disk-write rate down on long sessions.
This is the kind of optimisation that doesn’t appear in any feature list, but it’s the difference between “Gallery tab feels snappy” and “Gallery tab freezes for three seconds every time I click it.”
HuggingFace as a CivitAI fallback for big files
Some model weights on CivitAI are larger than two gigabytes (typical for SDXL checkpoints and Flux variants). CivitAI’s download CDN can be congested at peak hours, and partial downloads on multi-gigabyte files are painful. The CivitAI metadata for many of these files includes a huggingFaceUrl field pointing at a mirror on the HuggingFace Hub.
AI Models Manager’s download path tries CivitAI first and falls back to HuggingFace when the metadata has the alternative URL and the user has supplied a HuggingFace token in Settings. HuggingFace’s storage is generally faster for large files and supports resume cleanly. The fallback is automatic and transparent — the user sees the download go through whichever path completes first.
Cross-tab navigation by Action delegates
The seven sub-tabs of the Run view need to navigate between each other: from the Gallery, clicking a creator name jumps to Users; from a Users gallery, clicking a model name jumps to Models; from Discover, clicking a model card jumps to Models with that model selected; from Import/Export, importing a collection refreshes the Discover view.
Patterns at hand: an event-aggregator/mediator service; a single ViewModel that owns the navigation state and is referenced by every sub-VM; or direct method calls via Action delegates wired at composition time. AI Models Manager picks the third:
ModelsViewModel.NavigateToGalleryAction = modelId => { ...; SelectedTabIndex = 1; };
DiscoverViewModel.NavigateToModelsAction = modelId => { ...; SelectedTabIndex = 4; };
GalleryViewModel.NavigateToUserGalleryAction = username => { ...; SelectedTabIndex = 2; };
UsersGalleryViewModel.NavigateToModelsAction = modelName => { ...; SelectedTabIndex = 4; };
ImportExportViewModel.OnExternalModelsChanged = () => DiscoverViewModel.ReloadExternalModels();
The MainViewModel composes the sub-VMs and assigns the delegates at construction time. Each navigation is a direct method call; the IDE’s “Find All References” shows the entire navigation graph; adding a new navigation is one assignment in MainViewModel’s constructor. No event subscription state to forget to unsubscribe, no mediator with magic strings.
For a graph with five edges, the trade is correct. For a more complex graph it’s probably time for a different pattern; for this one, the explicit version reads better.
Supporting the platforms the app relies on
The footer of the app deliberately includes two prominent links: “Support CivitAI” and “Support HuggingFace”, pointing at the pricing pages of each. The platforms’ public APIs and CDN bandwidth aren’t a free resource — they cost real money to run. A personal-use library tool that relies on them should make the supporting-the-source path easy to find. It’s a small UI choice with a clear intention.
Credits and license
AI Models Manager is original ExpSoft C# / WPF code:
- .NET 8 + WPF — Microsoft, MIT.
- Newtonsoft.Json — James Newton-King, MIT.
- Microsoft.Web.WebView2 — Microsoft, distribution permitted.
- FFmpeg — LGPL-2.1+ / GPL-2.0+. Optional, user-supplied through the FFmpeg Path setting. Used to generate thumbnails for video samples produced by recent video-generation models.
External services the app talks to (with the user’s own access tokens):
- CivitAI — public REST API for browsing and downloading models. The platform’s rate limits and acceptable-use policy apply.
- HuggingFace Hub — public REST API and CDN for fallback downloads.
The model weights downloaded through the app remain under the licenses of their respective authors on CivitAI or HuggingFace. AI Models Manager bundles no model weights and is purely a client / browser / downloader.
Take-aways
- For any tool that persists references to local files, store the references as paths relative to a movable root. The user’s ability to reorganise their storage without rebuilding indexes is worth the small abstraction.
- Cursor pagination is the right choice for feed-style APIs. Storing the most-recent cursor on the client gives you free resumability.
- Disk-cache async-loaded thumbnails. For libraries past a few thousand items, the difference is between “smooth” and “unusable.”
- For small desktop apps (single-digit count of services), a deliberately manual composition root in
App.OnStartupbeats a DI container on readability and failure diagnostics. - Make the supporting-the-source link prominent. Tools that rely on free public APIs should make it easy to fund those APIs.