Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions Endpoints/ModelProxyEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -187,24 +187,24 @@ async Task<IResult> HandleDeleteModel(HttpContext context, IHttpClientFactory cl
var tag = !string.IsNullOrWhiteSpace(pipeline_tag) ? pipeline_tag : pipeline_tags;

string requestUrl;
var searchParam = !string.IsNullOrWhiteSpace(query) ? $"search={Uri.EscapeDataString(query)}&" : "";
if (!string.IsNullOrWhiteSpace(tag))
{
if (tag.Equals("gguf", StringComparison.OrdinalIgnoreCase))
{
var qParam = string.IsNullOrWhiteSpace(query) ? "llama" : query;
requestUrl = $"https://huggingface.co/api/models?search={Uri.EscapeDataString(qParam)}&filter=gguf&sort=downloads&direction=-1&limit=25";
var qFallback = string.IsNullOrWhiteSpace(query) ? "search=llama&" : searchParam;
requestUrl = $"https://huggingface.co/api/models?{qFallback}filter=gguf&sort=downloads&direction=-1&limit=25";
}
else
{
var qParam = Uri.EscapeDataString(query);
var tagParam = Uri.EscapeDataString(tag);
requestUrl = $"https://huggingface.co/api/models?search={qParam}&pipeline_tag={tagParam}&sort=downloads&direction=-1&limit=25";
requestUrl = $"https://huggingface.co/api/models?{searchParam}pipeline_tag={tagParam}&sort=downloads&direction=-1&limit=25";
}
}
else
{
var qParam = string.IsNullOrWhiteSpace(query) ? "llama" : query;
requestUrl = $"https://huggingface.co/api/models?search={Uri.EscapeDataString(qParam)}&filter=gguf&sort=downloads&direction=-1&limit=25";
var qFallback = string.IsNullOrWhiteSpace(query) ? "search=llama&" : searchParam;
requestUrl = $"https://huggingface.co/api/models?{qFallback}filter=gguf&sort=downloads&direction=-1&limit=25";
}

using var req = new HttpRequestMessage(HttpMethod.Get, requestUrl);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,9 @@ public async Task<List<HuggingFaceRepoItem>> SearchRepositoriesAsync(string apiB
public async Task<List<HuggingFaceRepoItem>> SearchModelsAsync(string query, string? pipelineTag = null, System.Threading.CancellationToken ct = default)
{
using var http = new HttpClient();
var tagParam = string.IsNullOrWhiteSpace(pipelineTag) ? "" : $"&pipeline_tag={Uri.EscapeDataString(pipelineTag)}";
var requestUrl = $"https://huggingface.co/api/models?search={Uri.EscapeDataString(query)}{tagParam}&sort=downloads&direction=-1&limit=20";
var searchParam = string.IsNullOrWhiteSpace(query) ? "" : $"search={Uri.EscapeDataString(query)}&";
var tagParam = string.IsNullOrWhiteSpace(pipelineTag) ? "" : $"pipeline_tag={Uri.EscapeDataString(pipelineTag)}&";
var requestUrl = $"https://huggingface.co/api/models?{searchParam}{tagParam}sort=downloads&direction=-1&limit=20";
var result = new List<HuggingFaceRepoItem>();
try
{
Expand Down
132 changes: 132 additions & 0 deletions LocalLLMServerManager.Shared/Services/UiDiagnosticLogger.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;

namespace LocalLLMServerManager.Shared.Services;

/// <summary>
/// Represents a captured UI binding or diagnostic error entry.
/// </summary>
public record BindingDiagnosticEntry(DateTime Timestamp, string Message, string? Source = null);

/// <summary>
/// Interface for thread-safe UI diagnostic error logging.
/// </summary>
public interface IUiDiagnosticLogger
{
/// <summary>
/// Records a new UI binding error.
/// </summary>
void RecordBindingError(string message, string? source = null);

/// <summary>
/// Retrieves the most recent binding diagnostic entries up to <paramref name="maxCount"/>.
/// </summary>
IReadOnlyList<BindingDiagnosticEntry> GetRecentErrors(int maxCount = 50);

/// <summary>
/// Clears all recorded diagnostic entries.
/// </summary>
void Clear();

/// <summary>
/// Exports a formatted diagnostic summary string.
/// </summary>
string ExportLogSummary();
}

/// <summary>
/// Thread-safe bounded diagnostic logger service for UI binding errors.
/// </summary>
public class UiDiagnosticLogger : IUiDiagnosticLogger
{
public const int DefaultMaxEntries = 500;

public static UiDiagnosticLogger Instance { get; } = new();

private readonly int _maxCapacity;
private readonly object _lock = new();
private readonly Queue<BindingDiagnosticEntry> _entries;

public int MaxCapacity => _maxCapacity;

public int Count
{
get
{
lock (_lock)
{
return _entries.Count;
}
}
}

public UiDiagnosticLogger(int maxCapacity = DefaultMaxEntries)
{
_maxCapacity = maxCapacity > 0 ? maxCapacity : DefaultMaxEntries;
_entries = new Queue<BindingDiagnosticEntry>(_maxCapacity);
}

public void RecordBindingError(string message, string? source = null)
{
lock (_lock)
{
while (_entries.Count >= _maxCapacity)
{
_entries.Dequeue();
}

_entries.Enqueue(new BindingDiagnosticEntry(DateTime.UtcNow, message ?? string.Empty, source));
}
}

public IReadOnlyList<BindingDiagnosticEntry> GetRecentErrors(int maxCount = 50)
{
if (maxCount <= 0)
{
return Array.Empty<BindingDiagnosticEntry>();
}

lock (_lock)
{
if (_entries.Count == 0)
{
return Array.Empty<BindingDiagnosticEntry>();
}

int count = Math.Min(maxCount, _entries.Count);
int skip = _entries.Count - count;
return _entries.Skip(skip).Take(count).ToList().AsReadOnly();
}
}

public void Clear()
{
lock (_lock)
{
_entries.Clear();
}
}

public string ExportLogSummary()
{
lock (_lock)
{
if (_entries.Count == 0)
{
return "No binding errors recorded.";
}

var sb = new StringBuilder();
sb.AppendLine($"=== UI Diagnostic Summary ({_entries.Count} error(s)) ===");
foreach (var entry in _entries)
{
var sourceInfo = string.IsNullOrWhiteSpace(entry.Source) ? string.Empty : $" [{entry.Source}]";
sb.AppendLine($"[{entry.Timestamp:yyyy-MM-dd HH:mm:ss.fff}]{sourceInfo}: {entry.Message}");
}

return sb.ToString().TrimEnd();
}
}
}
78 changes: 63 additions & 15 deletions LocalLLMServerManager.Shared/ViewModels/CivitaiSearchViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,23 @@ public partial class CivitaiSearchViewModel : ObservableObject
private readonly ITelemetryService? _telemetryService;

[ObservableProperty] private string _civitaiSearchQuery = "";
[ObservableProperty] private string _selectedCivitaiType = "Checkpoint";
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsTypeAllActive))]
[NotifyPropertyChangedFor(nameof(IsTypeCheckpointActive))]
[NotifyPropertyChangedFor(nameof(IsTypeLoraActive))]
private string _selectedCivitaiType = "Checkpoint";

public bool IsTypeAllActive => string.IsNullOrWhiteSpace(SelectedCivitaiType) || SelectedCivitaiType.Equals("All", StringComparison.OrdinalIgnoreCase);
public bool IsTypeCheckpointActive => SelectedCivitaiType.Equals("Checkpoint", StringComparison.OrdinalIgnoreCase);
public bool IsTypeLoraActive => SelectedCivitaiType.Equals("LORA", StringComparison.OrdinalIgnoreCase);

[ObservableProperty] private string? _activeStarterChip = null;

[ObservableProperty] private bool _isLoading = false;
public ObservableCollection<CivitaiModelItem> CivitaiResults { get; } = new();
public ObservableCollection<CivitaiModelItem> FilteredCivitaiResults { get; } = new();
public ObservableCollection<CivitaiModelItem> StarterModels { get; } = new();
public ObservableCollection<CivitaiModelItem> FilteredStarterModels { get; } = new();

public static readonly List<CivitaiModelItem> DefaultStarterModels = new()
{
Expand Down Expand Up @@ -71,6 +83,7 @@ private void InitStarterModels()
var badge = _canIRunItService.EvaluateQuickFit(s.Name, s.SizeBytes > 0 ? s.SizeBytes : null, "Image", (long)TotalVramMb, (long)TotalRamMb);
StarterModels.Add(s with { FitBadge = badge });
}
ApplyFilter();
}

