diff --git a/.gitignore b/.gitignore index cfa9380..6dd97e4 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,10 @@ # dotenv files .env +# Worktrees +.worktrees/ +worktrees/ + # User-specific files *.rsuser *.suo diff --git a/Endpoints/ModelProxyEndpoints.cs b/Endpoints/ModelProxyEndpoints.cs index 639feae..e0375e1 100644 --- a/Endpoints/ModelProxyEndpoints.cs +++ b/Endpoints/ModelProxyEndpoints.cs @@ -187,24 +187,24 @@ async Task 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); diff --git a/LocalLLMServerManager.Shared/Interfaces/IAiAppTools.cs b/LocalLLMServerManager.Shared/Interfaces/IAiAppTools.cs index d4361eb..c02e6ac 100644 --- a/LocalLLMServerManager.Shared/Interfaces/IAiAppTools.cs +++ b/LocalLLMServerManager.Shared/Interfaces/IAiAppTools.cs @@ -5,7 +5,7 @@ namespace LocalLLMServerManager.Shared.Interfaces; /// -/// Natural Language Integration Bridge: allows the AI Copilot to query telemetry, +/// Natural Language Integration Bridge: allows the AI Assist to query telemetry, /// manage engines, update settings, trigger workflows, and query documentation. /// public interface IAiAppTools diff --git a/LocalLLMServerManager.Shared/Models/StudioPresetModels.cs b/LocalLLMServerManager.Shared/Models/StudioPresetModels.cs index ca0a282..1c9624a 100644 --- a/LocalLLMServerManager.Shared/Models/StudioPresetModels.cs +++ b/LocalLLMServerManager.Shared/Models/StudioPresetModels.cs @@ -9,7 +9,9 @@ public enum StudioModality { Image, Video, - Audio + Audio, + Text, + Mesh } /// diff --git a/LocalLLMServerManager.Shared/Services/HuggingFaceSearchService.cs b/LocalLLMServerManager.Shared/Services/HuggingFaceSearchService.cs index f6cf39d..df7761e 100644 --- a/LocalLLMServerManager.Shared/Services/HuggingFaceSearchService.cs +++ b/LocalLLMServerManager.Shared/Services/HuggingFaceSearchService.cs @@ -78,8 +78,9 @@ public async Task> SearchRepositoriesAsync(string apiB public async Task> 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(); try { diff --git a/LocalLLMServerManager.Shared/Services/PromptManagementService.cs b/LocalLLMServerManager.Shared/Services/PromptManagementService.cs index a21dc86..114eeb3 100644 --- a/LocalLLMServerManager.Shared/Services/PromptManagementService.cs +++ b/LocalLLMServerManager.Shared/Services/PromptManagementService.cs @@ -151,8 +151,8 @@ private async Task LoadPromptDocumentAsync(string fileName, string fallb return fallbackContent; } - private const string FallbackSystemPrompt = @"# LocalLLMServerManager AI Copilot โ€” System Prompt -You are the LocalLLMServerManager Copilot, an expert AI engineer integrated directly into the application. + private const string FallbackSystemPrompt = @"# LocalLLMServerManager AI Assist โ€” System Prompt +You are the LocalLLMServerManager AI Assist, an expert AI engineer integrated directly into the application. Guide users on models, workflows, and settings, and proactively use your tools to perform actions in the app."; private const string FallbackCapabilitiesPrompt = @"# Application Capabilities Reference diff --git a/LocalLLMServerManager.Shared/Services/UiDiagnosticLogger.cs b/LocalLLMServerManager.Shared/Services/UiDiagnosticLogger.cs new file mode 100644 index 0000000..9c8d6e1 --- /dev/null +++ b/LocalLLMServerManager.Shared/Services/UiDiagnosticLogger.cs @@ -0,0 +1,132 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace LocalLLMServerManager.Shared.Services; + +/// +/// Represents a captured UI binding or diagnostic error entry. +/// +public record BindingDiagnosticEntry(DateTime Timestamp, string Message, string? Source = null); + +/// +/// Interface for thread-safe UI diagnostic error logging. +/// +public interface IUiDiagnosticLogger +{ + /// + /// Records a new UI binding error. + /// + void RecordBindingError(string message, string? source = null); + + /// + /// Retrieves the most recent binding diagnostic entries up to . + /// + IReadOnlyList GetRecentErrors(int maxCount = 50); + + /// + /// Clears all recorded diagnostic entries. + /// + void Clear(); + + /// + /// Exports a formatted diagnostic summary string. + /// + string ExportLogSummary(); +} + +/// +/// Thread-safe bounded diagnostic logger service for UI binding errors. +/// +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 _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(_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 GetRecentErrors(int maxCount = 50) + { + if (maxCount <= 0) + { + return Array.Empty(); + } + + lock (_lock) + { + if (_entries.Count == 0) + { + return Array.Empty(); + } + + 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(); + } + } +} diff --git a/LocalLLMServerManager.Shared/Services/UiDiagnosticTraceListener.cs b/LocalLLMServerManager.Shared/Services/UiDiagnosticTraceListener.cs new file mode 100644 index 0000000..ede469f --- /dev/null +++ b/LocalLLMServerManager.Shared/Services/UiDiagnosticTraceListener.cs @@ -0,0 +1,47 @@ +using System; +using System.Diagnostics; + +namespace LocalLLMServerManager.Shared.Services; + +/// +/// TraceListener that intercepts Avalonia UI binding diagnostic and warning messages +/// and records them into an IUiDiagnosticLogger instance. +/// +public class UiDiagnosticTraceListener : TraceListener +{ + private readonly IUiDiagnosticLogger _logger; + + public UiDiagnosticTraceListener(IUiDiagnosticLogger logger) + : base("UiDiagnosticTraceListener") + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public override void Write(string? message) + { + ProcessMessage(message); + } + + public override void WriteLine(string? message) + { + ProcessMessage(message); + } + + private void ProcessMessage(string? message) + { + if (string.IsNullOrWhiteSpace(message)) + { + return; + } + + // Intercept messages related to Avalonia data binding errors and warnings + if (message.Contains("[Binding]", StringComparison.OrdinalIgnoreCase) || + message.Contains("Error in binding", StringComparison.OrdinalIgnoreCase) || + message.Contains("Binding error", StringComparison.OrdinalIgnoreCase) || + (message.Contains("Binding", StringComparison.OrdinalIgnoreCase) && + (message.Contains("Error", StringComparison.OrdinalIgnoreCase) || message.Contains("Warning", StringComparison.OrdinalIgnoreCase)))) + { + _logger.RecordBindingError(message.Trim(), "Avalonia.Binding"); + } + } +} diff --git a/LocalLLMServerManager.Shared/Styles/DesignTokens.axaml b/LocalLLMServerManager.Shared/Styles/DesignTokens.axaml index 62616d0..5586037 100644 --- a/LocalLLMServerManager.Shared/Styles/DesignTokens.axaml +++ b/LocalLLMServerManager.Shared/Styles/DesignTokens.axaml @@ -33,4 +33,15 @@ + + + M21,16.5C21,16.88 20.79,17.21 20.47,17.38L12.57,21.82C12.41,21.94 12.21,22 12,22C11.79,22 11.59,21.94 11.43,21.82L3.53,17.38C3.21,17.21 3,16.88 3,16.5V7.5C3,7.12 3.21,6.79 3.53,6.62L11.43,2.18C11.59,2.06 11.79,2 12,2C12.21,2 12.41,2.06 12.57,2.18L20.47,6.62C20.79,6.79 21,7.12 21,7.5V16.5M12,4.15L6.04,7.5L12,10.85L17.96,7.5L12,4.15M5,15.91L11,19.29V12.58L5,9.21V15.91M19,15.91V9.21L13,12.58V19.29L19,15.91Z + M13 2L3 14h6l-2 8 10-12h-6l2-8z + M6 2v2H4a2 2 0 0 0-2 2v2h2v2H2v2h2v2H2v2a2 2 0 0 0 2 2h2v2h2v-2h2v2h2v-2h2v2h2a2 2 0 0 0 2-2v-2h2v-2h-2v-2h2v-2h-2V6a2 2 0 0 0-2-2h-2V2h-2v2h-2V2h-2v2H8V2H6zm0 4h12v12H6V6zm2 2v8h8V8H8z + M12 2a2 2 0 0 1 2 2v1h3a3 3 0 0 1 3 3v8a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3V8a3 3 0 0 1 3-3h3V4a2 2 0 0 1 2-2zm-4 8a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3zm8 0a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3zm-6 5a1 1 0 0 0 0 2h4a1 1 0 1 0 0-2H10z + M18 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zM6 4h5v8l-2.5-1.5L6 12V4zm12 16H6v-2h12v2zm0-4H6v-2h12v2zm0-4h-5V4h5v8z + M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 0 0 .12-.61l-1.92-3.32a.49.49 0 0 0-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54A.48.48 0 0 0 14 2h-4a.48.48 0 0 0-.48.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96a.49.49 0 0 0-.59.22L2.64 8.47a.49.49 0 0 0 .12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58a.49.49 0 0 0-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h4c.24 0 .44-.17.48-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32a.49.49 0 0 0-.12-.61l-2.03-1.58zM12 15.5a3.5 3.5 0 1 1 0-7 3.5 3.5 0 0 1 0 7z + M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z + M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z + M12 2C6.49 2 2 6.49 2 12s4.49 10 10 10a2.5 2.5 0 0 0 2.5-2.5c0-.61-.23-1.2-.64-1.67-.4-.46-.61-1.04-.61-1.63 0-1.38 1.12-2.5 2.5-2.5H17c2.76 0 5-2.24 5-5 0-4.74-4.48-8.7-10-8.7zm-5.5 9a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zm3-4a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zm5 0a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zm3 4a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3z diff --git a/LocalLLMServerManager.Shared/Styles/MatteTheme.axaml b/LocalLLMServerManager.Shared/Styles/MatteTheme.axaml index d79eff7..5b5c985 100644 --- a/LocalLLMServerManager.Shared/Styles/MatteTheme.axaml +++ b/LocalLLMServerManager.Shared/Styles/MatteTheme.axaml @@ -5,6 +5,8 @@ + + @@ -115,8 +117,11 @@ Margin="0,0,0,16" Padding="4" HorizontalAlignment="Left"> - + + + + + + + + + + + + + + + + + diff --git a/LocalLLMServerManager.Shared/ViewModels/AiAssistantViewModel.cs b/LocalLLMServerManager.Shared/ViewModels/AiAssistantViewModel.cs index 0acd5d9..0998b74 100644 --- a/LocalLLMServerManager.Shared/ViewModels/AiAssistantViewModel.cs +++ b/LocalLLMServerManager.Shared/ViewModels/AiAssistantViewModel.cs @@ -18,7 +18,7 @@ namespace LocalLLMServerManager.Shared.ViewModels; /// -/// ViewModel managing the In-App AI Assistant & Copilot interface. +/// ViewModel managing the In-AI Assist interface. /// Provides multi-turn chat orchestration, streaming token rendering, tool invocation cards, /// setup wizard with connection validation, and natural language app interaction. /// @@ -36,6 +36,20 @@ public partial class AiAssistantViewModel : ObservableObject Converters = { new JsonStringEnumConverter() } }; + public Action? OnPopOutNativeWindowRequested { get; set; } + + [RelayCommand] + public void RequestPopOut() + { + OnPopOutNativeWindowRequested?.Invoke(); + } + + [RelayCommand] + public async Task RefreshModelsAsync() + { + await LoadAvailableModelsAsync(refresh: true); + } + // Chat History public ObservableCollection Messages { get; } = new(); @@ -52,6 +66,13 @@ public partial class AiAssistantViewModel : ObservableObject [ObservableProperty] private string _connectionStatusMessage = ""; [ObservableProperty] private bool? _isConnectionSuccess = null; [ObservableProperty] private string _promptDirectory = ""; + [ObservableProperty] private string _apiBase = ""; + + public string BuildUrl(string relativePath) + { + if (string.IsNullOrWhiteSpace(ApiBase)) return relativePath; + return $"{ApiBase.TrimEnd('/')}/{relativePath.TrimStart('/')}"; + } // Chat UI States [ObservableProperty] private string _inputText = ""; @@ -156,7 +177,7 @@ public AiAssistantViewModel( Messages.Add(new AiChatMessageItem { Role = "assistant", - Content = "๐Ÿ‘‹ Hello! I am your AI Assistant and App Copilot. I can query live VRAM telemetry, evaluate model hardware fit, inspect or update app settings, launch workflows, and answer questions about the platform.\n\nType a request below or tap any suggestion chip to get started!", + Content = "๐Ÿ‘‹ Hello! I am your AI Assist. I can query live VRAM telemetry, evaluate model hardware fit, inspect or update app settings, launch workflows, and answer questions about the platform.\n\nType a request below or tap any suggestion chip to get started!", Timestamp = DateTime.UtcNow }); } @@ -298,7 +319,8 @@ public async Task SendMessageAsync() else if (_httpClient != null) { // Remote / WASM client streaming via SSE or POST - var response = await _httpClient.PostAsJsonAsync("/api/ai/chat", request with { Stream = false }, ct); + var url = BuildUrl("/api/ai/chat"); + var response = await _httpClient.PostAsJsonAsync(url, request with { Stream = false }, ct); if (response.IsSuccessStatusCode) { var chatResp = await response.Content.ReadFromJsonAsync(JsonOptions, ct); @@ -411,7 +433,8 @@ public async Task TestConnectionAsync() else if (_httpClient != null) { var payload = new { endpoint = Endpoint, apiKey = ApiKey, model = SelectedModel }; - var resp = await _httpClient.PostAsJsonAsync("/api/ai/validate", payload); + var url = BuildUrl("/api/ai/validate"); + var resp = await _httpClient.PostAsJsonAsync(url, payload); if (resp.IsSuccessStatusCode) { result = await resp.Content.ReadFromJsonAsync(JsonOptions); @@ -480,7 +503,7 @@ public async Task LoadAvailableModelsAsync(bool refresh = false) { var ep = Uri.EscapeDataString(Endpoint ?? ""); var key = Uri.EscapeDataString(ApiKey ?? ""); - var url = $"/api/ai/models?endpoint={ep}&apiKey={key}&includeLocal=false"; + var url = BuildUrl($"/api/ai/models?endpoint={ep}&apiKey={key}&includeLocal=false"); caps = await _httpClient.GetFromJsonAsync>(url, JsonOptions); } @@ -513,7 +536,7 @@ public async Task LoadAvailableModelsAsync(bool refresh = false) } [RelayCommand] - public void SaveConfiguration() + public async Task SaveConfiguration() { if (_settingsService != null) { @@ -527,6 +550,29 @@ public void SaveConfiguration() }; _settingsService.SaveSettings(updated); } + else if (_httpClient != null) + { + try + { + var settingsUrl = BuildUrl("/api/settings"); + var currentSettings = await _httpClient.GetFromJsonAsync(settingsUrl, JsonOptions); + if (currentSettings != null) + { + var updated = currentSettings with + { + AiAssistantEnabled = IsEnabled, + AiAssistantEndpoint = Endpoint, + AiAssistantApiKey = ApiKey, + AiAssistantModel = SelectedModel + }; + await _httpClient.PostAsJsonAsync(settingsUrl, updated, JsonOptions); + } + } + catch + { + // Suppress remote save error + } + } IsSetupCardVisible = false; ConnectionStatusMessage = "Settings saved successfully."; @@ -543,7 +589,8 @@ public async Task ReloadPromptsAsync() } else if (_httpClient != null) { - await _httpClient.PostAsync("/api/ai/prompts/reload", null); + var url = BuildUrl("/api/ai/prompts/reload"); + await _httpClient.PostAsync(url, null); ConnectionStatusMessage = "Prompts reloaded via API."; } } diff --git a/LocalLLMServerManager.Shared/ViewModels/CivitaiSearchViewModel.cs b/LocalLLMServerManager.Shared/ViewModels/CivitaiSearchViewModel.cs index 2c10116..90769f0 100644 --- a/LocalLLMServerManager.Shared/ViewModels/CivitaiSearchViewModel.cs +++ b/LocalLLMServerManager.Shared/ViewModels/CivitaiSearchViewModel.cs @@ -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 CivitaiResults { get; } = new(); public ObservableCollection FilteredCivitaiResults { get; } = new(); public ObservableCollection StarterModels { get; } = new(); + public ObservableCollection FilteredStarterModels { get; } = new(); public static readonly List DefaultStarterModels = new() { @@ -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) @@ -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) { @@ -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)) { @@ -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() { @@ -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) { diff --git a/LocalLLMServerManager.Shared/ViewModels/DocumentationViewModel.cs b/LocalLLMServerManager.Shared/ViewModels/DocumentationViewModel.cs index e15a51d..a5f15ac 100644 --- a/LocalLLMServerManager.Shared/ViewModels/DocumentationViewModel.cs +++ b/LocalLLMServerManager.Shared/ViewModels/DocumentationViewModel.cs @@ -11,12 +11,15 @@ public record DocStep( int StepNumber, string Title, string Action, - string ExpectedResult + string ExpectedResult, + int? TargetTab = null ); public record DocSection( string Id, string Title, + string Category, + string ReadingTime, string Icon, string Summary, string Prerequisite, @@ -32,12 +35,103 @@ public partial class DocumentationViewModel : ObservableObject [ObservableProperty] private DocSection? _selectedSection; + [ObservableProperty] + private bool _isFloatingOverlayOpen = false; + + [ObservableProperty] + private bool _isMinimizedToPill = false; + + [ObservableProperty] + private double _overlayX = 30; + + [ObservableProperty] + private double _overlayY = 70; + + [ObservableProperty] + private int _currentStepIndex = 0; + + public DocStep? CurrentStep => SelectedSection?.Steps != null && CurrentStepIndex >= 0 && CurrentStepIndex < SelectedSection.Steps.Count + ? SelectedSection.Steps[CurrentStepIndex] + : null; + + public Action? OnNavigateToTabRequested { get; set; } + public Action? OnPopOutNativeWindowRequested { get; set; } + + [RelayCommand] + public void PopOutNativeWindow() + { + if (OnPopOutNativeWindowRequested != null) + { + OnPopOutNativeWindowRequested.Invoke(); + } + else + { + ToggleFloatingOverlay(); + } + } + public DocumentationViewModel() { InitializeSections(); SelectedSection = Sections.FirstOrDefault(); } + partial void OnSelectedSectionChanged(DocSection? value) + { + CurrentStepIndex = 0; + OnPropertyChanged(nameof(CurrentStep)); + } + + partial void OnCurrentStepIndexChanged(int value) + { + OnPropertyChanged(nameof(CurrentStep)); + } + + [RelayCommand] + public void ToggleFloatingOverlay() + { + IsFloatingOverlayOpen = !IsFloatingOverlayOpen; + if (IsFloatingOverlayOpen) + { + CurrentStepIndex = 0; + IsMinimizedToPill = false; + } + } + + [RelayCommand] + public void ToggleMinimizeToPill() + { + IsMinimizedToPill = !IsMinimizedToPill; + } + + [RelayCommand] + public void NextStep() + { + if (SelectedSection != null && CurrentStepIndex < SelectedSection.Steps.Count - 1) + { + CurrentStepIndex++; + } + } + + [RelayCommand] + public void PreviousStep() + { + if (CurrentStepIndex > 0) + { + CurrentStepIndex--; + } + } + + [RelayCommand] + public void JumpToStepTab(int? targetTab) + { + int tab = targetTab ?? CurrentStep?.TargetTab ?? -1; + if (tab >= 0) + { + OnNavigateToTabRequested?.Invoke(tab); + } + } + [RelayCommand] public void SelectSection(string sectionId) { @@ -56,17 +150,19 @@ private void InitializeSections() Sections.Add(new DocSection( Id: "image-generation", Title: "How to Generate Images", + Category: "Workflows", + ReadingTime: "2 min", Icon: "๐ŸŽจ", Summary: "Use this procedure to generate images with Stable Diffusion or ComfyUI.", Prerequisite: "Start the Forge or ComfyUI engine in the Workflows tab.", Steps: new List { - new(1, "Open Workflows Tab", "Click 'Workflows' in the top navigation bar.", "The Workflows workspace opens."), - new(2, "Select Image Studio", "Click the '๐ŸŽจ Image' button in the studio selector.", "The Image generation controls display."), - new(3, "Choose Engine", "Select 'Stable Diffusion / Forge' or 'ComfyUI'.", "The engine status shows Online."), - new(4, "Select Style Preset", "Click a preset chip such as 'Standard Square' or 'Landscape Wallpaper'.", "Resolution and sampling parameters load automatically."), - new(5, "Enter Text Prompt", "Type your descriptive prompt into the Prompt box. Add unwanted features into the Negative Prompt box.", "Prompt text is ready for generation."), - new(6, "Click Generate Image", "Click the '๐ŸŽจ Generate Image' button.", "The stage tracker shows generation progress and the final image appears in the gallery.") + new(1, "Open Workflows Tab", "Click 'Workflows' in the top navigation bar.", "The Workflows workspace opens.", TargetTab: 1), + new(2, "Select Image Studio", "Click the '๐ŸŽจ Image' button in the studio selector.", "The Image generation controls display.", TargetTab: 1), + new(3, "Choose Engine", "Select 'Stable Diffusion / Forge' or 'ComfyUI'.", "The engine status shows Online.", TargetTab: 1), + new(4, "Select Style Preset", "Click a preset chip such as 'Standard Square' or 'Landscape Wallpaper'.", "Resolution and sampling parameters load automatically.", TargetTab: 1), + new(5, "Enter Text Prompt", "Type your descriptive prompt into the Prompt box. Add unwanted features into the Negative Prompt box.", "Prompt text is ready for generation.", TargetTab: 1), + new(6, "Click Generate Image", "Click the '๐ŸŽจ Generate Image' button.", "The stage tracker shows generation progress and the final image appears in the gallery.", TargetTab: 1) }, Notes: new List { @@ -83,17 +179,19 @@ private void InitializeSections() Sections.Add(new DocSection( Id: "text-generation", Title: "How to Run Text LLM Models", + Category: "Workflows", + ReadingTime: "3 min", Icon: "๐Ÿ’ฌ", Summary: "Use this procedure to download and run local Large Language Models with Ollama.", Prerequisite: "Verify that Ollama status shows Online in the top telemetry header.", Steps: new List { - new(1, "Open Models Tab", "Click 'Models' in the top navigation bar.", "The Models management view opens."), - new(2, "Download or Select Model", "Select 'Downloaded & Manage'. Choose a model from your installed list, or pull a new model from the library.", "The model is loaded and ready for inference."), - new(3, "Open Workflows Tab", "Click 'Workflows' in the top navigation bar, then select '๐Ÿ’ฌ Text'.", "The Text LLM generation studio displays."), - new(4, "Configure Context Window", "Adjust target context tokens if necessary. Review the estimated KV cache memory text.", "Target memory stays within available GPU VRAM limits."), - new(5, "Enter Prompt", "Type your question or instructions into the text area.", "The prompt is ready to submit."), - new(6, "Click Generate Text", "Click the '๐Ÿ’ฌ Generate Text' button.", "The model generates the response in real time.") + new(1, "Open Models Tab", "Click 'Models' in the top navigation bar.", "The Models management view opens.", TargetTab: 0), + new(2, "Download or Select Model", "Select 'Downloaded & Manage'. Choose a model from your installed list, or pull a new model from the library.", "The model is loaded and ready for inference.", TargetTab: 0), + new(3, "Open Workflows Tab", "Click 'Workflows' in the top navigation bar, then select '๐Ÿ’ฌ Text'.", "The Text LLM generation studio displays.", TargetTab: 1), + new(4, "Configure Context Window", "Adjust target context tokens if necessary. Review the estimated KV cache memory text.", "Target memory stays within available GPU VRAM limits.", TargetTab: 1), + new(5, "Enter Prompt", "Type your question or instructions into the text area.", "The prompt is ready to submit.", TargetTab: 1), + new(6, "Click Generate Text", "Click the '๐Ÿ’ฌ Generate Text' button.", "The model generates the response in real time.", TargetTab: 1) }, Notes: new List { @@ -105,17 +203,19 @@ private void InitializeSections() Sections.Add(new DocSection( Id: "video-generation", Title: "How to Generate AI Videos", + Category: "Workflows", + ReadingTime: "3 min", Icon: "๐ŸŽฌ", Summary: "Use this procedure to generate AI video clips with Wan 2.2, LTX-2.5, or HunyuanVideo.", Prerequisite: "Verify that at least 8 GB of VRAM is available and ComfyUI is running.", Steps: new List { - new(1, "Open Workflows Tab", "Click 'Workflows' in the top navigation bar.", "The Workflows workspace opens."), - new(2, "Select Video Studio", "Click the '๐ŸŽฌ Video' button in the studio selector.", "The Video generation studio displays."), - new(3, "Select Video Preset", "Click a preset chip (for example, 'Quick 480p Preview' or 'Cinematic HD 720p').", "Workflow model, resolution, and frame counts populate automatically."), - new(4, "Enter Prompt", "Type your scene description into the Prompt box.", "The scene description is validated."), - new(5, "Click Generate Video", "Click the '๐ŸŽฌ Generate Video' button.", "The 4-stage pipeline tracker shows live progress, VRAM load, and denoising steps."), - new(6, "Preview and Export", "When generation finishes, the clip loads in the interactive player. Click 'Export' to save the MP4 file.", "The MP4 video file is saved to your disk.") + new(1, "Open Workflows Tab", "Click 'Workflows' in the top navigation bar.", "The Workflows workspace opens.", TargetTab: 1), + new(2, "Select Video Studio", "Click the '๐ŸŽฌ Video' button in the studio selector.", "The Video generation studio displays.", TargetTab: 1), + new(3, "Select Video Preset", "Click a preset chip (for example, 'Quick 480p Preview' or 'Cinematic HD 720p').", "Workflow model, resolution, and frame counts populate automatically.", TargetTab: 1), + new(4, "Enter Prompt", "Type your scene description into the Prompt box.", "The scene description is validated.", TargetTab: 1), + new(5, "Click Generate Video", "Click the '๐ŸŽฌ Generate Video' button.", "The 4-stage pipeline tracker shows live progress, VRAM load, and denoising steps.", TargetTab: 1), + new(6, "Preview and Export", "When generation finishes, the clip loads in the interactive player. Click 'Export' to save the MP4 file.", "The MP4 video file is saved to your disk.", TargetTab: 1) }, Notes: new List { @@ -131,17 +231,19 @@ private void InitializeSections() Sections.Add(new DocSection( Id: "audio-generation", Title: "How to Synthesize Speech and Audio", + Category: "Workflows", + ReadingTime: "2 min", Icon: "๐ŸŽต", Summary: "Use this procedure to create speech with Kokoro TTS or sound effects with Stable Audio.", Prerequisite: "Ensure the Audio Feature Pack is installed in the Settings tab.", Steps: new List { - new(1, "Open Workflows Tab", "Click 'Workflows' in the top navigation bar.", "The Workflows workspace opens."), - new(2, "Select Audio Studio", "Click the '๐ŸŽต Audio' button in the studio selector.", "The Audio generation studio displays."), - new(3, "Choose Workflow", "Select 'Kokoro TTS' for speech narration or 'Stable Audio Open' for sound effects.", "Controls update with duration, seed, and voice parameters."), - new(4, "Enter Audio Prompt", "Type the speech text or sound effect description into the prompt box.", "The input text is ready for synthesis."), - new(5, "Queue Audio Generation", "Click 'Queue Audio Generation Workflow'.", "The audio stage tracker monitors generation and the waveform visualizer activates."), - new(6, "Listen and Play", "Click the Play button in the player bar to listen to the synthesized audio.", "Audio plays through your default speaker output.") + new(1, "Open Workflows Tab", "Click 'Workflows' in the top navigation bar.", "The Workflows workspace opens.", TargetTab: 1), + new(2, "Select Audio Studio", "Click the '๐ŸŽต Audio' button in the studio selector.", "The Audio generation studio displays.", TargetTab: 1), + new(3, "Choose Workflow", "Select 'Kokoro TTS' for speech narration or 'Stable Audio Open' for sound effects.", "Controls update with duration, seed, and voice parameters.", TargetTab: 1), + new(4, "Enter Audio Prompt", "Type the speech text or sound effect description into the prompt box.", "The input text is ready for synthesis.", TargetTab: 1), + new(5, "Queue Audio Generation", "Click 'Queue Audio Generation Workflow'.", "The audio stage tracker monitors generation and the waveform visualizer activates.", TargetTab: 1), + new(6, "Listen and Play", "Click the Play button in the player bar to listen to the synthesized audio.", "Audio plays through your default speaker output.", TargetTab: 1) } )); @@ -149,17 +251,19 @@ private void InitializeSections() Sections.Add(new DocSection( Id: "mesh-generation", Title: "How to Generate 3D Meshes", + Category: "Workflows", + ReadingTime: "2 min", Icon: "๐Ÿ“ฆ", Summary: "Use this procedure to convert images or text into 3D mesh files (.GLB).", Prerequisite: "Ensure ComfyUI 3D pack is enabled with at least 12 GB VRAM free.", Steps: new List { - new(1, "Open Workflows Tab", "Click 'Workflows' in the top navigation bar.", "The Workflows workspace opens."), - new(2, "Select 3D Mesh Studio", "Click the '๐Ÿ“ฆ 3D' button in the studio selector.", "The 3D studio controls display."), - new(3, "Select Mode", "Choose 'Text-to-3D' or 'Image-to-3D'.", "Input parameters load for the selected mode."), - new(4, "Enter Prompt or Image", "Type the object description or supply an input image.", "Input data is staged for 3D reconstruction."), - new(5, "Click Generate 3D Mesh", "Click the '๐Ÿ“ฆ Generate 3D Mesh' button.", "The system runs sparse-structure and texture generation passes."), - new(6, "Preview and Download", "Inspect the rendered model and click 'Download GLB'.", "The 3D GLB file is ready for import into Blender or 3D engines.") + new(1, "Open Workflows Tab", "Click 'Workflows' in the top navigation bar.", "The Workflows workspace opens.", TargetTab: 1), + new(2, "Select 3D Mesh Studio", "Click the '๐Ÿ“ฆ 3D' button in the studio selector.", "The 3D studio controls display.", TargetTab: 1), + new(3, "Select Mode", "Choose 'Text-to-3D' or 'Image-to-3D'.", "Input parameters load for the selected mode.", TargetTab: 1), + new(4, "Enter Prompt or Image", "Type the object description or supply an input image.", "Input data is staged for 3D reconstruction.", TargetTab: 1), + new(5, "Click Generate 3D Mesh", "Click the '๐Ÿ“ฆ Generate 3D Mesh' button.", "The system runs sparse-structure and texture generation passes.", TargetTab: 1), + new(6, "Preview and Download", "Inspect the rendered model and click 'Download GLB'.", "The 3D GLB file is ready for import into Blender or 3D engines.", TargetTab: 1) } )); @@ -167,16 +271,18 @@ private void InitializeSections() Sections.Add(new DocSection( Id: "models-hub", Title: "How to Download and Manage Models", + Category: "Models", + ReadingTime: "4 min", Icon: "๐ŸŒ", Summary: "Use this procedure to find, evaluate, and download models from Hugging Face and CivitAI.", Prerequisite: "Ensure an active internet connection is available.", Steps: new List { - new(1, "Open Models Tab", "Click 'Models' in the top navigation bar.", "The Models hub opens."), - new(2, "Select Hub", "Select '๐Ÿค— Hugging Face Hub' for GGUF/multimodal models, or '๐ŸŽจ CivitAI Hub' for SD checkpoints and LoRAs.", "The corresponding search view displays."), - new(3, "Search or Click Suggestion", "Type keywords into the search box or click a suggestion chip (such as 'SDXL' or 'Photorealistic').", "The loading indicator displays while searching."), - new(4, "Verify Hardware Fit", "Inspect the green, yellow, or red compatibility badge on the model card.", "The badge confirms whether the model fits into your GPU VRAM."), - new(5, "Click Download", "Click 'Download' on the desired model card.", "A download toast notification confirms the operation and progress begins.") + new(1, "Open Models Tab", "Click 'Models' in the top navigation bar.", "The Models hub opens.", TargetTab: 0), + new(2, "Select Hub", "Select '๐Ÿค— Hugging Face Hub' for GGUF/multimodal models, or '๐ŸŽจ CivitAI Hub' for SD checkpoints and LoRAs.", "The corresponding search view displays.", TargetTab: 0), + new(3, "Search or Click Suggestion", "Type keywords into the search box or click a suggestion chip (such as 'SDXL' or 'Photorealistic').", "The loading indicator displays while searching.", TargetTab: 0), + new(4, "Verify Hardware Fit", "Inspect the green, yellow, or red compatibility badge on the model card.", "The badge confirms whether the model fits into your GPU VRAM.", TargetTab: 0), + new(5, "Click Download", "Click 'Download' on the desired model card.", "A download toast notification confirms the operation and progress begins.", TargetTab: 0) } )); @@ -184,15 +290,17 @@ private void InitializeSections() Sections.Add(new DocSection( Id: "can-i-run-it", Title: "How to Check Hardware Compatibility", + Category: "Tools", + ReadingTime: "1 min", Icon: "โšก", Summary: "Use this procedure to verify whether an AI model fits into your GPU memory before downloading.", Prerequisite: "Check that GPU memory telemetry is visible in the top header.", Steps: new List { - new(1, "Open Can I Run It Tab", "Click 'โšก Can I Run It' in the top navigation bar.", "The hardware fit calculator view opens."), - new(2, "Select Modality", "Choose LLM, Image, Video, Audio, or 3D.", "Parameters for the chosen modality appear."), - new(3, "Enter Model Size", "Enter the model name, size in GB, or select a known preset.", "The system computes VRAM and system RAM requirements."), - new(4, "Read Verdict Banner", "Review the verdict: Full VRAM (Fastest), Partial Offload (Usable), CPU Only (Slow), or Out of Memory (Incompatible).", "You know if the model will run smoothly without system crashes.") + new(1, "Open Can I Run It Tab", "Click 'โšก Can I Run It' in the top navigation bar.", "The hardware fit calculator view opens.", TargetTab: 2), + new(2, "Select Modality", "Choose LLM, Image, Video, Audio, or 3D.", "Parameters for the chosen modality appear.", TargetTab: 2), + new(3, "Enter Model Size", "Enter the model name, size in GB, or select a known preset.", "The system computes VRAM and system RAM requirements.", TargetTab: 2), + new(4, "Read Verdict Banner", "Review the verdict: Full VRAM (Fastest), Partial Offload (Usable), CPU Only (Slow), or Out of Memory (Incompatible).", "You know if the model will run smoothly without system crashes.", TargetTab: 2) } )); @@ -200,15 +308,17 @@ private void InitializeSections() Sections.Add(new DocSection( Id: "troubleshooting", Title: "System Health & Troubleshooting", + Category: "Maintenance", + ReadingTime: "2 min", Icon: "๐Ÿ› ๏ธ", Summary: "Use this procedure to identify and fix common server or engine connection issues.", Prerequisite: "Check the service status pills in the top header.", Steps: new List { - new(1, "Check Service Badges", "Inspect the Ollama, Forge, and ComfyUI status pills in the header.", "Green indicates Online. Red indicates Offline. Yellow indicates Busy."), - new(2, "Start an Offline Engine", "If an engine shows Offline, open the Workflows tab and click 'Toggle Engine' or click 'Refresh' in the header.", "The engine starts and the pill turns green."), - new(3, "Clear GPU Memory", "If a model fails with Out Of Memory, click 'Refresh' or unload unused models in the Models tab.", "GPU VRAM is freed for the next generation."), - new(4, "Verify Tool Discovery", "Open Settings and review the Tool Discovery section if an engine fails to launch.", "Paths to Python, Git, and engines are validated.") + new(1, "Check Service Badges", "Inspect the Ollama, Forge, and ComfyUI status pills in the header.", "Green indicates Online. Red indicates Offline. Yellow indicates Busy.", TargetTab: 5), + new(2, "Start an Offline Engine", "If an engine shows Offline, open the Workflows tab and click 'Toggle Engine' or click 'Refresh' in the header.", "The engine starts and the pill turns green.", TargetTab: 1), + new(3, "Clear GPU Memory", "If a model fails with Out Of Memory, click 'Refresh' or unload unused models in the Models tab.", "GPU VRAM is freed for the next generation.", TargetTab: 0), + new(4, "Verify Tool Discovery", "Open Settings and review the Tool Discovery section if an engine fails to launch.", "Paths to Python, Git, and engines are validated.", TargetTab: 5) } )); } diff --git a/LocalLLMServerManager.Shared/ViewModels/HuggingFaceSearchViewModel.cs b/LocalLLMServerManager.Shared/ViewModels/HuggingFaceSearchViewModel.cs index 3386cd1..1deda8b 100644 --- a/LocalLLMServerManager.Shared/ViewModels/HuggingFaceSearchViewModel.cs +++ b/LocalLLMServerManager.Shared/ViewModels/HuggingFaceSearchViewModel.cs @@ -35,6 +35,25 @@ public partial class HuggingFaceSearchViewModel : ObservableObject public ObservableCollection SelectedInputModalities { get; } = new() { "Text" }; public ObservableCollection SelectedOutputModalities { get; } = new() { "Text" }; + [ObservableProperty] private string? _activePreset = "LLM"; + + public bool IsPresetMultimodal => string.Equals(ActivePreset, "Multimodal", StringComparison.OrdinalIgnoreCase); + public bool IsPresetLlm => string.Equals(ActivePreset, "LLM", StringComparison.OrdinalIgnoreCase); + public bool IsPresetImage => string.Equals(ActivePreset, "Image", StringComparison.OrdinalIgnoreCase); + public bool IsPresetVideo => string.Equals(ActivePreset, "Video", StringComparison.OrdinalIgnoreCase); + public bool IsPresetAudio => string.Equals(ActivePreset, "Audio", StringComparison.OrdinalIgnoreCase); + public bool IsPreset3D => string.Equals(ActivePreset, "3D", StringComparison.OrdinalIgnoreCase); + + partial void OnActivePresetChanged(string? value) + { + OnPropertyChanged(nameof(IsPresetMultimodal)); + OnPropertyChanged(nameof(IsPresetLlm)); + OnPropertyChanged(nameof(IsPresetImage)); + OnPropertyChanged(nameof(IsPresetVideo)); + OnPropertyChanged(nameof(IsPresetAudio)); + OnPropertyChanged(nameof(IsPreset3D)); + } + [ObservableProperty] private bool _isInputTextActive = true; [ObservableProperty] private bool _isInputImageActive = false; [ObservableProperty] private bool _isInputAudioActive = false; @@ -71,6 +90,7 @@ public HuggingFaceSearchViewModel( _canIRunItService = canIRunItService ?? new CanIRunItService(); _telemetryService = telemetryService; HuggingFaceResults.CollectionChanged += (s, e) => ApplyFilter(); + LoadCuratedStarterModels(); } public void UpdateHardwareTelemetry(double totalVramMb, double totalRamMb) @@ -169,6 +189,8 @@ public void ToggleInputModality(string modality) else if (m.Equals("Video", StringComparison.OrdinalIgnoreCase)) IsInputVideoActive = !IsInputVideoActive; + ActivePreset = null; + SelectedPipelineTag = null; SyncInputModalitiesList(); _ = SearchHuggingFaceAsync(); } @@ -188,6 +210,8 @@ public void ToggleOutputModality(string modality) else if (m.Equals("3D", StringComparison.OrdinalIgnoreCase) || m.Equals("ThreeD", StringComparison.OrdinalIgnoreCase)) IsOutputThreeDActive = !IsOutputThreeDActive; + ActivePreset = null; + SelectedPipelineTag = null; SyncOutputModalitiesList(); _ = SearchHuggingFaceAsync(); } @@ -214,9 +238,11 @@ public void SyncOutputModalitiesList() [RelayCommand] public void ApplyPreset(string preset) { + SelectedPipelineTag = null; var p = (preset ?? "").Trim().ToLowerInvariant(); if (p.Contains("multimodal") || p.Contains("vlm") || p.Contains("vision")) { + ActivePreset = "Multimodal"; IsInputTextActive = true; IsInputImageActive = true; IsInputAudioActive = false; @@ -230,6 +256,7 @@ public void ApplyPreset(string preset) } else if (p.Contains("llm") || p.Contains("text")) { + ActivePreset = "LLM"; IsInputTextActive = true; IsInputImageActive = false; IsInputAudioActive = false; @@ -243,6 +270,7 @@ public void ApplyPreset(string preset) } else if (p.Contains("image") || p.Contains("diffusion")) { + ActivePreset = "Image"; IsInputTextActive = true; IsInputImageActive = false; IsInputAudioActive = false; @@ -256,6 +284,7 @@ public void ApplyPreset(string preset) } else if (p.Contains("video")) { + ActivePreset = "Video"; IsInputTextActive = true; IsInputImageActive = true; IsInputAudioActive = false; @@ -269,6 +298,7 @@ public void ApplyPreset(string preset) } else if (p.Contains("audio") || p.Contains("speech") || p.Contains("tts")) { + ActivePreset = "Audio"; IsInputTextActive = true; IsInputImageActive = false; IsInputAudioActive = true; @@ -282,6 +312,7 @@ public void ApplyPreset(string preset) } else if (p.Contains("3d")) { + ActivePreset = "3D"; IsInputTextActive = true; IsInputImageActive = true; IsInputAudioActive = false; @@ -316,7 +347,11 @@ public static List ResolvePipelineTags(IEnumerable inputs, IEnum { tags.Add("image-to-text"); } - else if (inSet.Contains("Text") && outSet.Contains("Text")) + else if (inSet.Contains("Video") && outSet.Contains("Text")) + { + tags.Add("video-text-to-text"); + } + else if (inSet.Contains("Text") && outSet.Contains("Text") && !inSet.Contains("Audio") && !outSet.Contains("Audio") && !outSet.Contains("Image") && !outSet.Contains("Video") && !outSet.Contains("3D")) { tags.Add("text-generation"); } @@ -334,6 +369,7 @@ public static List ResolvePipelineTags(IEnumerable inputs, IEnum if (outSet.Contains("Video")) { if (inSet.Contains("Image")) tags.Add("image-to-video"); + if (inSet.Contains("Video")) tags.Add("video-to-video"); tags.Add("text-to-video"); } @@ -341,6 +377,7 @@ public static List ResolvePipelineTags(IEnumerable inputs, IEnum { tags.Add("text-to-speech"); tags.Add("text-to-audio"); + if (inSet.Contains("Audio")) tags.Add("audio-to-audio"); } if (inSet.Contains("Audio") && outSet.Contains("Text")) @@ -362,7 +399,11 @@ public static string DetermineModality(string modelName, string? pipelineTag) string tag = (pipelineTag ?? "").ToLowerInvariant(); string name = (modelName ?? "").ToLowerInvariant(); - if (tag.Contains("video") || name.Contains("wan") || name.Contains("ltx") || name.Contains("hunyuanvideo")) + // Check multimodal VLM first so image-to-text / image-text-to-text isn't misclassified as image diffusion + if (tag.Contains("image-text-to-text") || tag.Contains("image-to-text") || tag.Contains("visual-question-answering") || tag.Contains("vlm") || name.Contains("vl-") || name.Contains("-vl")) + return "LLM"; + + if (tag.Contains("video") || name.Contains("wan") || name.Contains("ltx") || name.Contains("hunyuanvideo") || name.Contains("cogvideox")) return "Video"; if (tag.Contains("audio") || tag.Contains("speech") || tag.Contains("tts") || name.Contains("kokoro") || name.Contains("whisper") || name.Contains("xtts")) return "Audio"; @@ -374,6 +415,55 @@ public static string DetermineModality(string modelName, string? pipelineTag) return "LLM"; } + public static List GetCuratedStarterModels() + { + return new List + { + new HuggingFaceRepoItem("Qwen/Qwen2.5-Coder-7B-Instruct-GGUF", "Qwen", 4820, "1,250,000 downloads", "text-generation"), + new HuggingFaceRepoItem("meta-llama/Llama-3.2-3B-Instruct-GGUF", "meta-llama", 3100, "980,000 downloads", "text-generation"), + new HuggingFaceRepoItem("deepseek-ai/DeepSeek-R1-Distill-Qwen-7B-GGUF", "deepseek-ai", 5200, "1,450,000 downloads", "text-generation"), + new HuggingFaceRepoItem("Wan-AI/Wan2.1-T2V-14B", "Wan-AI", 2800, "420,000 downloads", "text-to-video"), + new HuggingFaceRepoItem("hexgrad/Kokoro-82M", "hexgrad", 3900, "680,000 downloads", "text-to-audio"), + new HuggingFaceRepoItem("black-forest-labs/FLUX.1-schnell", "black-forest-labs", 6200, "1,850,000 downloads", "text-to-image") + }; + } + + public void LoadCuratedStarterModels() + { + if (HuggingFaceResults.Count > 0) return; + + foreach (var r in GetCuratedStarterModels()) + { + string modality = DetermineModality(r.Id, r.PipelineTag); + var badge = _canIRunItService.EvaluateQuickFit(r.Id, null, modality, (long)TotalVramMb, (long)TotalRamMb); + HuggingFaceResults.Add(r with { FitBadge = badge }); + } + ApplyFilter(); + } + + public async Task LoadDefaultModelsAsync(string apiBase, HttpClient http) + { + try + { + var results = await _hfSearchService.SearchRepositoriesAsync(apiBase, "", "text-generation", http); + if (results != null && results.Count > 0) + { + HuggingFaceResults.Clear(); + foreach (var r in results) + { + string modality = DetermineModality(r.Id, r.PipelineTag); + var badge = _canIRunItService.EvaluateQuickFit(r.Id, null, modality, (long)TotalVramMb, (long)TotalRamMb); + HuggingFaceResults.Add(r with { FitBadge = badge }); + } + ApplyFilter(); + } + } + catch + { + // Curated starter models are already loaded, retain them + } + } + [RelayCommand] public void NavigateToCanIRunIt(string? modelName) { @@ -446,10 +536,20 @@ public async Task SearchHuggingFaceAsync(string apiBase, HttpClient http) var badge = _canIRunItService.EvaluateQuickFit(r.Id, null, modality, (long)TotalVramMb, (long)TotalRamMb); HuggingFaceResults.Add(r with { FitBadge = badge }); } + + if (results.Count == 0 && string.IsNullOrWhiteSpace(HfSearchQuery)) + { + LoadCuratedStarterModels(); + } + ApplyFilter(); } catch { + if (HuggingFaceResults.Count == 0 && string.IsNullOrWhiteSpace(HfSearchQuery)) + { + LoadCuratedStarterModels(); + } ToastService.Instance.Show("Failed to query Hugging Face Hub.", ToastType.Error); } finally diff --git a/LocalLLMServerManager.Shared/ViewModels/MainViewModel.cs b/LocalLLMServerManager.Shared/ViewModels/MainViewModel.cs index 90dc1db..725cbc4 100644 --- a/LocalLLMServerManager.Shared/ViewModels/MainViewModel.cs +++ b/LocalLLMServerManager.Shared/ViewModels/MainViewModel.cs @@ -158,7 +158,15 @@ public MainViewModel( }; Settings = new SettingsViewModel(PresetService) { ApiBase = ApiBase }; Audio = new AudioStudioViewModel(PresetService, _canIRunItService) { ApiBase = ApiBase }; - Assistant = new AiAssistantViewModel(assistantService, settingsService, promptService, httpClient ?? Http); + Assistant = new AiAssistantViewModel( + assistantService, + settingsService, + promptService ?? new PromptManagementService(), + httpClient ?? Http) + { + ApiBase = ApiBase + }; + Documentation.OnNavigateToTabRequested = tab => SelectedTabIndex = tab; LoadStudioPresets(); RecalculateVideoHardwareFit(); @@ -166,6 +174,8 @@ public MainViewModel( _ = RefreshStatusAsync(); _ = Ollama.LoadInstalledModelsAsync(ApiBase, Http); + _ = HuggingFace.LoadDefaultModelsAsync(ApiBase, Http); + _ = Civitai.LoadDefaultModelsAsync(ApiBase, Http); _ = Audio.LoadAudioWorkflowsAsync(ApiBase, Http); _ = Audio.LoadAudioFilesAsync(ApiBase, Http); _ = LoadSettingsAsync(); @@ -185,7 +195,23 @@ private static string GetDefaultApiBase() return "http://127.0.0.1:5246"; } - public string ApiBase { get; set; } = GetDefaultApiBase(); + private string _apiBase = GetDefaultApiBase(); + public string ApiBase + { + get => _apiBase; + set + { + _apiBase = value; + if (Telemetry != null) Telemetry.ApiBase = value; + if (HardwareFit != null) HardwareFit.ApiBase = value; + if (Ollama != null) Ollama.ApiBase = value; + if (HuggingFace != null) HuggingFace.ApiBase = value; + if (Civitai != null) Civitai.ApiBase = value; + if (Settings != null) Settings.ApiBase = value; + if (Audio != null) Audio.ApiBase = value; + if (Assistant != null) Assistant.ApiBase = value; + } + } // Backward-compatible properties forwarding to sub-ViewModels public string GpuName { get => Telemetry.GpuName; set => Telemetry.GpuName = value; } @@ -233,6 +259,46 @@ private static string GetDefaultApiBase() public string SelectedTheme { get => Settings.SelectedTheme; set => Settings.SelectedTheme = value; } public System.Collections.Generic.IReadOnlyList AvailableThemes => Settings.AvailableThemes; + // Service Confirmation Modal Properties + [ObservableProperty] + private bool _isServiceConfirmModalOpen = false; + + [ObservableProperty] + private string _serviceConfirmMessage = ""; + + private string _serviceConfirmTarget = ""; + + [RelayCommand] + public void ToggleServiceInteractive(string serviceName) + { + _serviceConfirmTarget = serviceName.ToLowerInvariant(); + bool isOnline = false; + + if (_serviceConfirmTarget == "ollama") isOnline = Telemetry.IsOllamaOnline; + else if (_serviceConfirmTarget == "forge") isOnline = Telemetry.IsForgeOnline; + else if (_serviceConfirmTarget == "comfy") isOnline = Telemetry.IsComfyOnline; + + string action = isOnline ? "stop" : "start"; + ServiceConfirmMessage = $"Are you sure you want to {action} the {serviceName} engine?"; + IsServiceConfirmModalOpen = true; + } + + [RelayCommand] + public async Task ExecuteServiceConfirmAsync() + { + IsServiceConfirmModalOpen = false; + if (!string.IsNullOrEmpty(_serviceConfirmTarget)) + { + await ToggleEngineAsync(_serviceConfirmTarget); + } + } + + [RelayCommand] + public void CancelServiceConfirm() + { + IsServiceConfirmModalOpen = false; + } + // Studio Preset Collections & Selections public ObservableCollection VideoPresets { get; } = new(); public ObservableCollection VideoStarterPrompts { get; } = new(); @@ -784,23 +850,78 @@ public async Task LaunchTestFlightAsync(object? modalityParam = null) IsTestFlightRunning = true; IsTestFlightSuccess = false; TestFlightHasError = false; - TestFlightProgress = 15; - TestFlightStatusMessage = "1/4 Checking GPU clearance & allocating buffers..."; - await Task.Delay(20); + TestFlightProgress = 10; + TestFlightStatusMessage = "1/3 Checking service health & allocating buffers..."; - TestFlightProgress = 50; - TestFlightStatusMessage = $"2/4 Running test inference for {TestFlightModality}..."; - await Task.Delay(20); + try + { + using var cts = new System.Threading.CancellationTokenSource(TimeSpan.FromSeconds(30)); + + object reqObj = new { }; + string endpoint = ""; + string engineStr = TestFlightModality.ToString(); - TestFlightProgress = 85; - TestFlightStatusMessage = "3/4 Verifying pipeline & memory deallocation..."; - await Task.Delay(20); + if (TestFlightModality == StudioModality.Text) + { + endpoint = $"{ApiBase}/api/generate"; + reqObj = new { prompt = SelectedTestFlightStarterPrompt?.SamplePrompt ?? "Hello", model = "llama3.2:latest" }; + } + else if (TestFlightModality == StudioModality.Image) + { + endpoint = "http://127.0.0.1:7860/sdapi/v1/txt2img"; + reqObj = new { prompt = SelectedTestFlightStarterPrompt?.SamplePrompt ?? "test", steps = 1 }; + } + else if (TestFlightModality == StudioModality.Video) + { + endpoint = "http://127.0.0.1:8188/prompt"; + reqObj = new { prompt = new { } }; + } + else if (TestFlightModality == StudioModality.Audio) + { + endpoint = "http://127.0.0.1:8880/v1/audio/speech"; + reqObj = new { input = SelectedTestFlightStarterPrompt?.SamplePrompt ?? "test", voice = "af_heart" }; + } - TestFlightProgress = 100; - IsTestFlightRunning = false; - IsTestFlightSuccess = true; - TestFlightResultBannerText = $"๐ŸŽ‰ {TestFlightModality} Test Flight Succeeded! Your local engine and GPU are verified and ready for studio generation."; - TestFlightStatusMessage = "Test flight completed successfully!"; + TestFlightProgress = 40; + TestFlightStatusMessage = $"2/3 Sending real HTTP request to {engineStr} backend..."; + + var content = new StringContent(JsonSerializer.Serialize(reqObj), System.Text.Encoding.UTF8, "application/json"); + var response = await Http.PostAsync(endpoint, content, cts.Token); + + TestFlightProgress = 80; + TestFlightStatusMessage = "3/3 Verifying pipeline & HTTP response..."; + + if (response.IsSuccessStatusCode) + { + TestFlightProgress = 100; + IsTestFlightSuccess = true; + TestFlightResultBannerText = $"๐ŸŽ‰ {engineStr} Test Flight Succeeded! Your local engine and GPU are verified and ready for studio generation."; + TestFlightStatusMessage = "Test flight completed successfully with real inference payload!"; + } + else + { + var errJson = await response.Content.ReadAsStringAsync(); + throw new Exception($"HTTP {(int)response.StatusCode}: {errJson}"); + } + } + catch (TaskCanceledException) + { + TestFlightHasError = true; + IsTestFlightSuccess = false; + TestFlightErrorMessage = "Test flight timed out after 30 seconds. The engine might be offline or hung."; + TestFlightStatusMessage = "Timeout connecting to local engine."; + } + catch (Exception ex) + { + TestFlightHasError = true; + IsTestFlightSuccess = false; + TestFlightErrorMessage = $"Engine failure or unavailable: {ex.Message}"; + TestFlightStatusMessage = "Test flight encountered a real error."; + } + finally + { + IsTestFlightRunning = false; + } } private async Task StartBackgroundPollingAsync() @@ -871,7 +992,13 @@ public void NavigateToModelsTab() [RelayCommand] public void NavigateToAssistantTab() { - SelectedTabIndex = 3; + Assistant.RequestPopOut(); + } + + [RelayCommand] + public void NavigateToDocumentationTab() + { + Documentation.PopOutNativeWindow(); } [RelayCommand] diff --git a/LocalLLMServerManager.Shared/ViewModels/SettingsViewModel.cs b/LocalLLMServerManager.Shared/ViewModels/SettingsViewModel.cs index eaf9d67..175e90d 100644 --- a/LocalLLMServerManager.Shared/ViewModels/SettingsViewModel.cs +++ b/LocalLLMServerManager.Shared/ViewModels/SettingsViewModel.cs @@ -23,7 +23,18 @@ public partial class SettingsViewModel : ObservableObject public ObservableCollection AllPresets { get; } = new(); public ObservableCollection FilteredPresets { get; } = new(); - [ObservableProperty] private string _selectedPresetModalityFilter = "All"; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsFilterAllActive))] + [NotifyPropertyChangedFor(nameof(IsFilterVideoActive))] + [NotifyPropertyChangedFor(nameof(IsFilterImageActive))] + [NotifyPropertyChangedFor(nameof(IsFilterAudioActive))] + private string _selectedPresetModalityFilter = "All"; + + public bool IsFilterAllActive => string.IsNullOrWhiteSpace(SelectedPresetModalityFilter) || SelectedPresetModalityFilter.Equals("All", StringComparison.OrdinalIgnoreCase); + public bool IsFilterVideoActive => SelectedPresetModalityFilter.Equals("Video", StringComparison.OrdinalIgnoreCase); + public bool IsFilterImageActive => SelectedPresetModalityFilter.Equals("Image", StringComparison.OrdinalIgnoreCase); + public bool IsFilterAudioActive => SelectedPresetModalityFilter.Equals("Audio", StringComparison.OrdinalIgnoreCase); + [ObservableProperty] private string _presetsJson = ""; [ObservableProperty] private string _forgeModelsPath = ""; @@ -43,11 +54,81 @@ public partial class SettingsViewModel : ObservableObject [ObservableProperty] private string _audioEngineUrl = "http://127.0.0.1:8880"; [ObservableProperty] private string _preferredAudioVoice = "af_heart"; + [ObservableProperty] private string _aiAssistantEndpoint = "http://127.0.0.1:4000/v1"; + [ObservableProperty] private string _aiAssistantApiKey = ""; + [ObservableProperty] private string _aiAssistantModel = "google/gemini-2.5-flash"; + [ObservableProperty] private bool _isApiKeyVisible = false; + [ObservableProperty] private IStorageProvider? _storageProvider; [ObservableProperty] private bool _isAutoDetecting; // Real-time status indicators [ObservableProperty] private string _comfyUiExecutableStatus = "โš ๏ธ Missing"; + + public string ApiKeyStatusText => string.IsNullOrWhiteSpace(AiAssistantApiKey) + ? "โš ๏ธ Not Configured" + : $"โ— Configured (โ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ข{GetApiKeySuffix()})"; + + public string ApiKeyStatusColor => string.IsNullOrWhiteSpace(AiAssistantApiKey) ? "#F59E0B" : "#22C55E"; + + private string GetApiKeySuffix() + { + if (string.IsNullOrWhiteSpace(AiAssistantApiKey)) return ""; + return AiAssistantApiKey.Length >= 4 ? AiAssistantApiKey.Substring(AiAssistantApiKey.Length - 4) : AiAssistantApiKey; + } + + partial void OnAiAssistantApiKeyChanged(string value) + { + OnPropertyChanged(nameof(ApiKeyStatusText)); + OnPropertyChanged(nameof(ApiKeyStatusColor)); + } + + [RelayCommand] + public void ToggleApiKeyVisibility() + { + IsApiKeyVisible = !IsApiKeyVisible; + } + + [RelayCommand] + public void ClearApiKey() + { + AiAssistantApiKey = ""; + } + + public static string GetLocalIPv4Address() + { + try + { + foreach (var netInterface in System.Net.NetworkInformation.NetworkInterface.GetAllNetworkInterfaces()) + { + if (netInterface.OperationalStatus == System.Net.NetworkInformation.OperationalStatus.Up && + netInterface.NetworkInterfaceType != System.Net.NetworkInformation.NetworkInterfaceType.Loopback && + !netInterface.Description.Contains("Virtual", StringComparison.OrdinalIgnoreCase) && + !netInterface.Description.Contains("Tunnel", StringComparison.OrdinalIgnoreCase) && + !netInterface.Description.Contains("Hyper-V", StringComparison.OrdinalIgnoreCase)) + { + var props = netInterface.GetIPProperties(); + foreach (var ip in props.UnicastAddresses) + { + if (ip.Address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork) + { + var ipString = ip.Address.ToString(); + if (ipString.StartsWith("192.168.") || ipString.StartsWith("10.")) + return ipString; + if (ipString.StartsWith("172.")) + { + var bytes = ip.Address.GetAddressBytes(); + if (bytes[1] >= 16 && bytes[1] <= 31) + return ipString; + } + } + } + } + } + } + catch { } + return "127.0.0.1"; + } [ObservableProperty] private string _forgeExecutableStatus = "โš ๏ธ Missing"; [ObservableProperty] private string _ollamaStatus = "โš ๏ธ Missing"; [ObservableProperty] private string _forgeModelsStatus = "โš ๏ธ Missing"; @@ -769,6 +850,11 @@ public async Task LoadSettingsAsync(string apiBase, HttpClient http) ForgeExecutablePath = settings.ForgeExecutablePath ?? ""; OllamaExecutablePath = settings.OllamaExecutablePath ?? "ollama"; LanAccessUrl = settings.LanAccessUrl ?? "http://127.0.0.1:5246"; + if (string.IsNullOrWhiteSpace(LanAccessUrl) || LanAccessUrl.Contains("127.0.0.1") || LanAccessUrl.Contains("localhost") || LanAccessUrl.Contains("0.0.0.0")) + { + LanAccessUrl = $"http://{GetLocalIPv4Address()}:5246"; + } + SelectedThemeStyle = settings.SelectedThemeStyle ?? "semi"; ServiceName = settings.ServiceName ?? "LocalLLMServerManager"; PublishOutputPath = settings.PublishOutputPath ?? "C:\\LocalLLMServerManager"; @@ -776,6 +862,10 @@ public async Task LoadSettingsAsync(string apiBase, HttpClient http) AudioEngineUrl = settings.AudioEngineUrl ?? "http://127.0.0.1:8880"; PreferredAudioVoice = settings.PreferredAudioVoice ?? "af_heart"; + AiAssistantEndpoint = settings.AiAssistantEndpoint ?? "http://127.0.0.1:4000/v1"; + AiAssistantApiKey = settings.AiAssistantApiKey ?? ""; + AiAssistantModel = settings.AiAssistantModel ?? "google/gemini-2.5-flash"; + if (settings.CustomPresets != null && settings.CustomPresets.Count > 0) { foreach (var preset in settings.CustomPresets) @@ -832,7 +922,10 @@ public async Task SaveSettingsAsync(string apiBase, HttpClient http) AudioEngineExecutablePath: this.AudioEngineExecutablePath, AudioEngineUrl: this.AudioEngineUrl, PreferredAudioVoice: this.PreferredAudioVoice, - CustomPresets: customPresets + CustomPresets: customPresets, + AiAssistantEndpoint: this.AiAssistantEndpoint, + AiAssistantApiKey: this.AiAssistantApiKey, + AiAssistantModel: this.AiAssistantModel ); var content = new StringContent( diff --git a/LocalLLMServerManager.Shared/ViewModels/TelemetryViewModel.cs b/LocalLLMServerManager.Shared/ViewModels/TelemetryViewModel.cs index 866e66d..257545a 100644 --- a/LocalLLMServerManager.Shared/ViewModels/TelemetryViewModel.cs +++ b/LocalLLMServerManager.Shared/ViewModels/TelemetryViewModel.cs @@ -43,13 +43,65 @@ public partial class TelemetryViewModel : ObservableObject [ObservableProperty] private string _serviceModeText = "Connecting..."; [ObservableProperty] private bool _isServiceRunning = false; - [ObservableProperty] private string _apiBase = OperatingSystem.IsBrowser() ? "" : "http://127.0.0.1:5246"; + [ObservableProperty] private string _apiBase = OperatingSystem.IsBrowser() ? "" : $"http://{SettingsViewModel.GetLocalIPv4Address()}:5246"; + + [ObservableProperty] private bool _isManageServiceModalOpen; + [ObservableProperty] private string _manageServicePrompt = ""; + [ObservableProperty] private string _manageServiceTarget = ""; + [ObservableProperty] private bool _manageServiceIsStart; public TelemetryViewModel(ITelemetryService telemetryService) { _telemetryService = telemetryService; } + [RelayCommand] + public void ManageService(string serviceName) + { + ManageServiceTarget = serviceName; + bool isOnline = false; + if (serviceName == "Ollama") isOnline = IsOllamaOnline; + else if (serviceName == "Forge SD") isOnline = IsForgeOnline; + else if (serviceName == "ComfyUI") isOnline = IsComfyOnline; + + ManageServiceIsStart = !isOnline; + string action = ManageServiceIsStart ? "start" : "stop"; + ManageServicePrompt = $"Are you sure you want to {action} the {serviceName} service?"; + IsManageServiceModalOpen = true; + } + + [RelayCommand] + public void CancelManageService() + { + IsManageServiceModalOpen = false; + } + + [RelayCommand] + public async Task ConfirmManageServiceAsync() + { + IsManageServiceModalOpen = false; + try + { + var actionEndpoint = ManageServiceIsStart ? "/api/comfy/start" : "/api/comfy/stop"; + var engineName = ManageServiceTarget.ToLower().Replace(" sd", ""); + if (ManageServiceTarget == "ComfyUI") engineName = "comfy"; + + var req = new { engine = engineName }; + var content = new StringContent( + System.Text.Json.JsonSerializer.Serialize(req), + System.Text.Encoding.UTF8, + "application/json" + ); + using var http = HttpHelper.CreateClient(ApiBase); + await http.PostAsync($"{ApiBase}{actionEndpoint}", content); + } + catch { } + finally + { + await RefreshStatusAsync(); + } + } + [RelayCommand] public async Task RefreshStatusAsync() { diff --git a/LocalLLMServerManager.Shared/Views/AiAssistWindow.axaml b/LocalLLMServerManager.Shared/Views/AiAssistWindow.axaml new file mode 100644 index 0000000..b275a66 --- /dev/null +++ b/LocalLLMServerManager.Shared/Views/AiAssistWindow.axaml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LocalLLMServerManager.Shared/Views/Controls/CivitaiTabControl.axaml b/LocalLLMServerManager.Shared/Views/Controls/CivitaiTabControl.axaml index d930d6b..8f6c47b 100644 --- a/LocalLLMServerManager.Shared/Views/Controls/CivitaiTabControl.axaml +++ b/LocalLLMServerManager.Shared/Views/Controls/CivitaiTabControl.axaml @@ -4,7 +4,73 @@ x:Class="LocalLLMServerManager.Shared.Views.Controls.CivitaiTabControl" x:DataType="vm:CivitaiSearchViewModel"> - + + + + + + + + + + + + + + + + + + + @@ -28,39 +94,84 @@ - - - - - + + + + + + + + + - - - + + + + + + + + - + - + @@ -73,7 +184,7 @@ - + diff --git a/LocalLLMServerManager.Shared/Views/Controls/DocumentationFloatingOverlay.axaml b/LocalLLMServerManager.Shared/Views/Controls/DocumentationFloatingOverlay.axaml new file mode 100644 index 0000000..465b83c --- /dev/null +++ b/LocalLLMServerManager.Shared/Views/Controls/DocumentationFloatingOverlay.axaml @@ -0,0 +1,167 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LocalLLMServerManager.Shared/Views/Controls/DocumentationFloatingOverlay.axaml.cs b/LocalLLMServerManager.Shared/Views/Controls/DocumentationFloatingOverlay.axaml.cs new file mode 100644 index 0000000..ee8d6c1 --- /dev/null +++ b/LocalLLMServerManager.Shared/Views/Controls/DocumentationFloatingOverlay.axaml.cs @@ -0,0 +1,76 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Markup.Xaml; +using LocalLLMServerManager.Shared.ViewModels; + +namespace LocalLLMServerManager.Shared.Views.Controls; + +public partial class DocumentationFloatingOverlay : UserControl +{ + private Point _dragStartPoint; + private bool _isDragging = false; + + public DocumentationFloatingOverlay() + { + InitializeComponent(); + + var pillHandle = this.FindControl("PillDragHandle"); + if (pillHandle != null) + { + pillHandle.PointerPressed += OnDragHandlePointerPressed; + pillHandle.PointerMoved += OnDragHandlePointerMoved; + pillHandle.PointerReleased += OnDragHandlePointerReleased; + } + + var cardHandle = this.FindControl("CardDragHandle"); + if (cardHandle != null) + { + cardHandle.PointerPressed += OnDragHandlePointerPressed; + cardHandle.PointerMoved += OnDragHandlePointerMoved; + cardHandle.PointerReleased += OnDragHandlePointerReleased; + } + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } + + private void OnDragHandlePointerPressed(object? sender, PointerPressedEventArgs e) + { + if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) + { + _isDragging = true; + _dragStartPoint = e.GetPosition(this.Parent as Visual); + e.Pointer.Capture(sender as IInputElement); + e.Handled = true; + } + } + + private void OnDragHandlePointerMoved(object? sender, PointerEventArgs e) + { + if (_isDragging && DataContext is DocumentationViewModel vm && this.Parent is Visual parentVisual) + { + var currentPos = e.GetPosition(parentVisual); + var deltaX = currentPos.X - _dragStartPoint.X; + var deltaY = currentPos.Y - _dragStartPoint.Y; + + vm.OverlayX = Math.Max(10, vm.OverlayX + deltaX); + vm.OverlayY = Math.Max(10, vm.OverlayY + deltaY); + + _dragStartPoint = currentPos; + e.Handled = true; + } + } + + private void OnDragHandlePointerReleased(object? sender, PointerReleasedEventArgs e) + { + if (_isDragging) + { + _isDragging = false; + e.Pointer.Capture(null); + e.Handled = true; + } + } +} diff --git a/LocalLLMServerManager.Shared/Views/Controls/DocumentationTabControl.axaml b/LocalLLMServerManager.Shared/Views/Controls/DocumentationTabControl.axaml index 090aa1b..81b83d0 100644 --- a/LocalLLMServerManager.Shared/Views/Controls/DocumentationTabControl.axaml +++ b/LocalLLMServerManager.Shared/Views/Controls/DocumentationTabControl.axaml @@ -7,13 +7,13 @@ - - + + - + @@ -50,11 +50,28 @@ - + + + + + + + + + + + + + - - - diff --git a/LocalLLMServerManager.Shared/Views/Controls/EngineStudioTabControl.axaml b/LocalLLMServerManager.Shared/Views/Controls/EngineStudioTabControl.axaml index 25c79b9..ec51ff6 100644 --- a/LocalLLMServerManager.Shared/Views/Controls/EngineStudioTabControl.axaml +++ b/LocalLLMServerManager.Shared/Views/Controls/EngineStudioTabControl.axaml @@ -35,14 +35,16 @@ - + + + + + - + - - + + + - - + + + + - - - - diff --git a/LocalLLMServerManager.Shared/Views/Controls/ModelsTabControl.axaml b/LocalLLMServerManager.Shared/Views/Controls/ModelsTabControl.axaml index c7f7259..1794bb7 100644 --- a/LocalLLMServerManager.Shared/Views/Controls/ModelsTabControl.axaml +++ b/LocalLLMServerManager.Shared/Views/Controls/ModelsTabControl.axaml @@ -8,17 +8,35 @@ - + + + + + + + - + + + + + + + - + + + + + + + diff --git a/LocalLLMServerManager.Shared/Views/Controls/OllamaModelsTabControl.axaml b/LocalLLMServerManager.Shared/Views/Controls/OllamaModelsTabControl.axaml index 0554f9a..ecb211c 100644 --- a/LocalLLMServerManager.Shared/Views/Controls/OllamaModelsTabControl.axaml +++ b/LocalLLMServerManager.Shared/Views/Controls/OllamaModelsTabControl.axaml @@ -4,6 +4,30 @@ x:Class="LocalLLMServerManager.Shared.Views.Controls.OllamaModelsTabControl" x:DataType="vm:OllamaLibraryViewModel"> + + + + + + + + @@ -20,17 +44,33 @@ - - - - diff --git a/LocalLLMServerManager.Shared/Views/Controls/SettingsTabControl.axaml b/LocalLLMServerManager.Shared/Views/Controls/SettingsTabControl.axaml index a14c9fc..af6bc48 100644 --- a/LocalLLMServerManager.Shared/Views/Controls/SettingsTabControl.axaml +++ b/LocalLLMServerManager.Shared/Views/Controls/SettingsTabControl.axaml @@ -5,6 +5,33 @@ x:Class="LocalLLMServerManager.Shared.Views.Controls.SettingsTabControl" x:DataType="vm:SettingsViewModel"> + + + + + + + @@ -116,10 +143,30 @@ - + + + @@ -328,6 +375,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - + + + + + + + + + + + + + - + + + + + + + - + + + + + + + - + + + + + + + - - - - - - - - - - - - + + + + + + + + - + @@ -69,6 +109,20 @@ + + + + + + + +