diff --git a/.gitignore b/.gitignore
index f25c541..65fb6cb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -496,3 +496,6 @@ $RECYCLE.BIN/
# Vim temporary swap files
*.swp
+
+# Playwright video traces
+docs/images/videos/
diff --git a/Endpoints/EngineEndpoints.cs b/Endpoints/EngineEndpoints.cs
index 2171859..dbdee43 100644
--- a/Endpoints/EngineEndpoints.cs
+++ b/Endpoints/EngineEndpoints.cs
@@ -36,11 +36,11 @@ public static void MapEngineEndpoints(this WebApplication app)
{
var logger = loggerFactory.CreateLogger("EngineEndpoints");
var settings = settingsService.LoadSettings();
- var execPath = string.IsNullOrWhiteSpace(settings.ComfyUiExecutablePath) ? @"C:\AI\ComfyUI\run_nvidia_gpu.bat" : settings.ComfyUiExecutablePath;
+ var execPath = AiEngineManager.ResolveComfyExecutable(settings.ComfyUiExecutablePath);
- if (!Program.IsSafePath(execPath) || !System.IO.File.Exists(Program.ResolvePath(execPath, @"C:\AI\ComfyUI\run_nvidia_gpu.bat")))
+ if (!Program.IsSafePath(execPath) || !System.IO.File.Exists(execPath))
{
- return Results.BadRequest(new { message = $"Invalid or unsafe executable path: {execPath}" });
+ return Results.BadRequest(new { message = $"Invalid or missing ComfyUI executable path: {execPath}" });
}
await orchestrator.EnsureVramForComfyUiAsync();
@@ -64,11 +64,11 @@ public static void MapEngineEndpoints(this WebApplication app)
{
var logger = loggerFactory.CreateLogger("EngineEndpoints");
var settings = settingsService.LoadSettings();
- var execPath = string.IsNullOrWhiteSpace(settings.ForgeExecutablePath) ? @"C:\AI\webui\webui-user.bat" : settings.ForgeExecutablePath;
+ var execPath = AiEngineManager.ResolveForgeExecutable(settings.ForgeExecutablePath);
- if (!Program.IsSafePath(execPath) || !System.IO.File.Exists(Program.ResolvePath(execPath, @"C:\AI\webui\webui-user.bat")))
+ if (!Program.IsSafePath(execPath) || !System.IO.File.Exists(execPath))
{
- return Results.BadRequest(new { message = $"Invalid or unsafe executable path: {execPath}" });
+ return Results.BadRequest(new { message = $"Invalid or missing SD Forge executable path: {execPath}" });
}
var success = await engineManager.StartForgeAsync(execPath, logger);
@@ -96,13 +96,13 @@ public static void MapEngineEndpoints(this WebApplication app)
{
var logger = loggerFactory.CreateLogger("EngineEndpoints");
var settings = settingsService.LoadSettings();
- var execPath = string.IsNullOrWhiteSpace(settings.AudioEngineExecutablePath) ? @"C:\AI\Kokoro-FastAPI\main.py" : settings.AudioEngineExecutablePath;
+ var execPath = AiEngineManager.ResolveAudioExecutable(settings.AudioEngineExecutablePath);
if (!execPath.TrimStart().StartsWith("docker", StringComparison.OrdinalIgnoreCase))
{
- if (!Program.IsSafePath(execPath) || !System.IO.File.Exists(Program.ResolvePath(execPath, @"C:\AI\Kokoro-FastAPI\main.py")))
+ if (!Program.IsSafePath(execPath) || !System.IO.File.Exists(execPath))
{
- return Results.BadRequest(new { message = $"Invalid or unsafe executable path: {execPath}" });
+ return Results.BadRequest(new { message = $"Invalid or missing Audio Engine executable path: {execPath}" });
}
}
@@ -152,5 +152,49 @@ public static void MapEngineEndpoints(this WebApplication app)
};
return Results.Ok(new { voices = defaultVoices, preferred = settings.PreferredAudioVoice });
});
+
+ app.MapPost("/api/ollama/start", async (IAiEngineManager engineManager, ISettingsService settingsService, ILoggerFactory loggerFactory) =>
+ {
+ var logger = loggerFactory.CreateLogger("EngineEndpoints");
+ var settings = settingsService.LoadSettings();
+ var execPath = AiEngineManager.ResolveOllamaExecutable(settings.OllamaExecutablePath);
+
+ var success = await engineManager.StartOllamaAsync(execPath, logger);
+ if (success)
+ {
+ return Results.Ok(new { message = "Ollama Started", pid = engineManager.OllamaProcess?.Id });
+ }
+ return Results.Problem("Failed to start Ollama process");
+ });
+
+ app.MapPost("/api/ollama/stop", async (IAiEngineManager engineManager, ILoggerFactory loggerFactory) =>
+ {
+ var logger = loggerFactory.CreateLogger("EngineEndpoints");
+ var success = await engineManager.StopOllamaAsync(logger);
+ return Results.Ok(new { message = "Ollama Stopped" });
+ });
+
+ app.MapPost("/api/engine/start", async (EngineToggleRequest req, IAiEngineManager engineManager) =>
+ {
+ var result = await engineManager.StartEngineAsync(req.Engine ?? "");
+ if (result.Success)
+ {
+ return Results.Ok(new { message = result.Message, pid = result.Pid });
+ }
+ return Results.Problem(result.Message);
+ });
+
+ app.MapPost("/api/engine/stop", async (EngineToggleRequest req, IAiEngineManager engineManager) =>
+ {
+ var result = await engineManager.StopEngineAsync(req.Engine ?? "");
+ if (result.Success)
+ {
+ return Results.Ok(new { message = result.Message });
+ }
+ return Results.Problem(result.Message);
+ });
}
}
+
+public record EngineToggleRequest(string? Engine);
+
diff --git a/Endpoints/WorkflowEndpoints.cs b/Endpoints/WorkflowEndpoints.cs
index 94e9510..dba39de 100644
--- a/Endpoints/WorkflowEndpoints.cs
+++ b/Endpoints/WorkflowEndpoints.cs
@@ -410,7 +410,7 @@ public static void MapWorkflowEndpoints(this WebApplication app)
var dirResults = processedFiles.Where(r => r.dir == dir && r.result != null);
foreach (var res in dirResults)
{
- if (seenIds.Add(res.id))
+ if (res.result != null && seenIds.Add(res.id))
{
list.Add(res.result);
}
diff --git a/LocalLLMServerManager.Shared/Models/EngineStatusCardModel.cs b/LocalLLMServerManager.Shared/Models/EngineStatusCardModel.cs
new file mode 100644
index 0000000..12bbdbe
--- /dev/null
+++ b/LocalLLMServerManager.Shared/Models/EngineStatusCardModel.cs
@@ -0,0 +1,110 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+
+namespace LocalLLMServerManager.Shared.Models;
+
+public partial class EngineStatusCardModel : ObservableObject
+{
+ [ObservableProperty] private string _engineKey = "";
+ [ObservableProperty] private string _displayName = "";
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(StatusColor))]
+ [NotifyPropertyChangedFor(nameof(ActionButtonText))]
+ [NotifyPropertyChangedFor(nameof(StatusText))]
+ [NotifyPropertyChangedFor(nameof(DisplaySubtitle))]
+ private bool _isOnline;
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(StatusColor))]
+ [NotifyPropertyChangedFor(nameof(ActionButtonText))]
+ [NotifyPropertyChangedFor(nameof(StatusText))]
+ [NotifyPropertyChangedFor(nameof(DisplaySubtitle))]
+ private bool _isStarting;
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(StatusColor))]
+ [NotifyPropertyChangedFor(nameof(ActionButtonText))]
+ [NotifyPropertyChangedFor(nameof(StatusText))]
+ [NotifyPropertyChangedFor(nameof(DisplaySubtitle))]
+ private bool _isError;
+
+ [ObservableProperty] private int _port;
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(HasActiveModel))]
+ [NotifyPropertyChangedFor(nameof(DisplaySubtitle))]
+ private string _activeModel = "";
+
+ [ObservableProperty] private string _memoryUsage = "";
+ [ObservableProperty] private string _statusTooltip = "";
+
+ public string StatusColor => IsError ? "#ef4444" : IsOnline ? "#10b981" : IsStarting ? "#f59e0b" : "#64748b";
+ public string ActionButtonText => IsOnline ? "⏹ Stop" : "▷ Start";
+ public string StatusText => IsError ? "Error" : IsOnline ? "Online" : IsStarting ? "Starting..." : "Offline";
+ public bool HasActiveModel => !string.IsNullOrWhiteSpace(ActiveModel);
+ public string DisplaySubtitle => HasActiveModel ? ActiveModel : StatusText;
+
+ public EngineStatusCardModel()
+ {
+ }
+
+ public EngineStatusCardModel(string engineKey, string displayName, int port)
+ {
+ EngineKey = engineKey;
+ DisplayName = displayName;
+ Port = port;
+ StatusTooltip = $"{displayName}: Offline (Port {port})";
+ }
+}
+
+public class TelemetryData
+{
+ public bool OllamaOnline { get; set; }
+ public bool ComfyOnline { get; set; }
+ public bool ForgeOnline { get; set; }
+ public bool KokoroOnline { get; set; }
+
+ public bool OllamaStarting { get; set; }
+ public bool ComfyStarting { get; set; }
+ public bool ForgeStarting { get; set; }
+ public bool KokoroStarting { get; set; }
+
+ public string OllamaModel { get; set; } = "";
+ public string ComfyModel { get; set; } = "";
+ public string ForgeModel { get; set; } = "";
+ public string KokoroModel { get; set; } = "";
+
+ public string OllamaMemory { get; set; } = "";
+ public string ComfyMemory { get; set; } = "";
+ public string ForgeMemory { get; set; } = "";
+ public string KokoroMemory { get; set; } = "";
+
+ public string GpuName { get; set; } = "";
+ public double UsedVramGb { get; set; }
+ public double TotalVramGb { get; set; } = 16.0;
+ public int VramPercent { get; set; }
+
+ public TelemetryData()
+ {
+ }
+
+ public TelemetryData(
+ bool ollamaOnline = false,
+ bool comfyOnline = false,
+ bool forgeOnline = false,
+ bool kokoroOnline = false,
+ string? ollamaModel = null,
+ string? comfyModel = null,
+ string? forgeModel = null,
+ string? kokoroModel = null)
+ {
+ OllamaOnline = ollamaOnline;
+ ComfyOnline = comfyOnline;
+ ForgeOnline = forgeOnline;
+ KokoroOnline = kokoroOnline;
+ OllamaModel = ollamaModel ?? "";
+ ComfyModel = comfyModel ?? "";
+ ForgeModel = forgeModel ?? "";
+ KokoroModel = kokoroModel ?? "";
+ }
+}
diff --git a/LocalLLMServerManager.Shared/Styles/DesignTokens.axaml b/LocalLLMServerManager.Shared/Styles/DesignTokens.axaml
index 62e2fac..4eaccb9 100644
--- a/LocalLLMServerManager.Shared/Styles/DesignTokens.axaml
+++ b/LocalLLMServerManager.Shared/Styles/DesignTokens.axaml
@@ -1,18 +1,18 @@
-
- #0d1117
- #161b22
- #1c2128
- #30363d
- #388bfd
- #58a6ff
- #79c0ff
- #f0f6fc
- #8b949e
- #238636
- #da3633
- #d29922
+
+ #090A0F
+ #12141C
+ #181B26
+ #222634
+ #6366F1
+ #4F46E5
+ #8B5CF6
+ #F8FAFC
+ #94A3B8
+ #10B981
+ #EF4444
+ #F59E0B
@@ -20,7 +20,7 @@
-
+
diff --git a/LocalLLMServerManager.Shared/Styles/MatteTheme.axaml b/LocalLLMServerManager.Shared/Styles/MatteTheme.axaml
index 5b5c985..62af5c0 100644
--- a/LocalLLMServerManager.Shared/Styles/MatteTheme.axaml
+++ b/LocalLLMServerManager.Shared/Styles/MatteTheme.axaml
@@ -10,13 +10,14 @@
-
+
@@ -24,30 +25,31 @@
-
-
+
+
@@ -58,16 +60,17 @@
-
+
+
diff --git a/LocalLLMServerManager.Shared/ViewModels/DocumentationViewModel.cs b/LocalLLMServerManager.Shared/ViewModels/DocumentationViewModel.cs
index c995638..ffda35f 100644
--- a/LocalLLMServerManager.Shared/ViewModels/DocumentationViewModel.cs
+++ b/LocalLLMServerManager.Shared/ViewModels/DocumentationViewModel.cs
@@ -56,10 +56,21 @@ public partial class DocumentationViewModel : ObservableObject
public Action? OnNavigateToTabRequested { get; set; }
public Action? OnPopOutNativeWindowRequested { get; set; }
+ public Action? OnAskCopilotRequested { get; set; }
[ObservableProperty] private bool _isDrawerOpen = false;
[ObservableProperty] private bool _isDetailActive = false;
+ [RelayCommand]
+ public void AskCopilotAboutGuide()
+ {
+ if (SelectedSection != null)
+ {
+ var prompt = $"Can you explain '{SelectedSection.Title}' ({SelectedSection.Summary}) and how I use it in the Studio?";
+ OnAskCopilotRequested?.Invoke(prompt);
+ }
+ }
+
[RelayCommand]
public void BackToTopics()
{
diff --git a/LocalLLMServerManager.Shared/ViewModels/EngineStudioViewModel.cs b/LocalLLMServerManager.Shared/ViewModels/EngineStudioViewModel.cs
new file mode 100644
index 0000000..1aeba3b
--- /dev/null
+++ b/LocalLLMServerManager.Shared/ViewModels/EngineStudioViewModel.cs
@@ -0,0 +1,332 @@
+using System;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+
+namespace LocalLLMServerManager.Shared.ViewModels;
+
+///
+/// ViewModel managing the fluid creative studio canvas, top modality switching,
+/// creative prompt dock, and fine-tuning parameters flyout.
+///
+public partial class EngineStudioViewModel : ObservableObject
+{
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(IsImageModalityActive))]
+ [NotifyPropertyChangedFor(nameof(IsTextModalityActive))]
+ [NotifyPropertyChangedFor(nameof(IsVideoModalityActive))]
+ [NotifyPropertyChangedFor(nameof(Is3DModalityActive))]
+ [NotifyPropertyChangedFor(nameof(IsAudioModalityActive))]
+ [NotifyPropertyChangedFor(nameof(IsStickerModalityActive))]
+ [NotifyPropertyChangedFor(nameof(ActiveModelBadge))]
+ [NotifyPropertyChangedFor(nameof(ActivePromptPlaceholder))]
+ private string _selectedModality = "Image";
+
+ public bool IsImageModalityActive => SelectedModality == "Image" || SelectedModality == "Images";
+ public bool IsTextModalityActive => SelectedModality == "Text";
+ public bool IsVideoModalityActive => SelectedModality == "Video";
+ public bool Is3DModalityActive => SelectedModality == "3D Mesh";
+ public bool IsAudioModalityActive => SelectedModality == "Audio";
+ public bool IsStickerModalityActive => SelectedModality == "Sticker" || SelectedModality == "Sticker Studio";
+
+ [ObservableProperty]
+ private bool _isParametersFlyoutOpen;
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(ActiveModelBadge))]
+ private string _imageModel = "SDXL Base 1.0";
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(ActiveModelBadge))]
+ private string _textModel = "llama3.2:latest";
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(ActiveModelBadge))]
+ private string _videoModel = "Wan 2.2 / LTX-2.5";
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(ActiveModelBadge))]
+ private string _meshModel = "TRELLIS V2 (Gaussian Splat)";
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(ActiveModelBadge))]
+ private string _audioModel = "Kokoro TTS (af_heart)";
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(ActiveModelBadge))]
+ private string _stickerModel = "Sticker Studio (BirefNet + SDXL)";
+
+ [ObservableProperty]
+ private string _activeAspectPreset = "16:9";
+
+ [ObservableProperty]
+ private int _steps = 30;
+
+ [ObservableProperty]
+ private double _cfgScale = 7.0;
+
+ [ObservableProperty]
+ private long _seed = -1;
+
+ [ObservableProperty]
+ private double _denoise = 0.75;
+
+ [ObservableProperty]
+ private string _promptText = "";
+
+ [ObservableProperty]
+ private string _negativePromptText = "";
+
+ [ObservableProperty]
+ private bool _isGenerating;
+
+ [ObservableProperty]
+ private string _attachedImagePath = "";
+
+ [ObservableProperty]
+ private bool _hasAttachedImage;
+
+ public string ActiveModelBadge => SelectedModality switch
+ {
+ "Image" or "Images" => !string.IsNullOrWhiteSpace(ImageModel) ? ImageModel : "SDXL Base 1.0",
+ "Text" => !string.IsNullOrWhiteSpace(TextModel) ? TextModel : "llama3.2:latest",
+ "Video" => !string.IsNullOrWhiteSpace(VideoModel) ? VideoModel : "Wan 2.2 / LTX-2.5",
+ "3D Mesh" => !string.IsNullOrWhiteSpace(MeshModel) ? MeshModel : "TRELLIS V2 (Gaussian Splat)",
+ "Audio" => !string.IsNullOrWhiteSpace(AudioModel) ? AudioModel : "Kokoro TTS (af_heart)",
+ "Sticker" or "Sticker Studio" => !string.IsNullOrWhiteSpace(StickerModel) ? StickerModel : "Sticker Studio (BirefNet + SDXL)",
+ _ => "SDXL Base 1.0"
+ };
+
+ public string ActivePromptPlaceholder => SelectedModality switch
+ {
+ "Image" or "Images" => "Describe an image to generate with SDXL & Forge (e.g. 'cyberpunk street in rain')...",
+ "Text" => "Enter a prompt or query for the local LLM...",
+ "Video" => "Describe action and motion for video generation (e.g. 'cinematic drone sweep over mountains')...",
+ "3D Mesh" => "Describe a 3D object to sculpt or synthesize (e.g. 'ancient stone relic with glowing runes')...",
+ "Audio" => "Describe audio atmosphere or enter speech to synthesize with Kokoro TTS...",
+ "Sticker" or "Sticker Studio" => "Describe a sticker design to generate with transparent background (e.g. 'cute astronaut cat sticker, die-cut vector')...",
+ _ => "Describe what you want to create..."
+ };
+
+ [RelayCommand]
+ public void SelectModality(string modality)
+ {
+ if (string.IsNullOrWhiteSpace(modality)) return;
+ SelectedModality = modality;
+ OnPropertyChanged(nameof(IsImageModalityActive));
+ OnPropertyChanged(nameof(IsTextModalityActive));
+ OnPropertyChanged(nameof(IsVideoModalityActive));
+ OnPropertyChanged(nameof(Is3DModalityActive));
+ OnPropertyChanged(nameof(IsAudioModalityActive));
+ OnPropertyChanged(nameof(IsStickerModalityActive));
+ OnPropertyChanged(nameof(ActiveModelBadge));
+ OnPropertyChanged(nameof(ActivePromptPlaceholder));
+ }
+
+ [RelayCommand]
+ public void ToggleParametersFlyout()
+ {
+ IsParametersFlyoutOpen = !IsParametersFlyoutOpen;
+ }
+
+ [RelayCommand]
+ public void SelectAspectPreset(string preset)
+ {
+ if (!string.IsNullOrWhiteSpace(preset))
+ {
+ ActiveAspectPreset = preset;
+ }
+ }
+
+ [RelayCommand]
+ public void AttachImage()
+ {
+ HasAttachedImage = !HasAttachedImage;
+ }
+
+ [RelayCommand]
+ public void ApplyPromptChip(string prompt)
+ {
+ if (!string.IsNullOrWhiteSpace(prompt))
+ {
+ PromptText = prompt;
+ }
+ }
+
+ [RelayCommand]
+ public void GenerateFromDock()
+ {
+ Generate();
+ }
+
+ [RelayCommand]
+ public void Generate()
+ {
+ IsGenerating = !IsGenerating;
+ }
+}
+
+///
+/// Partial class extensions to MainViewModel to support the Fluid Studio Canvas & Creative Prompt Dock
+/// when EngineStudioTabControl is hosted with DataContext="{Binding}" (MainViewModel).
+///
+public partial class MainViewModel
+{
+ [ObservableProperty]
+ private EngineStudioViewModel _studio = new();
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(IsImageModalityActive))]
+ [NotifyPropertyChangedFor(nameof(IsTextModalityActive))]
+ [NotifyPropertyChangedFor(nameof(IsVideoModalityActive))]
+ [NotifyPropertyChangedFor(nameof(Is3DModalityActive))]
+ [NotifyPropertyChangedFor(nameof(IsAudioModalityActive))]
+ [NotifyPropertyChangedFor(nameof(ActiveModelBadge))]
+ [NotifyPropertyChangedFor(nameof(ActivePromptPlaceholder))]
+ private string _selectedModality = "Image";
+
+ public bool IsImageModalityActive => SelectedModality == "Image" || SelectedModality == "Images" || SelectedStudioMode == "Images";
+ public bool IsTextModalityActive => SelectedModality == "Text" || SelectedStudioMode == "Text";
+ public bool IsVideoModalityActive => SelectedModality == "Video" || SelectedStudioMode == "Video";
+ public bool Is3DModalityActive => SelectedModality == "3D Mesh" || SelectedStudioMode == "3D Mesh";
+ public bool IsAudioModalityActive => SelectedModality == "Audio" || SelectedStudioMode == "Audio";
+
+ [ObservableProperty]
+ private bool _isParametersFlyoutOpen;
+
+ [ObservableProperty]
+ private string _activeAspectPreset = "16:9";
+
+ [ObservableProperty]
+ private int _studioSteps = 30;
+
+ [ObservableProperty]
+ private double _studioCfgScale = 7.0;
+
+ [ObservableProperty]
+ private long _studioSeed = -1;
+
+ [ObservableProperty]
+ private double _studioDenoise = 0.75;
+
+ [ObservableProperty]
+ private string _promptText = "";
+
+ [ObservableProperty]
+ private string _negativePromptText = "";
+
+ public string ActiveModelBadge => SelectedModality switch
+ {
+ "Image" or "Images" => !string.IsNullOrWhiteSpace(SelectedImageWorkflow) ? SelectedImageWorkflow : "SDXL Base 1.0",
+ "Text" => Ollama?.SelectedInstalledModel?.Name ?? (!string.IsNullOrWhiteSpace(Telemetry?.OllamaModelName) ? Telemetry.OllamaModelName : "llama3.2:latest"),
+ "Video" => !string.IsNullOrWhiteSpace(SelectedVideoWorkflow) ? SelectedVideoWorkflow : "Wan 2.2 / LTX-2.5",
+ "3D Mesh" => !string.IsNullOrWhiteSpace(Selected3DFormat) ? $"TRELLIS V2 ({Selected3DFormat})" : "TRELLIS V2 (Gaussian Splat)",
+ "Audio" => Audio != null ? $"Kokoro TTS ({Audio.VoiceProfile})" : "Kokoro TTS (af_heart)",
+ _ => "SDXL Base 1.0"
+ };
+
+ public string ActivePromptPlaceholder => SelectedModality switch
+ {
+ "Image" or "Images" => "Describe an image to generate with SDXL & Forge (e.g. 'cyberpunk street in rain')...",
+ "Text" => "Enter a prompt or query for the local LLM...",
+ "Video" => "Describe action and motion for video generation (e.g. 'cinematic drone sweep over mountains')...",
+ "3D Mesh" => "Describe a 3D object to sculpt or synthesize (e.g. 'ancient stone relic with glowing runes')...",
+ "Audio" => "Describe audio atmosphere or enter speech to synthesize with Kokoro TTS...",
+ _ => "Describe what you want to create..."
+ };
+
+ [RelayCommand]
+ public void SelectModality(string modality)
+ {
+ if (string.IsNullOrWhiteSpace(modality)) return;
+ SelectedModality = modality;
+ if (modality == "Sticker" || modality == "Sticker Studio")
+ {
+ IsStickerStudioActive = true;
+ SelectedStudioMode = "Sticker Studio";
+ }
+ else
+ {
+ IsStickerStudioActive = false;
+ SelectedStudioMode = modality switch
+ {
+ "Image" => "Images",
+ _ => modality
+ };
+ }
+ Studio.SelectModality(modality);
+ OnPropertyChanged(nameof(IsImageModalityActive));
+ OnPropertyChanged(nameof(IsTextModalityActive));
+ OnPropertyChanged(nameof(IsVideoModalityActive));
+ OnPropertyChanged(nameof(Is3DModalityActive));
+ OnPropertyChanged(nameof(IsAudioModalityActive));
+ OnPropertyChanged(nameof(IsStickerModalityActive));
+ OnPropertyChanged(nameof(ActiveModelBadge));
+ OnPropertyChanged(nameof(ActivePromptPlaceholder));
+ }
+
+ [RelayCommand]
+ public void ToggleParametersFlyout()
+ {
+ IsParametersFlyoutOpen = !IsParametersFlyoutOpen;
+ Studio.IsParametersFlyoutOpen = IsParametersFlyoutOpen;
+ }
+
+ [RelayCommand]
+ public void AttachImage()
+ {
+ Studio.AttachImage();
+ }
+
+ [RelayCommand]
+ public void SelectAspectPreset(string preset)
+ {
+ if (!string.IsNullOrWhiteSpace(preset))
+ {
+ ActiveAspectPreset = preset;
+ Studio.ActiveAspectPreset = preset;
+ }
+ }
+
+ [RelayCommand]
+ public void ApplyPromptChip(string prompt)
+ {
+ if (string.IsNullOrWhiteSpace(prompt)) return;
+ PromptText = prompt;
+ Studio.PromptText = prompt;
+ if (IsImageModalityActive) ImagePrompt = prompt;
+ else if (IsTextModalityActive) OllamaPrompt = prompt;
+ else if (IsVideoModalityActive) VideoPrompt = prompt;
+ else if (Is3DModalityActive) Prompt3D = prompt;
+ else if (IsAudioModalityActive && Audio != null) Audio.Prompt = prompt;
+ }
+
+ [RelayCommand]
+ public void GenerateFromDock()
+ {
+ if (IsImageModalityActive)
+ {
+ if (!string.IsNullOrWhiteSpace(PromptText)) ImagePrompt = PromptText;
+ OpenForgeWebUiCommand.Execute(null);
+ }
+ else if (IsTextModalityActive)
+ {
+ if (!string.IsNullOrWhiteSpace(PromptText)) OllamaPrompt = PromptText;
+ GenerateOllamaTextCommand.Execute(null);
+ }
+ else if (IsVideoModalityActive)
+ {
+ if (!string.IsNullOrWhiteSpace(PromptText)) VideoPrompt = PromptText;
+ GenerateVideoCommand.Execute(null);
+ }
+ else if (Is3DModalityActive)
+ {
+ if (!string.IsNullOrWhiteSpace(PromptText)) Prompt3D = PromptText;
+ Generate3DCommand.Execute(null);
+ }
+ else if (IsAudioModalityActive && Audio != null)
+ {
+ if (!string.IsNullOrWhiteSpace(PromptText)) Audio.Prompt = PromptText;
+ Audio.GenerateAudioCommand.Execute(null);
+ }
+ }
+}
diff --git a/LocalLLMServerManager.Shared/ViewModels/MainViewModel.cs b/LocalLLMServerManager.Shared/ViewModels/MainViewModel.cs
index fffcfbe..3a3b7b8 100644
--- a/LocalLLMServerManager.Shared/ViewModels/MainViewModel.cs
+++ b/LocalLLMServerManager.Shared/ViewModels/MainViewModel.cs
@@ -130,6 +130,58 @@ partial void OnSelectedTabIndexChanged(int value)
[ObservableProperty] private bool _isAnyDrawerOpen = false;
[ObservableProperty] private bool _isDesktopHost = false;
+ [ObservableProperty] private bool _isCopilotSidebarOpen = false;
+ [ObservableProperty] private string _selectedCopilotTab = "Assistant";
+
+ public bool IsAssistantTabActive => SelectedCopilotTab == "Assistant";
+ public bool IsDocsTabActive => SelectedCopilotTab == "Docs";
+
+ [RelayCommand]
+ public void ToggleCopilotSidebar(string? tab = null)
+ {
+ if (tab != null && SelectedCopilotTab != tab && IsCopilotSidebarOpen)
+ {
+ SelectedCopilotTab = tab;
+ OnPropertyChanged(nameof(IsAssistantTabActive));
+ OnPropertyChanged(nameof(IsDocsTabActive));
+ UpdateIsAnyDrawerOpen();
+ return;
+ }
+
+ if (tab != null)
+ {
+ SelectedCopilotTab = tab;
+ OnPropertyChanged(nameof(IsAssistantTabActive));
+ OnPropertyChanged(nameof(IsDocsTabActive));
+ }
+
+ IsCopilotSidebarOpen = !IsCopilotSidebarOpen;
+
+ if (IsCopilotSidebarOpen && SelectedCopilotTab == "Assistant" && Assistant.AvailableModelCapabilities.Count <= 1)
+ {
+ _ = Assistant.LoadAvailableModelsAsync();
+ }
+
+ UpdateIsAnyDrawerOpen();
+ }
+
+ [RelayCommand]
+ public void SelectCopilotTab(string tab)
+ {
+ SelectedCopilotTab = tab;
+ OnPropertyChanged(nameof(IsAssistantTabActive));
+ OnPropertyChanged(nameof(IsDocsTabActive));
+ if (!IsCopilotSidebarOpen)
+ {
+ IsCopilotSidebarOpen = true;
+ }
+
+ if (SelectedCopilotTab == "Assistant" && Assistant.AvailableModelCapabilities.Count <= 1)
+ {
+ _ = Assistant.LoadAvailableModelsAsync();
+ }
+ UpdateIsAnyDrawerOpen();
+ }
[RelayCommand]
public void ToggleDocumentationDrawer()
@@ -140,10 +192,7 @@ public void ToggleDocumentationDrawer()
return;
}
- Assistant.IsDrawerOpen = false;
- Ollama.IsPullDrawerOpen = false;
- Documentation.IsDrawerOpen = !Documentation.IsDrawerOpen;
- UpdateIsAnyDrawerOpen();
+ ToggleCopilotSidebar("Docs");
}
[RelayCommand]
@@ -155,28 +204,20 @@ public void ToggleAiAssistDrawer()
return;
}
- Documentation.IsDrawerOpen = false;
- Ollama.IsPullDrawerOpen = false;
- Assistant.IsDrawerOpen = !Assistant.IsDrawerOpen;
- if (Assistant.IsDrawerOpen && Assistant.AvailableModelCapabilities.Count <= 1)
- {
- _ = Assistant.LoadAvailableModelsAsync();
- }
- UpdateIsAnyDrawerOpen();
+ ToggleCopilotSidebar("Assistant");
}
[RelayCommand]
public void CloseDrawers()
{
- Documentation.IsDrawerOpen = false;
- Assistant.IsDrawerOpen = false;
+ IsCopilotSidebarOpen = false;
Ollama.ClosePullDrawer();
UpdateIsAnyDrawerOpen();
}
public void UpdateIsAnyDrawerOpen()
{
- IsAnyDrawerOpen = Documentation.IsDrawerOpen || Assistant.IsDrawerOpen || Ollama.IsPullDrawerOpen;
+ IsAnyDrawerOpen = IsCopilotSidebarOpen || Ollama.IsPullDrawerOpen;
}
@@ -222,6 +263,7 @@ public MainViewModel(
_canIRunItService = canIRunItService ?? new CanIRunItService();
PresetService = studioPresetService ?? new StudioPresetService();
Telemetry = new TelemetryViewModel(telemetryService) { ApiBase = ApiBase };
+ Telemetry.OnManageServiceRequested = (serviceName) => ToggleServiceInteractive(serviceName);
HardwareFit = new CanIRunItViewModel(_canIRunItService, telemetryService, httpClient) { ApiBase = ApiBase };
Ollama = new OllamaLibraryViewModel(ollamaModelService, _canIRunItService, telemetryService)
{
@@ -259,6 +301,11 @@ public MainViewModel(
ApiBase = ApiBase
};
Documentation.OnNavigateToTabRequested = tab => SelectedTabIndex = tab;
+ Documentation.OnAskCopilotRequested = prompt =>
+ {
+ SelectCopilotTab("Assistant");
+ Assistant.InputText = prompt;
+ };
NavigationRail.PropertyChanged += (s, e) =>
{
@@ -385,12 +432,13 @@ public string ApiBase
[RelayCommand]
public void ToggleServiceInteractive(string serviceName)
{
- _serviceConfirmTarget = serviceName.ToLowerInvariant();
+ _serviceConfirmTarget = serviceName.Trim();
+ var lower = _serviceConfirmTarget.ToLowerInvariant();
bool isOnline = false;
- if (_serviceConfirmTarget == "ollama") isOnline = Telemetry.IsOllamaOnline;
- else if (_serviceConfirmTarget == "forge") isOnline = Telemetry.IsForgeOnline;
- else if (_serviceConfirmTarget == "comfy") isOnline = Telemetry.IsComfyOnline;
+ if (lower.Contains("ollama")) isOnline = Telemetry.IsOllamaOnline;
+ else if (lower.Contains("forge")) isOnline = Telemetry.IsForgeOnline;
+ else if (lower.Contains("comfy")) isOnline = Telemetry.IsComfyOnline;
string action = isOnline ? "stop" : "start";
ServiceConfirmMessage = $"Are you sure you want to {action} the {serviceName} engine?";
@@ -563,6 +611,7 @@ public void CancelServiceConfirm()
public bool IsVideoWorkflowActive => !IsStickerStudioActive && SelectedStudioMode == "Video";
public bool Is3DMeshWorkflowActive => !IsStickerStudioActive && SelectedStudioMode == "3D Mesh";
public bool IsAudioWorkflowActive => !IsStickerStudioActive && SelectedStudioMode == "Audio";
+ public bool IsStickerModalityActive => IsStickerStudioActive || SelectedStudioMode == "Sticker Studio" || SelectedModality == "Sticker" || SelectedModality == "Sticker Studio";
[ObservableProperty]
private string _ollamaPrompt = "Explain how local LLM quantization works in plain language.";
@@ -1178,17 +1227,48 @@ public void NavigateToDocumentationTab()
[RelayCommand]
public async Task ToggleEngineAsync(string engineName)
{
+ var rawName = engineName.Trim();
+ var normalized = rawName.ToLowerInvariant().Replace(" sd", "");
+ if (normalized.Contains("comfy")) normalized = "comfy";
+ else if (normalized.Contains("forge")) normalized = "forge";
+ else if (normalized.Contains("ollama")) normalized = "ollama";
+ else if (normalized.Contains("audio")) normalized = "audio";
+
+ bool isOnline = false;
+ if (normalized == "ollama") isOnline = Telemetry.IsOllamaOnline;
+ else if (normalized == "forge") isOnline = Telemetry.IsForgeOnline;
+ else if (normalized == "comfy") isOnline = Telemetry.IsComfyOnline;
+
+ bool isStart = !isOnline;
+ string actionWord = isStart ? "start" : "stop";
+
+ ToastService.Instance.Show($"{(isStart ? "Starting" : "Stopping")} {rawName}...", ToastType.Info, 2500);
+
try
{
+ var endpoint = isStart ? $"/api/{normalized}/start" : $"/api/{normalized}/stop";
var content = new StringContent(
- JsonSerializer.Serialize(new { engine = engineName }),
+ JsonSerializer.Serialize(new { engine = normalized }),
System.Text.Encoding.UTF8,
"application/json"
);
- await Http.PostAsync($"{ApiBase}/api/comfy/start", content);
+ var response = await Http.PostAsync($"{ApiBase}{endpoint}", content);
+ if (response.IsSuccessStatusCode)
+ {
+ ToastService.Instance.Show($"{rawName} {(isStart ? "started" : "stopped")} successfully.", ToastType.Success);
+ }
+ else
+ {
+ var errorText = await response.Content.ReadAsStringAsync();
+ var msg = string.IsNullOrWhiteSpace(errorText) ? $"Server returned {response.StatusCode}" : errorText;
+ ToastService.Instance.Show($"Failed to {actionWord} {rawName}: {msg}", ToastType.Error, 6000);
+ }
await RefreshStatusAsync();
}
- catch { }
+ catch (Exception ex)
+ {
+ ToastService.Instance.Show($"Error while attempting to {actionWord} {rawName}: {ex.Message}", ToastType.Error, 6000);
+ }
}
[RelayCommand]
diff --git a/LocalLLMServerManager.Shared/ViewModels/SettingsViewModel.cs b/LocalLLMServerManager.Shared/ViewModels/SettingsViewModel.cs
index 175e90d..98270b6 100644
--- a/LocalLLMServerManager.Shared/ViewModels/SettingsViewModel.cs
+++ b/LocalLLMServerManager.Shared/ViewModels/SettingsViewModel.cs
@@ -35,6 +35,25 @@ public partial class SettingsViewModel : ObservableObject
public bool IsFilterImageActive => SelectedPresetModalityFilter.Equals("Image", StringComparison.OrdinalIgnoreCase);
public bool IsFilterAudioActive => SelectedPresetModalityFilter.Equals("Audio", StringComparison.OrdinalIgnoreCase);
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(IsAppearanceCategoryActive))]
+ [NotifyPropertyChangedFor(nameof(IsEnginesCategoryActive))]
+ [NotifyPropertyChangedFor(nameof(IsFeaturePacksCategoryActive))]
+ [NotifyPropertyChangedFor(nameof(IsNetworkCategoryActive))]
+ private string _selectedCategory = "Appearance";
+
+ public bool IsAppearanceCategoryActive => string.IsNullOrWhiteSpace(SelectedCategory) || string.Equals(SelectedCategory, "Appearance", StringComparison.OrdinalIgnoreCase);
+ public bool IsEnginesCategoryActive => string.Equals(SelectedCategory, "Engines", StringComparison.OrdinalIgnoreCase);
+ public bool IsFeaturePacksCategoryActive => string.Equals(SelectedCategory, "FeaturePacks", StringComparison.OrdinalIgnoreCase);
+ public bool IsNetworkCategoryActive => string.Equals(SelectedCategory, "Network", StringComparison.OrdinalIgnoreCase);
+
+ [RelayCommand]
+ public void SelectCategory(string category)
+ {
+ if (string.IsNullOrWhiteSpace(category)) return;
+ SelectedCategory = category;
+ }
+
[ObservableProperty] private string _presetsJson = "";
[ObservableProperty] private string _forgeModelsPath = "";
diff --git a/LocalLLMServerManager.Shared/ViewModels/TelemetryViewModel.cs b/LocalLLMServerManager.Shared/ViewModels/TelemetryViewModel.cs
index a0472a4..a4abec0 100644
--- a/LocalLLMServerManager.Shared/ViewModels/TelemetryViewModel.cs
+++ b/LocalLLMServerManager.Shared/ViewModels/TelemetryViewModel.cs
@@ -4,6 +4,7 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LocalLLMServerManager.Shared.Interfaces;
+using LocalLLMServerManager.Shared.Models;
using LocalLLMServerManager.Shared.Services;
namespace LocalLLMServerManager.Shared.ViewModels;
@@ -19,6 +20,16 @@ public partial class TelemetryViewModel : ObservableObject
[ObservableProperty] private double _vramPercentage = 0.0;
[ObservableProperty] private string _vramStatusText = "0.0 GB / 16.0 GB (0%)";
+ [ObservableProperty] private EngineStatusCardModel _ollamaCard = new("ollama", "Ollama", 11434);
+ [ObservableProperty] private EngineStatusCardModel _comfyUiCard = new("comfyui", "ComfyUI", 8188);
+ [ObservableProperty] private EngineStatusCardModel _forgeCard = new("forge", "SD Forge", 7860);
+ [ObservableProperty] private EngineStatusCardModel _kokoroCard = new("kokoro", "Kokoro TTS", 8880);
+
+ [ObservableProperty] private string _ollamaModelName = "";
+ [ObservableProperty] private string _comfyModelName = "";
+ [ObservableProperty] private string _forgeModelName = "";
+ [ObservableProperty] private string _kokoroModelName = "";
+
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsOllamaOnline))]
[NotifyPropertyChangedFor(nameof(OllamaStatusColor))]
@@ -34,13 +45,81 @@ public partial class TelemetryViewModel : ObservableObject
[NotifyPropertyChangedFor(nameof(ComfyStatusColor))]
private string _comfyStatus = "Offline";
- public bool IsOllamaOnline => string.Equals(OllamaStatus, "Online", StringComparison.OrdinalIgnoreCase);
- public bool IsForgeOnline => string.Equals(ForgeStatus, "Online", StringComparison.OrdinalIgnoreCase);
- public bool IsComfyOnline => string.Equals(ComfyStatus, "Online", StringComparison.OrdinalIgnoreCase);
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(IsKokoroOnline))]
+ [NotifyPropertyChangedFor(nameof(KokoroStatusColor))]
+ private string _kokoroStatus = "Offline";
+
+ public bool IsOllamaOnline => string.Equals(OllamaStatus, "Online", StringComparison.OrdinalIgnoreCase) || string.Equals(OllamaStatus, "Running", StringComparison.OrdinalIgnoreCase);
+ public bool IsForgeOnline => string.Equals(ForgeStatus, "Online", StringComparison.OrdinalIgnoreCase) || string.Equals(ForgeStatus, "Running", StringComparison.OrdinalIgnoreCase);
+ public bool IsComfyOnline => string.Equals(ComfyStatus, "Online", StringComparison.OrdinalIgnoreCase) || string.Equals(ComfyStatus, "Running", StringComparison.OrdinalIgnoreCase);
+ public bool IsKokoroOnline => string.Equals(KokoroStatus, "Online", StringComparison.OrdinalIgnoreCase) || string.Equals(KokoroStatus, "Running", StringComparison.OrdinalIgnoreCase);
public string OllamaStatusColor => IsOllamaOnline ? "#22C55E" : "#64748B";
public string ForgeStatusColor => IsForgeOnline ? "#22C55E" : "#64748B";
public string ComfyStatusColor => IsComfyOnline ? "#22C55E" : "#64748B";
+ public string KokoroStatusColor => IsKokoroOnline ? "#22C55E" : "#64748B";
+
+ partial void OnOllamaStatusChanged(string value) => UpdateCardFromStatus(OllamaCard, value);
+ partial void OnForgeStatusChanged(string value) => UpdateCardFromStatus(ForgeCard, value);
+ partial void OnComfyStatusChanged(string value) => UpdateCardFromStatus(ComfyUiCard, value);
+ partial void OnKokoroStatusChanged(string value) => UpdateCardFromStatus(KokoroCard, value);
+
+ partial void OnOllamaModelNameChanged(string value)
+ {
+ if (OllamaCard != null)
+ {
+ OllamaCard.ActiveModel = value;
+ OllamaCard.StatusTooltip = $"{OllamaCard.DisplayName}: {(OllamaCard.IsOnline ? "Online" : "Offline")} (Port {OllamaCard.Port})" +
+ (!string.IsNullOrEmpty(value) ? $" | Model: {value}" : "");
+ }
+ }
+
+ partial void OnForgeModelNameChanged(string value)
+ {
+ if (ForgeCard != null)
+ {
+ ForgeCard.ActiveModel = value;
+ ForgeCard.StatusTooltip = $"{ForgeCard.DisplayName}: {(ForgeCard.IsOnline ? "Online" : "Offline")} (Port {ForgeCard.Port})" +
+ (!string.IsNullOrEmpty(value) ? $" | Model: {value}" : "");
+ }
+ }
+
+ partial void OnComfyModelNameChanged(string value)
+ {
+ if (ComfyUiCard != null)
+ {
+ ComfyUiCard.ActiveModel = value;
+ ComfyUiCard.StatusTooltip = $"{ComfyUiCard.DisplayName}: {(ComfyUiCard.IsOnline ? "Online" : "Offline")} (Port {ComfyUiCard.Port})" +
+ (!string.IsNullOrEmpty(value) ? $" | Model: {value}" : "");
+ }
+ }
+
+ partial void OnKokoroModelNameChanged(string value)
+ {
+ if (KokoroCard != null)
+ {
+ KokoroCard.ActiveModel = value;
+ KokoroCard.StatusTooltip = $"{KokoroCard.DisplayName}: {(KokoroCard.IsOnline ? "Online" : "Offline")} (Port {KokoroCard.Port})" +
+ (!string.IsNullOrEmpty(value) ? $" | Model: {value}" : "");
+ }
+ }
+
+ private void UpdateCardFromStatus(EngineStatusCardModel? card, string status)
+ {
+ if (card == null) return;
+ var isOnline = string.Equals(status, "Online", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(status, "Running", StringComparison.OrdinalIgnoreCase);
+ var isStarting = string.Equals(status, "Starting", StringComparison.OrdinalIgnoreCase);
+ var isError = string.Equals(status, "Error", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(status, "Failed", StringComparison.OrdinalIgnoreCase);
+
+ card.IsOnline = isOnline;
+ card.IsStarting = isStarting;
+ card.IsError = isError;
+ card.StatusTooltip = $"{card.DisplayName}: {(isOnline ? "Online" : isStarting ? "Starting..." : isError ? "Error" : "Offline")} (Port {card.Port})" +
+ (!string.IsNullOrEmpty(card.ActiveModel) ? $" | Model: {card.ActiveModel}" : "");
+ }
[ObservableProperty] private string _serviceModeText = "Connecting...";
[ObservableProperty] private bool _isServiceRunning = false;
@@ -51,9 +130,17 @@ public partial class TelemetryViewModel : ObservableObject
[ObservableProperty] private string _manageServiceTarget = "";
[ObservableProperty] private bool _manageServiceIsStart;
+ public TelemetryViewModel() : this(new TelemetryService())
+ {
+ }
+
public TelemetryViewModel(ITelemetryService telemetryService)
{
_telemetryService = telemetryService;
+ UpdateCardFromStatus(OllamaCard, OllamaStatus);
+ UpdateCardFromStatus(ComfyUiCard, ComfyStatus);
+ UpdateCardFromStatus(ForgeCard, ForgeStatus);
+ UpdateCardFromStatus(KokoroCard, KokoroStatus);
}
[RelayCommand]
@@ -62,14 +149,24 @@ public void ToggleCollapse()
IsCollapsed = !IsCollapsed;
}
+ public Action? OnManageServiceRequested { get; set; }
+
[RelayCommand]
public void ManageService(string serviceName)
{
+ if (OnManageServiceRequested != null)
+ {
+ OnManageServiceRequested(serviceName);
+ return;
+ }
+
ManageServiceTarget = serviceName;
bool isOnline = false;
- if (serviceName == "Ollama") isOnline = IsOllamaOnline;
- else if (serviceName == "Forge SD") isOnline = IsForgeOnline;
- else if (serviceName == "ComfyUI") isOnline = IsComfyOnline;
+ var lower = (serviceName ?? "").ToLowerInvariant();
+ if (lower.Contains("ollama")) isOnline = IsOllamaOnline;
+ else if (lower.Contains("forge")) isOnline = IsForgeOnline;
+ else if (lower.Contains("comfy")) isOnline = IsComfyOnline;
+ else if (lower.Contains("kokoro") || lower.Contains("audio")) isOnline = IsKokoroOnline;
ManageServiceIsStart = !isOnline;
string action = ManageServiceIsStart ? "start" : "stop";
@@ -87,12 +184,22 @@ public void CancelManageService()
public async Task ConfirmManageServiceAsync()
{
IsManageServiceModalOpen = false;
+ var serviceName = ManageServiceTarget ?? "Service";
+ var isStart = ManageServiceIsStart;
+ var action = isStart ? "start" : "stop";
+
+ var lower = serviceName.Trim().ToLowerInvariant();
+ string engineName = "ollama";
+ if (lower.Contains("comfy")) engineName = "comfy";
+ else if (lower.Contains("forge")) engineName = "forge";
+ else if (lower.Contains("audio") || lower.Contains("kokoro")) engineName = "audio";
+ else if (lower.Contains("ollama")) engineName = "ollama";
+
+ ToastService.Instance.Show($"{(isStart ? "Starting" : "Stopping")} {serviceName}...", ToastType.Info, 2500);
+
try
{
- var actionEndpoint = ManageServiceIsStart ? "/api/comfy/start" : "/api/comfy/stop";
- var engineName = ManageServiceTarget.ToLower().Replace(" sd", "");
- if (ManageServiceTarget == "ComfyUI") engineName = "comfy";
-
+ var actionEndpoint = isStart ? $"/api/{engineName}/start" : $"/api/{engineName}/stop";
var req = new { engine = engineName };
var content = new StringContent(
System.Text.Json.JsonSerializer.Serialize(req),
@@ -100,15 +207,190 @@ public async Task ConfirmManageServiceAsync()
"application/json"
);
using var http = HttpHelper.CreateClient(ApiBase);
- await http.PostAsync($"{ApiBase}{actionEndpoint}", content);
+ var response = await http.PostAsync($"{ApiBase}{actionEndpoint}", content);
+ if (response.IsSuccessStatusCode)
+ {
+ ToastService.Instance.Show($"{serviceName} {(isStart ? "started" : "stopped")} successfully.", ToastType.Success);
+ }
+ else
+ {
+ var errorText = await response.Content.ReadAsStringAsync();
+ var msg = string.IsNullOrWhiteSpace(errorText) ? $"Server returned {response.StatusCode}" : errorText;
+ ToastService.Instance.Show($"Failed to {action} {serviceName}: {msg}", ToastType.Error, 6000);
+ }
+ }
+ catch (Exception ex)
+ {
+ ToastService.Instance.Show($"Error while attempting to {action} {serviceName}: {ex.Message}", ToastType.Error, 6000);
}
- catch { }
finally
{
await RefreshStatusAsync();
}
}
+ [RelayCommand]
+ public async Task ToggleEngineCardAsync(string? engineKey)
+ {
+ if (string.IsNullOrWhiteSpace(engineKey)) return;
+
+ var key = engineKey.Trim().ToLowerInvariant();
+ EngineStatusCardModel? card = key switch
+ {
+ "ollama" => OllamaCard,
+ "comfy" or "comfyui" => ComfyUiCard,
+ "forge" or "sdforge" => ForgeCard,
+ "kokoro" or "audio" => KokoroCard,
+ _ => null
+ };
+
+ if (card == null) return;
+
+ if (OnManageServiceRequested != null)
+ {
+ OnManageServiceRequested.Invoke(card.DisplayName);
+ return;
+ }
+
+ bool isOnline = card.IsOnline;
+ bool isStarting = !isOnline;
+ card.IsStarting = isStarting;
+ card.StatusTooltip = $"{card.DisplayName}: {(isStarting ? "Starting..." : "Stopping...")} (Port {card.Port})";
+
+ string apiEngine = key switch
+ {
+ "kokoro" => "audio",
+ "comfyui" => "comfy",
+ _ => key
+ };
+
+ var action = isStarting ? "start" : "stop";
+ ToastService.Instance.Show($"{(isStarting ? "Starting" : "Stopping")} {card.DisplayName}...", ToastType.Info, 2500);
+
+ try
+ {
+ var actionEndpoint = $"/api/{apiEngine}/{action}";
+ var req = new { engine = apiEngine };
+ var content = new StringContent(
+ System.Text.Json.JsonSerializer.Serialize(req),
+ System.Text.Encoding.UTF8,
+ "application/json"
+ );
+ using var http = HttpHelper.CreateClient(ApiBase);
+ var response = await http.PostAsync($"{ApiBase}{actionEndpoint}", content);
+ if (response.IsSuccessStatusCode)
+ {
+ ToastService.Instance.Show($"{card.DisplayName} {(isStarting ? "started" : "stopped")} successfully.", ToastType.Success);
+ }
+ else
+ {
+ var errorText = await response.Content.ReadAsStringAsync();
+ var msg = string.IsNullOrWhiteSpace(errorText) ? $"Server returned {response.StatusCode}" : errorText;
+ ToastService.Instance.Show($"Failed to {action} {card.DisplayName}: {msg}", ToastType.Error, 6000);
+ }
+ }
+ catch (Exception ex)
+ {
+ ToastService.Instance.Show($"Error while attempting to {action} {card.DisplayName}: {ex.Message}", ToastType.Error, 6000);
+ }
+ finally
+ {
+ await RefreshStatusAsync();
+ }
+ }
+
+ public void UpdateFromTelemetry(TelemetryData data)
+ {
+ if (data == null) return;
+
+ if (OllamaCard != null)
+ {
+ OllamaCard.IsOnline = data.OllamaOnline;
+ OllamaCard.IsStarting = data.OllamaStarting;
+ if (!string.IsNullOrEmpty(data.OllamaModel))
+ {
+ OllamaCard.ActiveModel = data.OllamaModel;
+ OllamaModelName = data.OllamaModel;
+ }
+ if (!string.IsNullOrEmpty(data.OllamaMemory))
+ {
+ OllamaCard.MemoryUsage = data.OllamaMemory;
+ }
+ OllamaStatus = data.OllamaOnline ? "Online" : (data.OllamaStarting ? "Starting" : "Offline");
+ OllamaCard.StatusTooltip = $"{OllamaCard.DisplayName}: {(OllamaCard.IsOnline ? "Online" : OllamaCard.IsStarting ? "Starting..." : "Offline")} (Port {OllamaCard.Port})" +
+ (!string.IsNullOrEmpty(OllamaCard.ActiveModel) ? $" | Model: {OllamaCard.ActiveModel}" : "");
+ }
+
+ if (ComfyUiCard != null)
+ {
+ ComfyUiCard.IsOnline = data.ComfyOnline;
+ ComfyUiCard.IsStarting = data.ComfyStarting;
+ if (!string.IsNullOrEmpty(data.ComfyModel))
+ {
+ ComfyUiCard.ActiveModel = data.ComfyModel;
+ ComfyModelName = data.ComfyModel;
+ }
+ if (!string.IsNullOrEmpty(data.ComfyMemory))
+ {
+ ComfyUiCard.MemoryUsage = data.ComfyMemory;
+ }
+ ComfyStatus = data.ComfyOnline ? "Online" : (data.ComfyStarting ? "Starting" : "Offline");
+ ComfyUiCard.StatusTooltip = $"{ComfyUiCard.DisplayName}: {(ComfyUiCard.IsOnline ? "Online" : ComfyUiCard.IsStarting ? "Starting..." : "Offline")} (Port {ComfyUiCard.Port})" +
+ (!string.IsNullOrEmpty(ComfyUiCard.ActiveModel) ? $" | Model: {ComfyUiCard.ActiveModel}" : "");
+ }
+
+ if (ForgeCard != null)
+ {
+ ForgeCard.IsOnline = data.ForgeOnline;
+ ForgeCard.IsStarting = data.ForgeStarting;
+ if (!string.IsNullOrEmpty(data.ForgeModel))
+ {
+ ForgeCard.ActiveModel = data.ForgeModel;
+ ForgeModelName = data.ForgeModel;
+ }
+ if (!string.IsNullOrEmpty(data.ForgeMemory))
+ {
+ ForgeCard.MemoryUsage = data.ForgeMemory;
+ }
+ ForgeStatus = data.ForgeOnline ? "Online" : (data.ForgeStarting ? "Starting" : "Offline");
+ ForgeCard.StatusTooltip = $"{ForgeCard.DisplayName}: {(ForgeCard.IsOnline ? "Online" : ForgeCard.IsStarting ? "Starting..." : "Offline")} (Port {ForgeCard.Port})" +
+ (!string.IsNullOrEmpty(ForgeCard.ActiveModel) ? $" | Model: {ForgeCard.ActiveModel}" : "");
+ }
+
+ if (KokoroCard != null)
+ {
+ KokoroCard.IsOnline = data.KokoroOnline;
+ KokoroCard.IsStarting = data.KokoroStarting;
+ if (!string.IsNullOrEmpty(data.KokoroModel))
+ {
+ KokoroCard.ActiveModel = data.KokoroModel;
+ KokoroModelName = data.KokoroModel;
+ }
+ if (!string.IsNullOrEmpty(data.KokoroMemory))
+ {
+ KokoroCard.MemoryUsage = data.KokoroMemory;
+ }
+ KokoroStatus = data.KokoroOnline ? "Online" : (data.KokoroStarting ? "Starting" : "Offline");
+ KokoroCard.StatusTooltip = $"{KokoroCard.DisplayName}: {(KokoroCard.IsOnline ? "Online" : KokoroCard.IsStarting ? "Starting..." : "Offline")} (Port {KokoroCard.Port})" +
+ (!string.IsNullOrEmpty(KokoroCard.ActiveModel) ? $" | Model: {KokoroCard.ActiveModel}" : "");
+ }
+
+ if (!string.IsNullOrEmpty(data.GpuName))
+ {
+ GpuName = data.GpuName;
+ }
+ if (data.TotalVramGb > 0)
+ {
+ VramTotalGb = data.TotalVramGb;
+ VramUsedGb = data.UsedVramGb;
+ VramPercentage = data.VramPercent > 0 ? data.VramPercent : Math.Round((VramUsedGb / VramTotalGb) * 100.0, 1);
+ VramStatusText = $"{VramUsedGb} GB / {VramTotalGb} GB ({VramPercentage}%)";
+ }
+
+ IsServiceRunning = (OllamaCard?.IsOnline ?? false) || (ComfyUiCard?.IsOnline ?? false) || (ForgeCard?.IsOnline ?? false) || (KokoroCard?.IsOnline ?? false);
+ ServiceModeText = IsServiceRunning ? "Service Connected 🟢" : "Connecting...";
+ }
+
[RelayCommand]
public async Task RefreshStatusAsync()
{
@@ -129,7 +411,26 @@ public async Task CheckHealthAsync(string apiBase, string comfyUrl, HttpClient h
ForgeStatus = forge ? "Online" : "Offline";
ComfyStatus = comfy ? "Online" : "Offline";
- IsServiceRunning = ollama || forge || comfy;
+ if (OllamaCard != null)
+ {
+ OllamaCard.IsOnline = ollama;
+ OllamaCard.StatusTooltip = $"{OllamaCard.DisplayName}: {(ollama ? "Online" : "Offline")} (Port {OllamaCard.Port})" +
+ (!string.IsNullOrEmpty(OllamaCard.ActiveModel) ? $" | Model: {OllamaCard.ActiveModel}" : "");
+ }
+ if (ForgeCard != null)
+ {
+ ForgeCard.IsOnline = forge;
+ ForgeCard.StatusTooltip = $"{ForgeCard.DisplayName}: {(forge ? "Online" : "Offline")} (Port {ForgeCard.Port})" +
+ (!string.IsNullOrEmpty(ForgeCard.ActiveModel) ? $" | Model: {ForgeCard.ActiveModel}" : "");
+ }
+ if (ComfyUiCard != null)
+ {
+ ComfyUiCard.IsOnline = comfy;
+ ComfyUiCard.StatusTooltip = $"{ComfyUiCard.DisplayName}: {(comfy ? "Online" : "Offline")} (Port {ComfyUiCard.Port})" +
+ (!string.IsNullOrEmpty(ComfyUiCard.ActiveModel) ? $" | Model: {ComfyUiCard.ActiveModel}" : "");
+ }
+
+ IsServiceRunning = ollama || forge || comfy || (KokoroCard?.IsOnline ?? false);
ServiceModeText = IsServiceRunning ? "Service Connected 🟢" : "Connecting...";
}
diff --git a/LocalLLMServerManager.Shared/Views/Controls/AiAssistantTabControl.axaml.cs b/LocalLLMServerManager.Shared/Views/Controls/AiAssistantTabControl.axaml.cs
index 9c51edb..1754b56 100644
--- a/LocalLLMServerManager.Shared/Views/Controls/AiAssistantTabControl.axaml.cs
+++ b/LocalLLMServerManager.Shared/Views/Controls/AiAssistantTabControl.axaml.cs
@@ -82,7 +82,7 @@ public AiAssistantTabControl()
protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
{
base.OnAttachedToVisualTree(e);
- if (DataContext is AiAssistantViewModel vm && vm.IsDrawerOpen && vm.AvailableModelCapabilities.Count <= 1)
+ if (DataContext is AiAssistantViewModel vm && vm.AvailableModelCapabilities.Count <= 1)
{
_ = vm.LoadAvailableModelsAsync();
}
diff --git a/LocalLLMServerManager.Shared/Views/Controls/DocumentationTabControl.axaml b/LocalLLMServerManager.Shared/Views/Controls/DocumentationTabControl.axaml
index d55c9cb..4e88b0d 100644
--- a/LocalLLMServerManager.Shared/Views/Controls/DocumentationTabControl.axaml
+++ b/LocalLLMServerManager.Shared/Views/Controls/DocumentationTabControl.axaml
@@ -73,6 +73,17 @@
+
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
diff --git a/LocalLLMServerManager.Shared/Views/Controls/EngineStudioTabControl.axaml b/LocalLLMServerManager.Shared/Views/Controls/EngineStudioTabControl.axaml
index f11e629..7f3ab2d 100644
--- a/LocalLLMServerManager.Shared/Views/Controls/EngineStudioTabControl.axaml
+++ b/LocalLLMServerManager.Shared/Views/Controls/EngineStudioTabControl.axaml
@@ -8,726 +8,647 @@
x:Class="LocalLLMServerManager.Shared.Views.Controls.EngineStudioTabControl"
x:DataType="vm:MainViewModel">
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
-
-
-
-
+
+
+
+
+
+
+
-
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
+
+
+
+
+
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
+
+
-
-
-
-
+
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
-
-
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
-
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
-
-
-
+
+
+
+
-
-
-
-
-
-
-
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
-
-
-
-
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/LocalLLMServerManager.Shared/Views/Controls/SettingsTabControl.axaml b/LocalLLMServerManager.Shared/Views/Controls/SettingsTabControl.axaml
index 61fdd6c..3288d41 100644
--- a/LocalLLMServerManager.Shared/Views/Controls/SettingsTabControl.axaml
+++ b/LocalLLMServerManager.Shared/Views/Controls/SettingsTabControl.axaml
@@ -6,10 +6,44 @@
x:DataType="vm:SettingsViewModel">
+
+
+
+
+
+
+
+
-
+
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
-
+
+
-
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
-
+
+
+
+
-
-
-
-
-
-
-
-
-
+
+
+
+
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
+
+ 💾 Save Settings
+
-
-
-
-
-
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
-
+
+
+
+
+
+
-
-
-
-
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
-
- 💾 Save Settings
-
+
+
+
-
-
-
+
+
+
diff --git a/LocalLLMServerManager.Shared/Views/Controls/TelemetryHeaderControl.axaml b/LocalLLMServerManager.Shared/Views/Controls/TelemetryHeaderControl.axaml
index 3f6e8e6..f766a40 100644
--- a/LocalLLMServerManager.Shared/Views/Controls/TelemetryHeaderControl.axaml
+++ b/LocalLLMServerManager.Shared/Views/Controls/TelemetryHeaderControl.axaml
@@ -6,9 +6,9 @@
-
-
-
+
+
+
@@ -16,34 +16,62 @@
-
-
-
-
+
+
+
+
-
-
+
+
-
-
-
-
-
+
+
+
+
+
+
+
+
-
-
+
+
-
-
-
-
-
+
+
+
+
+
+
+
+
-
-
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -97,26 +125,44 @@
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
+
+
+
+
+
+
-
-
-
-
-
+
+
+
+
+
+
-
+
diff --git a/LocalLLMServerManager.Shared/Views/Controls/TelemetryRibbonControl.axaml b/LocalLLMServerManager.Shared/Views/Controls/TelemetryRibbonControl.axaml
index 0f5cbd7..d18ab86 100644
--- a/LocalLLMServerManager.Shared/Views/Controls/TelemetryRibbonControl.axaml
+++ b/LocalLLMServerManager.Shared/Views/Controls/TelemetryRibbonControl.axaml
@@ -13,8 +13,8 @@
-
-
+
+