using System.ComponentModel;
using System.Runtime.CompilerServices;
using LlamaApp.Common;
using Microsoft.UI.Xaml.Media;
using Microsoft.UI.Xaml.Media.Imaging;
namespace LlamaApp.Views;
///
/// Lightweight view-model item for a model row. Represents either a locally
/// downloaded GGUF model or a Hugging Face Hub model available for download.
/// Implements so the UI can react to
/// download-progress updates in real time.
///
public sealed class ModelItem : IModel, INotifyPropertyChanged
{
private bool _isDownloading;
private double _downloadFraction;
private long _downloadedBytes;
private long _downloadTotalBytes;
private double _downloadBytesPerSecond;
private bool _downloadFailed;
private CancellationTokenSource? _downloadCancellation;
private bool _isLoading;
private double _loadFraction;
private bool _isLoaded;
private bool _loadFailed;
private ImageSource? _logo;
/// Display label (e.g. "GPT-OSS 20B (mxfp4)").
public string Name { get; set; } = "";
///
/// The actual Hugging Face repo id (e.g. "ggml-org/gpt-oss-20b-GGUF") used
/// for server API calls. Separated from (the display
/// label) so the UI shows a friendly name while the server gets the repo id.
///
public string? RepoName { get; set; }
// ---- IModel (explicit Name so IModel.Name returns the repo id) ----
string IModel.Name => RepoName ?? Name;
///
/// Server model id = <repo>:<quant> (or just <repo>
/// when is empty) — the form the llama server's
/// /models/load requires. See .
///
string IModel.ServerModelId => string.IsNullOrEmpty(Quant)
? (RepoName ?? Name)
: $"{RepoName ?? Name}:{Quant}";
public string Description { get; set; } = "";
/// Short display name: the part of after the last '/'.
public string DisplayName => Name.Split('/', StringSplitOptions.RemoveEmptyEntries).Last().Trim();
///
/// The row's tooltip: the full (possibly ellipsized) name, plus the
/// catalog's one-line on a second line when
/// known — so you can tell what a model is before downloading it.
///
public string RowToolTip => string.IsNullOrWhiteSpace(Description)
? Name
: $"{Name}\n{Description}";
public string Parameters { get; set; } = "";
public string Size { get; set; } = "";
///
/// Raw download size in bytes from the catalog (0 when unknown) — used by
/// the disk-space preflight before a Recommended-row download starts.
///
public ulong SizeBytes { get; set; }
public string License { get; set; } = "";
public bool Vision { get; set; }
/// Quantization label, e.g. "Q4_0", "mxfp4" (used for ServerModelId).
public string? Quant { get; set; } = null;
///
/// The resolved brand logo (theme-dependent — see ).
/// Notifies so the shell can re-resolve logos on a theme change.
///
public ImageSource? Logo
{
get => _logo;
set
{
if (ReferenceEquals(_logo, value)) return;
_logo = value;
OnPropertyChanged();
}
}
/// True for Hub models that can be downloaded; false for locally available models (run/play).
public bool Downloadable { get; set; }
public string? Brand { get; set; }
// ---- Download progress state (drives the progress ring) ----
/// True while a download is in flight; the row shows a progress ring.
public bool IsDownloading
{
get => _isDownloading;
set
{
if (_isDownloading == value) return;
_isDownloading = value;
OnPropertyChanged();
OnPropertyChanged(nameof(PlayGlyphVisible));
OnPropertyChanged(nameof(ProgressRingVisible));
OnPropertyChanged(nameof(LoadingRingVisible));
OnPropertyChanged(nameof(OpenGlyphVisible));
OnPropertyChanged(nameof(IsIndeterminateDownload));
OnPropertyChanged(nameof(DownloadPercentTextVisible));
OnPropertyChanged(nameof(CancelDownloadVisible));
OnPropertyChanged(nameof(SubtitleText));
}
}
/// Download completion fraction (0..1); bound to the progress ring.
public double DownloadFraction
{
get => _downloadFraction;
set
{
if (!(Math.Abs(_downloadFraction - value) > 0.001)) return;
_downloadFraction = value;
OnPropertyChanged();
OnPropertyChanged(nameof(DownloadProgressPercent));
OnPropertyChanged(nameof(DownloadProgressText));
OnPropertyChanged(nameof(DownloadPercentTextVisible));
OnPropertyChanged(nameof(IsIndeterminateDownload));
}
}
///
/// Cancels an in-flight download. Created by the download driver
/// (MainWindow.DownloadAndLaunchAsync) when the download starts and cleared
/// when it ends; the row's cancel button calls
/// on it. Stays null for
/// downloads the app didn't start (WebUI / CLI) — those can't be canceled
/// from the row, so the setter notifies
/// to keep the cancel button in sync.
///
public CancellationTokenSource? DownloadCancellation
{
get => _downloadCancellation;
set
{
if (ReferenceEquals(_downloadCancellation, value)) return;
_downloadCancellation = value;
OnPropertyChanged(nameof(CancelDownloadVisible));
}
}
///
/// Bytes fetched so far, fed by the download driver (and the external-
/// download watch) from the server's SSE progress events. Drives the
/// detail line that replaces the subtitle while downloading.
///
public long DownloadedBytes
{
get => _downloadedBytes;
set
{
if (_downloadedBytes == value) return;
_downloadedBytes = value;
OnPropertyChanged();
OnPropertyChanged(nameof(DownloadDetailText));
OnPropertyChanged(nameof(SubtitleText));
}
}
/// Total bytes to fetch; 0 until the server reports a size.
public long DownloadTotalBytes
{
get => _downloadTotalBytes;
set
{
if (_downloadTotalBytes == value) return;
_downloadTotalBytes = value;
OnPropertyChanged();
OnPropertyChanged(nameof(DownloadDetailText));
OnPropertyChanged(nameof(SubtitleText));
}
}
///
/// Smoothed download rate in bytes/sec, estimated by the download driver
/// between throttled progress samples; 0 until the first estimate.
///
public double DownloadBytesPerSecond
{
get => _downloadBytesPerSecond;
set
{
if (Math.Abs(_downloadBytesPerSecond - value) < 1.0) return;
_downloadBytesPerSecond = value;
OnPropertyChanged();
OnPropertyChanged(nameof(DownloadDetailText));
OnPropertyChanged(nameof(SubtitleText));
}
}
/// True if the download failed; the row shows an error indicator.
public bool DownloadFailed
{
get => _downloadFailed;
set
{
if (_downloadFailed == value) return;
_downloadFailed = value;
OnPropertyChanged();
// A failed row swaps the play glyph for the warning + retry affordance.
OnPropertyChanged(nameof(PlayGlyphVisible));
}
}
///
/// True when the last load request was rejected by the server (OOM, corrupt
/// GGUF, …) or never confirmed — the row shows a warning + retry affordance
/// instead of the play glyph, mirroring . Set by
/// the load driver (MainWindow.LoadAndWatchAsync); cleared when a load is
/// (re)attempted and when the server reports the model loaded.
///
public bool LoadFailed
{
get => _loadFailed;
set
{
if (_loadFailed == value) return;
_loadFailed = value;
OnPropertyChanged();
// A failed row swaps the play glyph for the warning + retry affordance.
OnPropertyChanged(nameof(PlayGlyphVisible));
}
}
// ---- Model load state (drives the Available-row action cell) ----
// The lifecycle of a local model row, left to right:
// unloaded --(play click)--> loading --(server reports loaded)--> loaded
// A row that's mid-download shows the download ring first, then transitions
// to loading once the download finishes and the load request is sent.
///
/// True while a load request is in flight — the row shows a load ring
/// (indeterminate until the server's status_change events report a
/// fraction via ). Set optimistically by the
/// play-click handler; cleared by the model-state poller once the server
/// reports the model as loaded (or by the load caller on rejection).
///
public bool IsLoading
{
get => _isLoading;
set
{
if (_isLoading == value) return;
_isLoading = value;
OnPropertyChanged();
OnPropertyChanged(nameof(PlayGlyphVisible));
OnPropertyChanged(nameof(LoadingRingVisible));
OnPropertyChanged(nameof(OpenGlyphVisible));
OnPropertyChanged(nameof(IsIndeterminateLoad));
OnPropertyChanged(nameof(LoadPercentTextVisible));
}
}
///
/// Load completion fraction (0..1), fed by the server's status_change
/// SSE events while the model loads; 0 until the first event arrives (the
/// load ring spins indeterminately until then — also the steady state for
/// externally-triggered loads, which only the poller observes).
///
public double LoadFraction
{
get => _loadFraction;
set
{
if (!(Math.Abs(_loadFraction - value) > 0.001)) return;
_loadFraction = value;
OnPropertyChanged();
OnPropertyChanged(nameof(LoadProgressPercent));
OnPropertyChanged(nameof(LoadProgressText));
OnPropertyChanged(nameof(LoadPercentTextVisible));
OnPropertyChanged(nameof(IsIndeterminateLoad));
}
}
///
/// True when the server reports the model as loaded — the row shows
/// the OpenInNewWindow glyph (click to open the WebUI). Updated by the
/// model-state poller.
///
public bool IsLoaded
{
get => _isLoaded;
set
{
if (_isLoaded == value) return;
_isLoaded = value;
OnPropertyChanged();
OnPropertyChanged(nameof(PlayGlyphVisible));
OnPropertyChanged(nameof(LoadingRingVisible));
OnPropertyChanged(nameof(OpenGlyphVisible));
}
}
// ---- Derived UI state (avoids needing XAML value converters) ----
// The Available-row action cell shows exactly one of: play (unloaded),
// download ring (downloading from the Hub), load ring (load request
// sent), or OpenInNewWindow (loaded). Download takes
// priority over a load (a row can't load until it's downloaded).
///
/// True when the play glyph should be visible (unloaded, idle). A failed
/// download shows the warning + retry affordance instead of play — the
/// model isn't (fully) cached, so loading it would just be rejected. A
/// failed load shows the same affordance so the rejection isn't silent.
///
public bool PlayGlyphVisible => !IsDownloading && !IsLoading && !IsLoaded && !DownloadFailed && !LoadFailed;
/// True when the download progress ring should be visible.
public bool ProgressRingVisible => IsDownloading;
///
/// True when the row's cancel-download button should be visible — while an
/// app-driven download is in flight. Externally-triggered downloads (WebUI /
/// CLI) have no source, so the button
/// hides rather than offering a no-op cancel.
///
public bool CancelDownloadVisible => IsDownloading && DownloadCancellation is not null;
/// True when the load ring should be visible.
public bool LoadingRingVisible => IsLoading && !IsDownloading;
/// True when the OpenInNewWindow glyph should be visible (loaded).
public bool OpenGlyphVisible => IsLoaded && !IsDownloading && !IsLoading;
/// True when the ring should spin indeterminately (no bytes yet).
public bool IsIndeterminateDownload => IsDownloading && DownloadFraction <= 0;
/// Download completion as a percentage (0..100) for ProgressRing.Value.
public double DownloadProgressPercent => DownloadFraction * 100;
/// Download completion as a short label (e.g. "42%") shown under the ring.
public string DownloadProgressText => $"{DownloadProgressPercent:0}%";
///
/// True when the percent caption should be visible — while downloading with
/// a known byte count (an indeterminate ring shows no caption).
///
public bool DownloadPercentTextVisible => IsDownloading && DownloadFraction > 0;
/// True when the load ring should spin indeterminately (no progress reported yet).
public bool IsIndeterminateLoad => IsLoading && LoadFraction <= 0;
/// Load completion as a percentage (0..100) for ProgressRing.Value.
public double LoadProgressPercent => LoadFraction * 100;
/// Load completion as a short label (e.g. "42%") shown under the load ring.
public string LoadProgressText => $"{LoadProgressPercent:0}%";
///
/// True when the load percent caption should be visible — while the load
/// ring is up and a progress fraction has been reported.
///
public bool LoadPercentTextVisible => LoadingRingVisible && LoadFraction > 0;
///
/// The download detail line, e.g. "3.2 GB of 12.1 GB · 45 MB/s · ~4 min
/// left" (formatting rules live in ).
///
public string DownloadDetailText => DownloadProgressPresentation.FormatDetail(
DownloadedBytes, DownloadTotalBytes, DownloadBytesPerSecond);
///
/// The row's subtitle line: while a download with a known size runs, the
/// live progress detail (); otherwise the
/// catalog's "params · size" pair (empty parts dropped).
///
public string SubtitleText => IsDownloading && DownloadTotalBytes > 0
? DownloadDetailText
: string.Join(" · ", new[] { Parameters, Size }
.Where(s => !string.IsNullOrWhiteSpace(s)));
public event PropertyChangedEventHandler? PropertyChanged;
private void OnPropertyChanged([CallerMemberName] string? prop = null)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(prop));
// Resolved-logo cache: parsing an SVG into an SvgImageSource isn't free,
// and the same few brands repeat across every row — the Recommended list
// alone can hold dozens of rows sharing ~6 brands, and the Available list
// is rebuilt from scratch on every full populate. ImageSources are
// shareable across Image elements, so one instance per brand serves all
// rows. Callers are on the UI thread; the lock is belt-and-suspenders.
private static readonly object LogoCacheLock = new();
private static readonly Dictionary LogoCache = new(StringComparer.Ordinal);
///
/// Set by the shell (MainWindow) from the effective theme: true while the
/// dark theme is active → picks the white
/// ".light" SVG variants. Read/written on the UI thread, like the cache.
///
public static bool UseLightLogos;
///
/// Clears the resolved-logo cache. Called by the shell on a theme change,
/// before re-resolving every row's .
///
public static void ClearLogoCache()
{
lock (LogoCacheLock) LogoCache.Clear();
}
///
/// Resolves a to a bundled brand-logo ImageSource
/// (Assets/Logos/<logo>.svg), using the brand→logo mapping. Returns
/// null when the brand is unknown — the XAML Image then stays empty, and the
/// row shows the background tile alone. Instances are cached per brand (see
/// ).
///
/// The bundled SVGs fill with currentColor, which
/// renders as black — invisible on the dark
/// theme's Mica surface. When is set, the
/// white-filled <logo>.light.svg variant is used instead.
///
public static ImageSource? ResolveLogo(string? brand)
{
var logo = BrandToLogo(brand);
if (logo is null) return null;
// Dark theme → white artwork (the default variant rasterizes black).
// The variant suffix becomes part of the cache key, so both themes'
// instances coexist in the cache.
if (UseLightLogos) logo += ".light";
lock (LogoCacheLock)
{
if (LogoCache.TryGetValue(logo, out var cached))
return cached;
// Rasterize at a bounded 64px width rather than the SVG's natural
// size: the tile renders the logo at 24px logical (48px physical at
// 200% scale), so a small raster keeps per-image memory down while
// staying crisp on high-DPI displays. Setting only the width
// preserves the aspect ratio.
var source = new SvgImageSource(new Uri($"ms-appx:///Assets/Logos/{logo}.svg"))
{
RasterizePixelWidth = 64,
};
LogoCache[logo] = source;
return source;
}
}
///
/// Brand → logo filename mapping (case-insensitive prefix match). Mirrors
/// the macOS app's brandLogoAsset table. Returns null for unknown brands.
///
private static string? BrandToLogo(string? brand)
{
if (string.IsNullOrWhiteSpace(brand)) return null;
var b = brand.Trim();
if (Has("qwen")) return "qwen";
if (Has("gemma")) return "gemma";
if (Has("openai")) return "gpt";
if (Has("gpt")) return "gpt";
if (Has("mistral")) return "mistral";
if (Has("ministral")) return "mistral";
if (Has("devstral")) return "mistral";
if (Has("magistral")) return "mistral";
if (Has("glm")) return "z";
if (Has("nemotron")) return "nvidia";
if (Has("nvidia")) return "nvidia";
return null;
bool Has(string key) => b.StartsWith(key, StringComparison.OrdinalIgnoreCase);
}
}