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
79 changes: 75 additions & 4 deletions Endpoints/ModelProxyEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,14 +44,33 @@ public static void MapModelProxyEndpoints(this WebApplication app)
return Results.Ok(new { models = new object[0] });
});

app.MapGet("/api/hf/search", async (string? q, HttpClient httpClient) =>
app.MapGet("/api/hf/search", async (string? q, string? pipeline_tag, HttpClient httpClient) =>
{
try
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
var query = string.IsNullOrWhiteSpace(q) ? "llama" : q;
var requestUrl = $"https://huggingface.co/api/models?search={Uri.EscapeDataString(query)}&filter=gguf&sort=downloads&direction=-1&limit=20";
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var query = string.IsNullOrWhiteSpace(q) ? (string.IsNullOrWhiteSpace(pipeline_tag) ? "llama" : "") : q;

string requestUrl;
if (!string.IsNullOrWhiteSpace(pipeline_tag))
{
if (pipeline_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=20";
}
else
{
var qParam = Uri.EscapeDataString(query);
requestUrl = $"https://huggingface.co/api/models?search={qParam}&pipeline_tag={Uri.EscapeDataString(pipeline_tag)}&sort=downloads&direction=-1&limit=20";
}
}
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=20";
}

using var req = new HttpRequestMessage(HttpMethod.Get, requestUrl);
req.Headers.UserAgent.ParseAdd("LocalLLMServerManager/3.5.0");
var response = await httpClient.SendAsync(req, cts.Token);
Expand All @@ -69,6 +88,58 @@ public static void MapModelProxyEndpoints(this WebApplication app)
}
});

app.MapGet("/api/civitai/download", async (string fileUrl, string? modelType, string? fileName, HttpClient httpClient) =>
{
try
{
var safeFileName = string.IsNullOrWhiteSpace(fileName) ? "model.safetensors" : fileName;
var targetDir = LocalLLMServerManager.Shared.Services.DownloadManager.ResolveTargetDirectory(modelType, safeFileName);
Directory.CreateDirectory(targetDir);
var targetPath = Path.Combine(targetDir, safeFileName);

using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
using var response = await httpClient.GetAsync(fileUrl, HttpCompletionOption.ResponseHeadersRead, cts.Token);
if (response.IsSuccessStatusCode)
{
using var stream = await response.Content.ReadAsStreamAsync(cts.Token);
using var fileStream = File.Create(targetPath);
await stream.CopyToAsync(fileStream, cts.Token);
return Results.Ok(new { status = "success", path = targetPath });
}
return Results.StatusCode((int)response.StatusCode);
}
catch (Exception ex)
{
return Results.Problem(ex.Message);
}
});

app.MapGet("/api/hf/download", async (string fileUrl, string? pipelineTag, string? fileName, HttpClient httpClient) =>
{
try
{
var safeFileName = string.IsNullOrWhiteSpace(fileName) ? "model.safetensors" : fileName;
var targetDir = LocalLLMServerManager.Shared.Services.DownloadManager.ResolveTargetDirectory(pipelineTag, safeFileName);
Directory.CreateDirectory(targetDir);
var targetPath = Path.Combine(targetDir, safeFileName);

using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
using var response = await httpClient.GetAsync(fileUrl, HttpCompletionOption.ResponseHeadersRead, cts.Token);
if (response.IsSuccessStatusCode)
{
using var stream = await response.Content.ReadAsStreamAsync(cts.Token);
using var fileStream = File.Create(targetPath);
await stream.CopyToAsync(fileStream, cts.Token);
return Results.Ok(new { status = "success", path = targetPath });
}
return Results.StatusCode((int)response.StatusCode);
}
catch (Exception ex)
{
return Results.Problem(ex.Message);
}
});

