← 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 Windows desktop tool that helps creators of AI-generated images keep an organised, browsable local library of models, sample images and their prompts across several 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, with the user’s own access tokens (model files remain under the licenses of their respective authors on those platforms). The interesting engineering choice is small and easy to miss: every path persisted in the app’s data files is rewritten as a path relative to the data root via a PathConverter, and that root is simply the app’s own folder, re-resolved at every launch. Move the whole folder from D: to a bigger E: tomorrow morning and the entire library — catalogue, image references, downloaded models, collections imported from other drives — keeps working. Paired with cursor pagination on the CivitAI API, disk-cached thumbnails and an unfashionably manual composition root, that one design choice is what makes a personal model library feel durable instead of brittle.

· Updated · 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 eight 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 (every version and file of the archived models, sent to the cart), Workflows (the generation workflows CivitAI also hosts, shown as cards), 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 kinds of record get persisted to disk as JSON under the data root (the app’s own folder):

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 if that path still exists. If it no longer does, it re-anchors it on its Downloads\, models_cache\ or Users\ segment under the current root — so old entries survive a move too, and get quietly upgraded the first time the user re-saves them.

The root itself is never a setting: at every launch the app resolves it as its own folder (or the folder just above, if the data is there), and Settings only shows it. Moving the library from a 1 TB SSD to a 4 TB SSD means moving the app’s folder — no index to rebuild, nothing to configure. 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 scans in runs of N pages (Batches) and keeps the cursor of the last completed run in _lastCursor. The Continue button fetches the next N pages from there instead of re-fetching from the top; New Scan starts over. Rate limits and server errors are retried with a back-off of 10 to 60 seconds before a scan gives up. For a long walk through the LoRAs, that’s the difference between “I have to restart from the beginning every time” and “I can go a few pages at a time.”

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 key from the image URL. Look in thumbs_cache\<key>.<ext> 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, store the bytes in the cache as served, then decode them at the width the view needs and bind the 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 thumbs_cache\ in the app’s folder; Clear Cache in Discover empties it. Its size depends on the preview sizes the CDN serves, so it is worth clearing after a long browsing spree.

The deliberately manual composition root

The app instantiates ten services and ten 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 a hundred and fifteen lines of explicit new statements, in a deliberate order:

ConfigManager → AppConfig → LogService → PathConverter → DataService
  → FolderStatsCache → CivitAIApi → DownloadService → ThumbnailService
  → ImageCache → HuggingFaceApi → QueueManager
  → (splash, thumbs_cache, base-model list synced from CivitAI, 6 s max)
  → 8 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 — ten 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.

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 (image, video and model-file counts, plus the thumbnails to show) to disk between sessions and only invalidates an entry when the folder’s last-write time changes. The first time a folder is touched, the cost is paid; every subsequent access is instant. The cache is written every 60 seconds when something changed, and once more at MainWindow.Closed — not 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 mirror, tried first

Many model files published on CivitAI also exist on the HuggingFace Hub, whose storage is fast for large files. When the user has saved a HuggingFace token, the cart first looks for a mirror of each .safetensors file on the Hub, by exact file name among the most-downloaded matching repositories. If one is found, the file comes from there; if not — or if that download fails — the app uses the CivitAI link, and the partial file from the Hub is discarded first so that bytes from two different sources are never mixed.

Either way, a download resumes: the partial .tmp file is kept when a download is cut, paused, stopped or interrupted by closing the app, the transfer continues with an HTTP range request, and a resumed file is checked against its SHA-256 before it is accepted.

Cross-tab navigation by Action delegates

The eight 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.

The Workflows tab, added since, wires in the same way (WorkflowsViewModel.NavigateToGalleryAction), and a delete in Discover refreshes Models and Workflows through one more delegate. For a graph of about nine edges, the trade is still 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 app deliberately shows two buttons at the top right of its window: “Support Civitai.com” and “Support Hugging Face”, opening each platform’s membership page. 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 data root — the app’s own folder — before serialisation. When the user moves the app’s folder from, say, D: to E:, the app resolves its new root at launch, re-reads the JSONs and re-prepends it. 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), re-anchored under the new root when their old path is gone, and migrate quietly the first time they’re re-saved.

Why does the app keep its data in its own folder and not in %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. Keeping it next to the app lets the user put it on the disk with the most free space simply by unzipping the app there. Second, the relative-path persistence (above) only makes sense if the root can move — and “the data is wherever the app is” makes moving it a plain folder move rather than registry archaeology.

How does the cursor-based pagination handle interrupted scans?

Every API response from CivitAI includes a metadata.nextCursor token. Discover scans in runs of N pages and keeps the cursor of the last completed run in _lastCursor. Continue fetches the next N pages from that cursor instead of starting over from the top of the feed; New Scan starts again. Rate limits and server errors are retried with a back-off first. A run cancelled half-way is not resumed page by page: Continue goes on from the last completed run.

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 writes the list of models in the current library to a JSON file. IMPORT loads such a JSON from another disk, 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, and those models are skipped when you archive.

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 ships as a small zip — an executable of about 9 MB plus the WebView2 loader — and uses the free .NET 8 Desktop Runtime, which Windows offers to install on first launch. No model data inside.

Want to use ExpSoft AI Models Manager?

Get it on Patreon →