public void UpdateHardwareTelemetry(double totalVramMb, double totalRamMb)
Expand Down Expand Up @@ -103,28 +116,39 @@ public void ApplyFilter()
FilteredCivitaiResults.Clear();
foreach (var r in CivitaiResults)
{
if (r.FitBadge == null)
if (MatchesVerdictFilter(r.FitBadge))
{
FilteredCivitaiResults.Add(r);
continue;
}
}

bool matches = r.FitBadge.FitVerdict switch
{
FitVerdict.FullVram => IsFullVramActive,
FitVerdict.PartialOffload => IsPartialOffloadActive,
FitVerdict.CpuOnly => IsCpuOnlyActive,
FitVerdict.OutOfMemory => IsOomActive,
_ => true
};

if (matches)
FilteredStarterModels.Clear();
foreach (var s in StarterModels)
{
bool typeMatches = string.IsNullOrWhiteSpace(SelectedCivitaiType) ||
SelectedCivitaiType.Equals("All", StringComparison.OrdinalIgnoreCase) ||
s.Type.Equals(SelectedCivitaiType, StringComparison.OrdinalIgnoreCase);

if (typeMatches && MatchesVerdictFilter(s.FitBadge))
{
FilteredCivitaiResults.Add(r);
FilteredStarterModels.Add(s);
}
}
}

