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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ on:

jobs:
build-and-test:
runs-on: windows-latest
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [windows-latest, ubuntu-latest]

steps:
- name: Checkout Code
Expand All @@ -29,7 +33,8 @@ jobs:
run: dotnet build LocalLLMServerManager.slnx -c Release --no-restore

- name: Install Playwright Browsers
run: pwsh LocalLLMServerManager.Tests/bin/Release/net10.0/playwright.ps1 install chromium
run: pwsh LocalLLMServerManager.Tests/bin/Release/net10.0/playwright.ps1 install --with-deps chromium
shell: pwsh

- name: Run Unit Tests & Collect Code Coverage
run: dotnet test LocalLLMServerManager.Tests/LocalLLMServerManager.Tests.csproj -c Release --collect:"XPlat Code Coverage" --nologo
10 changes: 10 additions & 0 deletions Endpoints/DiscoveryEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,16 @@ public static void MapDiscoveryEndpoints(this WebApplication app)
results[nameof(request.AudioEngineExecutablePath)] = discoveryService.ValidatePath(request.AudioEngineExecutablePath, PathTargetType.Executable);
}

if (request.FFmpegExecutablePath != null)
{
results[nameof(request.FFmpegExecutablePath)] = discoveryService.ValidatePath(request.FFmpegExecutablePath, PathTargetType.Executable);
}

if (request.PythonExecutablePath != null)
{
results[nameof(request.PythonExecutablePath)] = discoveryService.ValidatePath(request.PythonExecutablePath, PathTargetType.Executable);
}

var allValid = results.Values.All(r => r.IsValid);
return Results.Ok(new ValidatePathsResponse(results, allValid));
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<Version>3.7.0</Version>
<AssemblyVersion>3.7.0.0</AssemblyVersion>
<FileVersion>3.7.0.0</FileVersion>
<Version>3.8.0</Version>
<AssemblyVersion>3.8.0.0</AssemblyVersion>
<FileVersion>3.8.0.0</FileVersion>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>

Expand Down
103 changes: 103 additions & 0 deletions LocalLLMServerManager.Tests/ToolDiscoveryServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -331,12 +331,115 @@ public async Task DetectAllToolsAsync_OnDefaultSearchRoots_DiscoversUniqueToolRo
{
Assert.NotEqual(results.ComfyUi.ExecutablePath, results.Forge.ExecutablePath);
Assert.NotEqual(results.ComfyUi.RootDirectory, results.Forge.RootDirectory);
Assert.NotNull(results.ComfyUi.ExecutablePath);
Assert.Contains("ComfyUI", results.ComfyUi.ExecutablePath, StringComparison.OrdinalIgnoreCase);
Assert.NotNull(results.Forge.ExecutablePath);
Assert.True(
results.Forge.ExecutablePath.Contains("SD_Forge", StringComparison.OrdinalIgnoreCase) ||
results.Forge.ExecutablePath.Contains("Forge", StringComparison.OrdinalIgnoreCase) ||
results.Forge.ExecutablePath.Contains("webui", StringComparison.OrdinalIgnoreCase)
);
}
}

[Fact]
public void DetectFFmpeg_WhenBinaryExistsInSearchRoot_DiscoversFFmpeg()
{
var binFolder = Path.Combine(_tempDirectory, "FFmpeg", "bin");
Directory.CreateDirectory(binFolder);
var binaryName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "ffmpeg.exe" : "ffmpeg";
var ffmpegFile = Path.Combine(binFolder, binaryName);
File.WriteAllText(ffmpegFile, "dummy");

var service = new ToolDiscoveryService(searchRoots: new[] { _tempDirectory });
var result = service.DetectFFmpeg();

Assert.True(result.IsInstalled);
Assert.Equal(ffmpegFile, result.ExecutablePath);
Assert.Contains("Discovered FFmpeg", result.StatusMessage);
}