app.MapGet("/api/hf/model", async (string repoId, HttpClient httpClient) =>
{
try
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ namespace LocalLLMServerManager.Shared.Interfaces;

public interface IHuggingFaceSearchService
{
Task<List<HuggingFaceRepoItem>> SearchRepositoriesAsync(string apiBase, string query, HttpClient http);
Task<List<HuggingFaceRepoItem>> SearchRepositoriesAsync(string apiBase, string query, string? pipelineTag, HttpClient http);
Task<List<HuggingFaceRepoItem>> SearchRepositoriesAsync(string apiBase, string query, HttpClient http) => SearchRepositoriesAsync(apiBase, query, null, http);
Task<List<HuggingFaceRepoItem>> SearchModelsAsync(string query, string? pipelineTag = null, System.Threading.CancellationToken ct = default);
Task<List<HfFileQuantItem>> FetchQuantizationsAsync(string apiBase, string repoId, HttpClient http);
}
45 changes: 45 additions & 0 deletions LocalLLMServerManager.Shared/Services/DownloadManager.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
using System;
using System.IO;

namespace LocalLLMServerManager.Shared.Services;

public static class DownloadManager
{
public static string ResolveTargetDirectory(string? modelTypeOrPipelineTag, string? fileName = null, string? rootPath = null)
{
var baseDir = rootPath ?? AppContext.BaseDirectory;
var tagOrType = (modelTypeOrPipelineTag ?? "").ToLowerInvariant();
var file = (fileName ?? "").ToLowerInvariant();

// Video models -> ComfyUI/models/diffusion_models
if (tagOrType.Contains("video") || tagOrType.Contains("text-to-video") || tagOrType.Contains("image-to-video") ||
file.Contains("wan") || file.Contains("ltx") || file.Contains("hunyuanvideo"))
{
return Path.Combine(baseDir, "ComfyUI", "models", "diffusion_models");
}

// TTS / Audio models -> models/tts
if (tagOrType.Contains("speech") || tagOrType.Contains("tts") || tagOrType.Contains("audio") ||
tagOrType.Contains("text-to-speech") || tagOrType.Contains("text-to-audio") ||
tagOrType.Contains("automatic-speech-recognition") ||
file.Contains("kokoro") || file.Contains("f5-tts") || file.Contains("stable-audio"))
{
return Path.Combine(baseDir, "models", "tts");
}

// 3D models -> models/3d
if (tagOrType.Contains("3d") || tagOrType.Contains("text-to-3d") || file.Contains("trellis") || file.Contains("hunyuan3d"))
{
return Path.Combine(baseDir, "models", "3d");
}

// LoRA -> models/Lora
if (tagOrType.Contains("lora"))
{
return Path.Combine(baseDir, "models", "Lora");
}

// Default / Checkpoints / LLMs
return Path.Combine(baseDir, "models", "checkpoints");
}
}
49 changes: 47 additions & 2 deletions LocalLLMServerManager.Shared/Services/HuggingFaceSearchService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,17 @@ namespace LocalLLMServerManager.Shared.Services;
public class HuggingFaceSearchService : IHuggingFaceSearchService
{
public async Task<List<HuggingFaceRepoItem>> SearchRepositoriesAsync(string apiBase, string query, HttpClient http)
{
return await SearchRepositoriesAsync(apiBase, query, null, http);
}

public async Task<List<HuggingFaceRepoItem>> SearchRepositoriesAsync(string apiBase, string query, string? pipelineTag, HttpClient http)
{
var result = new List<HuggingFaceRepoItem>();
try
{
var url = $"{apiBase}/api/hf/search?q={Uri.EscapeDataString(query)}";
var tagParam = string.IsNullOrWhiteSpace(pipelineTag) ? "" : $"&pipeline_tag={Uri.EscapeDataString(pipelineTag)}";
var url = $"{apiBase}/api/hf/search?q={Uri.EscapeDataString(query)}{tagParam}";
var response = await http.GetAsync(url);
if (response.IsSuccessStatusCode)
{
Expand All @@ -29,9 +35,48 @@ public async Task<List<HuggingFaceRepoItem>> SearchRepositoriesAsync(string apiB
string author = repo?["author"]?.ToString() ?? "Community";
int downloads = repo?["downloads"]?.GetValue<int>() ?? 0;
int likes = repo?["likes"]?.GetValue<int>() ?? 0;
string tag = repo?["pipeline_tag"]?.ToString() ?? "";
if (!string.IsNullOrEmpty(id))
{
result.Add(new HuggingFaceRepoItem(id, author, likes, $"{downloads:N0} downloads", tag));
}
}
}
}
}
catch { }

return result;
}

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 result = new List<HuggingFaceRepoItem>();
try
{
using var req = new HttpRequestMessage(HttpMethod.Get, requestUrl);
req.Headers.UserAgent.ParseAdd("LocalLLMServerManager/3.5.0");
var response = await http.SendAsync(req, ct);
if (response.IsSuccessStatusCode)
{
var jsonStr = await response.Content.ReadAsStringAsync(ct);
var doc = JsonNode.Parse(jsonStr);
var repos = doc?.AsArray();
if (repos != null)
{
foreach (var repo in repos)
{
string id = repo?["id"]?.ToString() ?? "";
string author = repo?["author"]?.ToString() ?? "Community";
int downloads = repo?["downloads"]?.GetValue<int>() ?? 0;
int likes = repo?["likes"]?.GetValue<int>() ?? 0;
string tag = repo?["pipeline_tag"]?.ToString() ?? "";
if (!string.IsNullOrEmpty(id))
{
result.Add(new HuggingFaceRepoItem(id, author, likes, $"{downloads:N0} downloads"));
result.Add(new HuggingFaceRepoItem(id, author, likes, $"{downloads:N0} downloads", tag));
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ public partial class HuggingFaceSearchViewModel : ObservableObject
private readonly IHuggingFaceSearchService _hfSearchService;

[ObservableProperty] private string _hfSearchQuery = "";
[ObservableProperty] private string? _selectedPipelineTag = null;
public ObservableCollection<HuggingFaceRepoItem> HuggingFaceResults { get; } = new();

[ObservableProperty] private bool _isHfModalOpen = false;
Expand All @@ -31,11 +32,18 @@ public async Task SearchHuggingFaceAsync()
await SearchHuggingFaceAsync("http://127.0.0.1:5246", new HttpClient());
}

[RelayCommand]
public async Task SelectCategoryAsync(string? tag)
{
SelectedPipelineTag = string.IsNullOrWhiteSpace(tag) ? null : tag;
await SearchHuggingFaceAsync();
}

public async Task SearchHuggingFaceAsync(string apiBase, HttpClient http)
{
try
{
var results = await _hfSearchService.SearchRepositoriesAsync(apiBase, HfSearchQuery, http);
var results = await _hfSearchService.SearchRepositoriesAsync(apiBase, HfSearchQuery, SelectedPipelineTag, http);
HuggingFaceResults.Clear();
foreach (var r in results)
{
Expand Down
3 changes: 2 additions & 1 deletion LocalLLMServerManager.Shared/ViewModels/MainViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ public record HuggingFaceRepoItem(
string Id,
string Author,
int Likes,
string Downloads
string Downloads,
string PipelineTag = ""
);

public record HfFileQuantItem(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,28 @@
x:Class="LocalLLMServerManager.Shared.Views.Controls.HuggingFaceTabControl"
x:DataType="vm:HuggingFaceSearchViewModel">

<Grid RowDefinitions="Auto, *" Margin="0,12,0,0">
<Grid Grid.Row="0" ColumnDefinitions="*, Auto" Margin="0,0,0,12">
<TextBox Grid.Column="0" Text="{Binding HfSearchQuery}" PlaceholderText="Search GGUF models (e.g. llama 3.3, qwen2.5-coder)..."
<Grid RowDefinitions="Auto, Auto, *" Margin="0,12,0,0">
<Grid Grid.Row="0" ColumnDefinitions="*, Auto" Margin="0,0,0,8">
<TextBox Grid.Column="0" Text="{Binding HfSearchQuery}" PlaceholderText="Search Hugging Face models (e.g. llama 3.3, Wan, LTX, Kokoro)..."
Classes="matte-input" VerticalAlignment="Center"/>
<Button Grid.Column="1" Command="{Binding SearchHuggingFaceCommand}"
Classes="matte-primary" Margin="8,0,0,0" VerticalAlignment="Center">
🔍 Search Hub
</Button>
</Grid>

<ScrollViewer Grid.Row="1">
<ScrollViewer Grid.Row="1" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled" Margin="0,0,0,12">
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Content="All" Command="{Binding SelectCategoryCommand}" CommandParameter="" Classes="matte-pill"/>
<Button Content="🦙 LLM (GGUF)" Command="{Binding SelectCategoryCommand}" CommandParameter="gguf" Classes="matte-pill"/>
<Button Content="🎬 Video" Command="{Binding SelectCategoryCommand}" CommandParameter="text-to-video" Classes="matte-pill"/>
<Button Content="🔊 Speech/TTS" Command="{Binding SelectCategoryCommand}" CommandParameter="text-to-speech" Classes="matte-pill"/>
<Button Content="🎵 Music/SFX" Command="{Binding SelectCategoryCommand}" CommandParameter="text-to-audio" Classes="matte-pill"/>
<Button Content="📦 3D Mesh" Command="{Binding SelectCategoryCommand}" CommandParameter="text-to-3d" Classes="matte-pill"/>
</StackPanel>
</ScrollViewer>

<ScrollViewer Grid.Row="2">
<ItemsControl ItemsSource="{Binding HuggingFaceResults}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:HuggingFaceRepoItem">
Expand All @@ -25,6 +36,9 @@
<TextBlock Text="{Binding Author, StringFormat='Author: {0}'}" FontSize="12" Foreground="{StaticResource TextMutedBrush}"/>
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
<Border Classes="matte-pill" Padding="8,4" IsVisible="{Binding PipelineTag, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<TextBlock Text="{Binding PipelineTag}" FontSize="11" Foreground="{StaticResource AccentBrush}" FontWeight="SemiBold"/>
</Border>
<Border Classes="matte-pill" Padding="8,4">
<TextBlock Text="{Binding Downloads, StringFormat='⬇️ {0:N0}'}" FontSize="11" Foreground="{StaticResource TextMainBrush}" FontWeight="SemiBold"/>
</Border>
Expand Down
22 changes: 20 additions & 2 deletions LocalLLMServerManager.Tests/SearchServicesTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,8 @@ public async Task HuggingFaceSearchService_SearchRepositoriesAsync_ParsesJsonRes
""id"": ""meta-llama/Llama-3.3-8B-Instruct-GGUF"",
""author"": ""meta-llama"",
""likes"": 1200,
""downloads"": 45000
""downloads"": 45000,
""pipeline_tag"": ""text-generation""
}
]";

Expand All @@ -91,11 +92,28 @@ public async Task HuggingFaceSearchService_SearchRepositoriesAsync_ParsesJsonRes
var client = new HttpClient(handlerMock.Object);
var service = new HuggingFaceSearchService();

var results = await service.SearchRepositoriesAsync("http://localhost", "llama", client);
var results = await service.SearchRepositoriesAsync("http://localhost", "llama", "text-generation", client);
Assert.NotEmpty(results);
Assert.Equal("meta-llama/Llama-3.3-8B-Instruct-GGUF", results[0].Id);
Assert.Equal("meta-llama", results[0].Author);
Assert.Equal(1200, results[0].Likes);
Assert.Equal("text-generation", results[0].PipelineTag);
}

[Theory]
[InlineData("text-to-video", "ltx.safetensors", "ComfyUI/models/diffusion_models")]
[InlineData("image-to-video", "wan.safetensors", "ComfyUI/models/diffusion_models")]
[InlineData("text-to-speech", "kokoro.pt", "models/tts")]
[InlineData("text-to-audio", "f5tts.pt", "models/tts")]
[InlineData("text-to-3d", "trellis.safetensors", "models/3d")]
[InlineData("Lora", "style.safetensors", "models/Lora")]
[InlineData("Checkpoint", "sd.safetensors", "models/checkpoints")]
public void DownloadManager_ResolveTargetDirectory_RoutesCorrectly(string tagOrType, string fileName, string expectedSubdir)
{
var root = "/test/root";
var resolved = DownloadManager.ResolveTargetDirectory(tagOrType, fileName, root);
var normalizedExpected = System.IO.Path.Combine(root, expectedSubdir.Replace('/', System.IO.Path.DirectorySeparatorChar));
Assert.Equal(normalizedExpected, resolved);
}

[Fact]
Expand Down
Loading