private bool MatchesVerdictFilter(QuickFitBadge? fitBadge)
{
if (fitBadge == null) return true;
return fitBadge.FitVerdict switch
{
FitVerdict.FullVram => IsFullVramActive,
FitVerdict.PartialOffload => IsPartialOffloadActive,
FitVerdict.CpuOnly => IsCpuOnlyActive,
FitVerdict.OutOfMemory => IsOomActive,
_ => true
};
}

[RelayCommand]
public void ToggleFitVerdict(string verdict)
{
Expand Down Expand Up @@ -153,9 +177,19 @@ public void ToggleFitVerdict(string verdict)
partial void OnIsCpuOnlyActiveChanged(bool value) => ApplyFilter();
partial void OnIsOomActiveChanged(bool value) => ApplyFilter();

[RelayCommand]
public void SelectCivitaiType(string? type)
{
SelectedCivitaiType = string.IsNullOrWhiteSpace(type) ? "All" : type;
ActiveStarterChip = null;
ApplyFilter();
_ = SearchCivitaiAsync();
}

[RelayCommand]
public void ApplyStarterChip(string? chip)
{
ActiveStarterChip = chip;
var clean = (chip ?? "").Replace("🌟", "").Replace("📸", "").Replace("🎨", "").Replace("🎭", "").Replace("⚡", "").Replace("🔮", "").Trim();
if (clean.Contains("LoRA", StringComparison.OrdinalIgnoreCase))
{
Expand Down Expand Up @@ -188,6 +222,19 @@ public void InspectModel(CivitaiModelItem? item)
}
}

public async Task LoadDefaultModelsAsync(string apiBase, HttpClient http)
{
if (CivitaiResults.Count > 0) return;
try
{
await SearchCivitaiAsync(apiBase, http);
}
catch
{
// Starter models are retained
}
}

[RelayCommand]
public async Task SearchCivitaiAsync()
{
Expand All @@ -199,7 +246,8 @@ public async Task SearchCivitaiAsync(string apiBase, HttpClient http)
IsLoading = true;
try
{
var results = await _civitaiSearchService.SearchModelsAsync(apiBase, CivitaiSearchQuery, SelectedCivitaiType, "Most Downloaded", http);
string typeParam = SelectedCivitaiType.Equals("All", StringComparison.OrdinalIgnoreCase) ? "" : SelectedCivitaiType;
var results = await _civitaiSearchService.SearchModelsAsync(apiBase, CivitaiSearchQuery, typeParam, "Most Downloaded", http);
CivitaiResults.Clear();
foreach (var r in results)
{
Expand Down
Loading
Loading