[Fact]
public void DetectFFmpeg_WhenMissing_ReturnsNotDetected()
{
var emptyFolder = Path.Combine(_tempDirectory, "EmptyFFmpegRoot");
Directory.CreateDirectory(emptyFolder);

var service = new ToolDiscoveryService(searchRoots: new[] { emptyFolder });
// If system has ffmpeg on PATH, it may find it, but if not it should return false or valid discovery
var result = service.DetectFFmpeg();
Assert.NotNull(result);
Assert.NotNull(result.StatusMessage);
}

[Fact]
public void DetectPythonEnvironment_WhenVirtualEnvExists_DiscoversPython()
{
var venvDir = Path.Combine(_tempDirectory, "test_env");
var scriptDir = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
? Path.Combine(venvDir, "Scripts")
: Path.Combine(venvDir, "bin");
Directory.CreateDirectory(scriptDir);

var pyBinary = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "python.exe" : "python3";
var pyFile = Path.Combine(scriptDir, pyBinary);
File.WriteAllText(pyFile, "dummy");

var service = new ToolDiscoveryService(searchRoots: new[] { venvDir });
var result = service.DetectPythonEnvironment();

Assert.True(result.IsInstalled);
Assert.Equal(pyFile, result.ExecutablePath);
Assert.Contains("Discovered Python", result.StatusMessage);
}

[Fact]
public void DetectForge_WithLinuxShellRunner_DiscoversForge()
{
var forgeFolder = Path.Combine(_tempDirectory, "stable-diffusion-webui");
Directory.CreateDirectory(forgeFolder);
var modelsFolder = Path.Combine(forgeFolder, "models");
Directory.CreateDirectory(modelsFolder);
var scriptPath = Path.Combine(forgeFolder, "webui.sh");
File.WriteAllText(scriptPath, "#!/usr/bin/env bash\npython launch.py");

var service = new ToolDiscoveryService(searchRoots: new[] { _tempDirectory });
var result = service.DetectForge();

Assert.True(result.IsInstalled);
Assert.Equal(scriptPath, result.ExecutablePath);
Assert.Equal(forgeFolder, result.RootDirectory);
Assert.Equal(modelsFolder, result.ModelsDirectory);
}

[Fact]
public void DetectComfyUi_WithLinuxShellRunner_DiscoversComfy()
{
var comfyFolder = Path.Combine(_tempDirectory, "ComfyUI");
Directory.CreateDirectory(comfyFolder);
var modelsFolder = Path.Combine(comfyFolder, "models");
Directory.CreateDirectory(modelsFolder);
var scriptPath = Path.Combine(comfyFolder, "run.sh");
File.WriteAllText(scriptPath, "#!/usr/bin/env bash\npython main.py");

var service = new ToolDiscoveryService(searchRoots: new[] { _tempDirectory });
var result = service.DetectComfyUi();

Assert.True(result.IsInstalled);
Assert.Equal(scriptPath, result.ExecutablePath);
Assert.Equal(comfyFolder, result.RootDirectory);
Assert.Equal(modelsFolder, result.ModelsDirectory);
}

