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); } }