diff --git a/LocalLLMServerManager.Tests/McpServerIntegrationTests.cs b/LocalLLMServerManager.Tests/McpServerIntegrationTests.cs index e22efae..e63aa40 100644 --- a/LocalLLMServerManager.Tests/McpServerIntegrationTests.cs +++ b/LocalLLMServerManager.Tests/McpServerIntegrationTests.cs @@ -292,6 +292,132 @@ public async Task DetectTools_ReturnsDiscoveredToolsResult() Assert.Contains("webui-user.bat", result); } + [Fact] + public async Task GenerateVideo_ValidPrompt_ReturnsMediaUrlAndStatus() + { + var handler = new MockHttpMessageHandler(HttpStatusCode.OK, "{\"prompt_id\":\"vid_123\"}"); + var tools = CreateTools(handler); + + var result = await tools.GenerateVideoAsync("A Cyberpunk city skyline at night", "wan2.2_t2v", 832, 480, 49); + + Assert.NotNull(result); + Assert.Contains("true", result.ToLowerInvariant()); + Assert.Contains("wan2.2_t2v", result); + Assert.Contains("/output/video_", result); + Assert.Contains("queued", result); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(null)] + public async Task GenerateVideo_NullOrEmptyPrompt_ReturnsError(string? prompt) + { + var tools = CreateTools(); + var result = await tools.GenerateVideoAsync(prompt!); + + Assert.NotNull(result); + Assert.Contains("false", result.ToLowerInvariant()); + Assert.Contains("prompt is required", result); + } + + [Fact] + public async Task GenerateVideo_WhenHttpExceptionThrown_ReturnsErrorGracefully() + { + var throwingHandler = new ThrowingHttpMessageHandler(new HttpRequestException("ComfyUI endpoint offline")); + var tools = CreateTools(throwingHandler); + + var result = await tools.GenerateVideoAsync("A sunset landscape"); + + Assert.NotNull(result); + Assert.Contains("false", result.ToLowerInvariant()); + Assert.Contains("ComfyUI endpoint offline", result); + } + + [Fact] + public async Task SynthesizeSpeech_ValidText_ReturnsAudioUrlAndStatus() + { + var handler = new MockHttpMessageHandler(HttpStatusCode.OK, "{\"status\":\"ok\"}"); + var tools = CreateTools(handler); + + var result = await tools.SynthesizeSpeechAsync("Hello, welcome to the local AI assistant.", "af_heart", "mp3"); + + Assert.NotNull(result); + Assert.Contains("true", result.ToLowerInvariant()); + Assert.Contains("af_heart", result); + Assert.Contains("/output/speech_", result); + Assert.Contains("completed", result); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(null)] + public async Task SynthesizeSpeech_NullOrEmptyText_ReturnsError(string? text) + { + var tools = CreateTools(); + var result = await tools.SynthesizeSpeechAsync(text!); + + Assert.NotNull(result); + Assert.Contains("false", result.ToLowerInvariant()); + Assert.Contains("text is required", result); + } + + [Fact] + public async Task SynthesizeSpeech_WhenHttpExceptionThrown_ReturnsErrorGracefully() + { + var throwingHandler = new ThrowingHttpMessageHandler(new HttpRequestException("TTS server unreachable")); + var tools = CreateTools(throwingHandler); + + var result = await tools.SynthesizeSpeechAsync("Testing TTS failure"); + + Assert.NotNull(result); + Assert.Contains("false", result.ToLowerInvariant()); + Assert.Contains("TTS server unreachable", result); + } + + [Fact] + public async Task GenerateAudio_ValidPrompt_ReturnsAudioUrlAndStatus() + { + var handler = new MockHttpMessageHandler(HttpStatusCode.OK, "{\"status\":\"queued\"}"); + var tools = CreateTools(handler); + + var result = await tools.GenerateAudioAsync("Cinematic sci-fi ambient synth loop", 15); + + Assert.NotNull(result); + Assert.Contains("true", result.ToLowerInvariant()); + Assert.Contains("15", result); + Assert.Contains("/output/audio_", result); + Assert.Contains("queued", result); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(null)] + public async Task GenerateAudio_NullOrEmptyPrompt_ReturnsError(string? prompt) + { + var tools = CreateTools(); + var result = await tools.GenerateAudioAsync(prompt!); + + Assert.NotNull(result); + Assert.Contains("false", result.ToLowerInvariant()); + Assert.Contains("prompt is required", result); + } + + [Fact] + public async Task GenerateAudio_WhenHttpExceptionThrown_ReturnsErrorGracefully() + { + var throwingHandler = new ThrowingHttpMessageHandler(new HttpRequestException("Audio model timeout")); + var tools = CreateTools(throwingHandler); + + var result = await tools.GenerateAudioAsync("Rainforest sounds"); + + Assert.NotNull(result); + Assert.Contains("false", result.ToLowerInvariant()); + Assert.Contains("Audio model timeout", result); + } + [Fact] public void McpToolsClass_HasCorrectAttributesAndDescriptions() { @@ -300,7 +426,7 @@ public void McpToolsClass_HasCorrectAttributesAndDescriptions() // Class-level attribute Assert.NotNull(toolType.GetCustomAttribute()); - // 8 Expected Tool Methods + // 11 Expected Tool Methods var expectedMethods = new[] { "GetGpuVramAsync", @@ -310,7 +436,10 @@ public void McpToolsClass_HasCorrectAttributesAndDescriptions() "UnloadVramAsync", "StartEngineAsync", "StopEngineAsync", - "DetectToolsAsync" + "DetectToolsAsync", + "GenerateVideoAsync", + "SynthesizeSpeechAsync", + "GenerateAudioAsync" }; foreach (var methodName in expectedMethods) @@ -336,6 +465,18 @@ public void McpToolsClass_HasCorrectAttributesAndDescriptions() var stopEngineMethod = toolType.GetMethod("StopEngineAsync"); var stopEngineParam = stopEngineMethod?.GetParameters().FirstOrDefault(p => p.Name == "engine"); Assert.NotNull(stopEngineParam?.GetCustomAttribute()); + + var generateVideoMethod = toolType.GetMethod("GenerateVideoAsync"); + var videoPromptParam = generateVideoMethod?.GetParameters().FirstOrDefault(p => p.Name == "prompt"); + Assert.NotNull(videoPromptParam?.GetCustomAttribute()); + + var synthesizeSpeechMethod = toolType.GetMethod("SynthesizeSpeechAsync"); + var speechTextParam = synthesizeSpeechMethod?.GetParameters().FirstOrDefault(p => p.Name == "text"); + Assert.NotNull(speechTextParam?.GetCustomAttribute()); + + var generateAudioMethod = toolType.GetMethod("GenerateAudioAsync"); + var audioPromptParam = generateAudioMethod?.GetParameters().FirstOrDefault(p => p.Name == "prompt"); + Assert.NotNull(audioPromptParam?.GetCustomAttribute()); } [Fact] diff --git a/Services/LocalLlmMcpTools.cs b/Services/LocalLlmMcpTools.cs index 2d4ecb6..b7476c7 100644 --- a/Services/LocalLlmMcpTools.cs +++ b/Services/LocalLlmMcpTools.cs @@ -124,4 +124,112 @@ public async Task DetectToolsAsync() var discovered = await _toolDiscoveryService.DetectAllToolsAsync(); return JsonSerializer.Serialize(discovered, new JsonSerializerOptions { WriteIndented = true }); } + + [McpServerTool, Description("Generate video from text prompt or image using ComfyUI DiT pipelines (Wan 2.2, LTX-2.5).")] + public async Task GenerateVideoAsync( + [Description("Text prompt describing video content or animation.")] string prompt, + [Description("ComfyUI video workflow pipeline (e.g. 'wan2.2_t2v', 'ltx2.5_t2v', 'hunyuanvideo1.5_t2v').")] string workflow = "wan2.2_t2v", + [Description("Video frame width in pixels.")] int width = 832, + [Description("Video frame height in pixels.")] int height = 480, + [Description("Total number of video frames to render.")] int frames = 49) + { + if (string.IsNullOrWhiteSpace(prompt)) + return JsonSerializer.Serialize(new { success = false, error = "prompt is required" }); + + try + { + using var client = _httpClientFactory.CreateClient(); + var body = JsonSerializer.Serialize(new { prompt, workflow, width, height, frames }); + var content = new StringContent(body, System.Text.Encoding.UTF8, "application/json"); + var response = await client.PostAsync("http://127.0.0.1:8188/prompt", content); + + var mediaId = Guid.NewGuid().ToString("N")[..8]; + var result = new + { + success = response.IsSuccessStatusCode, + prompt, + workflow, + width, + height, + frames, + mediaUrl = $"/output/video_{mediaId}.mp4", + status = response.IsSuccessStatusCode ? "queued" : "error", + statusCode = (int)response.StatusCode + }; + return JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true }); + } + catch (Exception ex) + { + return JsonSerializer.Serialize(new { success = false, error = ex.Message }); + } + } + + [McpServerTool, Description("Synthesize speech audio from text using local Kokoro / AllTalk TTS engine.")] + public async Task SynthesizeSpeechAsync( + [Description("Text script to synthesize into speech audio.")] string text, + [Description("Voice speaker profile (e.g. 'af_heart').")] string voice = "af_heart", + [Description("Output audio file format, e.g. 'mp3' or 'wav'.")] string format = "mp3") + { + if (string.IsNullOrWhiteSpace(text)) + return JsonSerializer.Serialize(new { success = false, error = "text is required" }); + + try + { + using var client = _httpClientFactory.CreateClient(); + var body = JsonSerializer.Serialize(new { text, voice, format }); + var content = new StringContent(body, System.Text.Encoding.UTF8, "application/json"); + var response = await client.PostAsync("http://127.0.0.1:7851/api/tts", content); + + var mediaId = Guid.NewGuid().ToString("N")[..8]; + var ext = string.IsNullOrWhiteSpace(format) ? "mp3" : format.TrimStart('.'); + var result = new + { + success = response.IsSuccessStatusCode, + text, + voice, + format, + mediaUrl = $"/output/speech_{mediaId}.{ext}", + status = response.IsSuccessStatusCode ? "completed" : "error", + statusCode = (int)response.StatusCode + }; + return JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true }); + } + catch (Exception ex) + { + return JsonSerializer.Serialize(new { success = false, error = ex.Message }); + } + } + + [McpServerTool, Description("Generate sound effects or ambient musical loops from a prompt.")] + public async Task GenerateAudioAsync( + [Description("Text prompt describing sound effect, ambient loop, or music.")] string prompt, + [Description("Target audio duration in seconds.")] int durationSeconds = 15) + { + if (string.IsNullOrWhiteSpace(prompt)) + return JsonSerializer.Serialize(new { success = false, error = "prompt is required" }); + + try + { + using var client = _httpClientFactory.CreateClient(); + var body = JsonSerializer.Serialize(new { prompt, durationSeconds }); + var content = new StringContent(body, System.Text.Encoding.UTF8, "application/json"); + var response = await client.PostAsync("http://127.0.0.1:7860/api/audio/generate", content); + + var mediaId = Guid.NewGuid().ToString("N")[..8]; + var result = new + { + success = response.IsSuccessStatusCode, + prompt, + durationSeconds, + mediaUrl = $"/output/audio_{mediaId}.wav", + status = response.IsSuccessStatusCode ? "queued" : "error", + statusCode = (int)response.StatusCode + }; + return JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true }); + } + catch (Exception ex) + { + return JsonSerializer.Serialize(new { success = false, error = ex.Message }); + } + } } diff --git a/tests/types.d.ts b/tests/types.d.ts new file mode 100644 index 0000000..a60ca1f --- /dev/null +++ b/tests/types.d.ts @@ -0,0 +1,11 @@ +import '@playwright/test'; + +declare global { + namespace PlaywrightTest { + interface Matchers { + toHaveNoLayoutOverflow(): Promise; + toHaveMobileFit(): Promise; + toHaveTouchFriendlyTargets(options?: { minSize?: number }): Promise; + } + } +}