[Fact]
public async Task DetectAllToolsAsync_IncludesFFmpegAndPython()
{
var service = new ToolDiscoveryService(searchRoots: new[] { _tempDirectory });
var result = await service.DetectAllToolsAsync();

Assert.NotNull(result);
Assert.NotNull(result.FFmpeg);
Assert.NotNull(result.PythonEnvironment);
Assert.NotNull(result.AudioEngine);
}
}
6 changes: 3 additions & 3 deletions LocalLLMServerManager.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@
MINOR — new user-facing features (bump per feature PR)
PATCH — bug fixes, dependency updates, doc-only changes
-->
<Version>3.7.0</Version>
<AssemblyVersion>3.7.0.0</AssemblyVersion>
<FileVersion>3.7.0.0</FileVersion>
<Version>3.8.0</Version>
<AssemblyVersion>3.8.0.0</AssemblyVersion>
<FileVersion>3.8.0.0</FileVersion>
<ApplicationIcon>Assets\app-icon.ico</ApplicationIcon>
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
</PropertyGroup>
Expand Down
14 changes: 12 additions & 2 deletions Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,13 @@ public static void MainInternal(string[] args, bool runWeb = false)
{
if (args.Contains("--server") || args.Contains("--headless"))
{
Directory.SetCurrentDirectory(AppContext.BaseDirectory);
var app = CreateWebApplication(args, isServiceMode: false);
if (runWeb) app.Run();
}
else if (args.Contains("--service"))
{
Directory.SetCurrentDirectory(AppContext.BaseDirectory);
var app = CreateWebApplication(args, isServiceMode: true);
if (runWeb) app.Run();
}
Expand Down Expand Up @@ -135,7 +137,11 @@ public static bool IsSafePath(string path)

public static WebApplication CreateWebApplication(string[] args, bool isServiceMode = false, string url = "http://0.0.0.0:5246")
{
var builder = WebApplication.CreateBuilder(args);
var builder = WebApplication.CreateBuilder(new WebApplicationOptions
{
Args = args,
ContentRootPath = AppContext.BaseDirectory
});

builder.WebHost.UseUrls(url);
builder.Logging.AddFilter("System.Net.Http.HttpClient", LogLevel.Warning);
Expand Down Expand Up @@ -165,12 +171,16 @@ public static WebApplication CreateWebApplication(string[] args, bool isServiceM
}
catch { }

if (isServiceMode)
if (isServiceMode && OperatingSystem.IsWindows())
{
builder.Host.UseWindowsService(options =>
{
options.ServiceName = "LocalLLMServerManager";
});
builder.Services.AddWindowsService(options =>
{
options.ServiceName = "LocalLLMServerManager";
});
}

var app = builder.Build();
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Local LLM Server Manager

> **v3.7.0** — A unified cross-platform application (.NET 10 + Avalonia UI & WebAssembly), System Tray app, background service/daemon, Model Context Protocol (MCP) AI API, visual orchestrator dashboard, and automated Playwright E2E testing framework to manage local Large Language Models (**Ollama**), Image Generation (**Stable Diffusion / Forge & ComfyUI**), **3D Mesh Generation (TRELLIS V2 & Hunyuan3D v2)**, **Video Generation (Wan 2.2, LTX-2.5, HunyuanVideo)**, and **Audio & Speech Generation (Kokoro TTS, Stable Audio Open 3.0, YuE)** on Windows, Linux, Mobile, and Web.
> **v3.8.0** — A unified cross-platform application (.NET 10 + Avalonia UI & WebAssembly), System Tray app, background service/daemon, Model Context Protocol (MCP) AI API, visual orchestrator dashboard, and automated Playwright E2E testing framework to manage local Large Language Models (**Ollama**), Image Generation (**Stable Diffusion / Forge & ComfyUI**), **3D Mesh Generation (TRELLIS V2 & Hunyuan3D v2)**, **Video Generation (Wan 2.2, LTX-2.5, HunyuanVideo)**, and **Audio & Speech Generation (Kokoro TTS, Stable Audio Open 3.0, YuE)** on Windows, Linux, Mobile, and Web.
It features the official **`L³M²`** monochromatic brand identity, a high-contrast **Matte Carbon Design System**, a live **Dynamic Theming Engine** (Matte Carbon, OLED Black, Clean Light), integrated **`playwright-layout-inspector`** automated visual & layout audits, NVML CUDA real-time telemetry, **Hugging Face Hub** Multimodal discovery (GGUF, Text-to-Video, Image-to-Video, TTS, Text-to-Audio), **CivitAI** checkpoint downloads, **Multimodal Studio** with interactive 3D WebGL viewer, Video Player Preview, Audio Waveform Visualizer, a unified **Avalonia WebAssembly (WASM)** dashboard, **Modular Feature Packs** (`--with-video`, `--with-audio`), and an active **Model Context Protocol (MCP) Server** (`/mcp`).

![Dashboard Overview](docs/images/dashboard_desktop.png)
Expand Down Expand Up @@ -32,7 +32,7 @@ The application features a dark Fluent Avalonia UI theme (`#0F172A`) organized i
| [====================================------------------------------------------------] |
| 8,192 tokens |
+-----------------------------------------------------------------------------------------+
| LocalLLMServerManager v3.7.0 -- Unified WASM & Desktop UI System Tray Enabled 🟢 |
| LocalLLMServerManager v3.8.0 -- Unified WASM & Desktop UI System Tray Enabled 🟢 |
+-----------------------------------------------------------------------------------------+
```

Expand Down Expand Up @@ -354,6 +354,7 @@ We use **MAJOR.MINOR.PATCH** (SemVer):
| `3.5.0` | Flexible tool path configuration, multi-drive auto-discovery service (`IToolDiscoveryService`), `POST /api/tools/detect`, official Model Context Protocol (MCP) server endpoint (`/mcp`) with 8 AI automation tools, and graceful in-place update support across Windows Inno Setup and shell installers |
| `3.6.0` | Monochromatic L³M² brand identity, Matte Carbon Design System, live Dynamic Theming Engine (Matte Carbon, OLED Black, Clean Light), and playwright-layout-inspector visual audits |
| `3.7.0` | Multimodal Video & Audio Studio (Wan 2.2, LTX-2.5, HunyuanVideo, Kokoro TTS, Stable Audio Open 3.0, YuE), interactive Video Player and Audio Waveform controls, Multimodal Hugging Face Discovery filters, 3 new MCP AI Tools (`generate_video`, `synthesize_speech`, `generate_audio`), OpenAI-compatible `/v1/audio/speech`, and Modular Feature Packs (`--with-video`, `--with-audio`) |
| `3.8.0` | Cross-Platform Tool Discovery (FFmpeg hardware encoder detection: NVENC, Intel QSV, VAAPI, AMD AMF; Kokoro Python environment inspection; Linux paths & shell runners), Dual-OS GitHub Actions CI Matrix (`[windows-latest, ubuntu-latest]`), Windows Service directory handling & Linux headless guard, and enhanced Windows & Linux installers with automated Firewall rule creation and LAN/MCP endpoint summaries |

---

Expand Down
10 changes: 8 additions & 2 deletions Services/IToolDiscoveryService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ public interface IToolDiscoveryService
DiscoveredToolInfo DetectComfyUi();
DiscoveredToolInfo DetectForge();
DiscoveredToolInfo DetectAudioEngine();
DiscoveredToolInfo DetectFFmpeg();
DiscoveredToolInfo DetectPythonEnvironment();
PathValidationResult ValidatePath(string? path, PathTargetType targetType);
}

Expand All @@ -27,7 +29,9 @@ public record DiscoveredToolsResult(
DiscoveredToolInfo Forge,
string SuggestedThreeDPath,
string SuggestedWorkflowsPath,
DiscoveredToolInfo AudioEngine = default!
DiscoveredToolInfo AudioEngine = default!,
DiscoveredToolInfo FFmpeg = default!,
DiscoveredToolInfo PythonEnvironment = default!
);

public enum PathTargetType
Expand All @@ -50,7 +54,9 @@ public record ValidatePathsRequest(
string? ComfyUiExecutablePath = null,
string? ForgeExecutablePath = null,
string? OllamaExecutablePath = null,
string? AudioEngineExecutablePath = null
string? AudioEngineExecutablePath = null,
string? FFmpegExecutablePath = null,
string? PythonExecutablePath = null
);

public record ValidatePathsResponse(
Expand Down
Loading
Loading