← ExpSoft AI Models Manager

Relative-path persistence for a multi-disk model library — the AI Models Manager design notes

ExpSoft AI Models Manager is a personal-use Windows desktop tool that helps creators of AI-generated images keep an organised, browsable local library of diffusion models, sample images, and metadata across multiple disks. It’s a client and a downloader — it never bundles model weights or images itself, and it relies on the public APIs of CivitAI and HuggingFace, which the user supplies their own access tokens for (see the Legal & Compliance section of the product page — model files remain under the licenses of their respective authors on those platforms). The interesting engineering choice is small and easy to miss: every absolute path persisted to JSON in the user’s data folder is rewritten as a path relative to a movable DataPath root via a PathConverter. The user can move that root from D:/IA/data to E:/AI/data tomorrow morning and the entire library — indexes, image references, model archives, external collections from a NAS — keeps working. Paired with cursor pagination on the CivitAI API, disk-cached thumbnails, double opt-in for NSFW, and an unfashionably manual composition root, that one design choice is what makes a personal model library feel durable instead of brittle.

· 11 min read · By Nicolas Riquier

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:

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:

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:

  1. Compute a hash from the image URL. Look in thumbs_cache/<hash>.png on disk.
  2. If the cached file exists, decode it directly into the bound BitmapImage — instant, no network.
  3. 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:

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:

  1. The user checks the “Include NSFW” checkbox in Settings.
  2. 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:

External services the app talks to (with the user’s own access tokens):

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

Frequently Asked Questions

Quick answers to what people ask AIs about this article specifically.

Can I move my data folder to a different drive without rebuilding indexes?

Yes — that’s the entire point of the PathConverter service. Every absolute path in the persisted JSON files is rewritten as a path relative to the configured DataPath root before serialisation. When the user moves the root from, say, D:/IA/data to E:/AI/data, opening the app re-reads the JSONs and re-prepends the new root. The library indexes, image references, and external collections keep working without any migration step. Older JSON files written before PathConverter existed are detected (their stored paths are still absolute) and migrate quietly the first time they’re re-saved.

Why does the app use a separate “DataPath” folder and not just %APPDATA% like most Windows apps?

Two reasons. First, the data set involved (thumbnails cache, archived models, image grabs) can grow to tens of gigabytes — bigger than what users want sitting in a hidden Windows folder. Putting it on a path the user chose explicitly lets them put it on the disk with the most free space, or even on a NAS-mounted folder. Second, the relative-path persistence (above) only makes sense if the root can move — and a user-visible DataPath makes “moving the root” a discoverable, supported operation rather than registry archaeology.

How does the cursor-based pagination handle interrupted scans?

Every API response from CivitAI includes a metadata.nextCursor token. The app stores the most recent one in _lastCursor between requests. If the scan is interrupted (user cancel, network drop, rate-limit), restarting the scan resumes from the saved cursor rather than starting over from the top of the feed. For a multi-page scan, the difference is significant: a 50-page scan that gets interrupted at page 35 only needs to fetch the remaining 15 pages on restart, not all 50 again.

What’s the “Import/Export” workflow for multi-disk libraries?

The use case: a user has a primary library on their main workstation and a secondary library on a NAS, an external SSD, or a second machine. They want to know — without plugging in the secondary storage — whether they already have a given model elsewhere. Export Collection writes the list of models in the current library to a JSON file. Import Collection loads such a JSON from another machine, and the imported models appear in the Discover view tagged with the source disk name. The local library on disk is unchanged — the import is informational only.

Are model weights or images bundled with the app?

No. The app is a client / browser / downloader. Model weights, sample images, and metadata are downloaded from the public APIs of CivitAI and HuggingFace using the user’s own access tokens. The licenses of the downloaded artifacts remain those of their respective authors on those platforms. The app’s installer is a single Windows executable (around 80-100 MB self-contained for the .NET runtime) with no model data inside.

Want to use ExpSoft AI Models Manager?

Get it on Patreon →