diff --git a/Endpoints/AiAssistantEndpoints.cs b/Endpoints/AiAssistantEndpoints.cs
new file mode 100644
index 0000000..d3a3ddb
--- /dev/null
+++ b/Endpoints/AiAssistantEndpoints.cs
@@ -0,0 +1,194 @@
+using System;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using LocalLLMServerManager.Services;
+using LocalLLMServerManager.Shared.Interfaces;
+using LocalLLMServerManager.Shared.Models;
+using LocalLLMServerManager.Shared.Services;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+
+namespace LocalLLMServerManager.Endpoints;
+
+public static class AiAssistantEndpoints
+{
+ private static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ PropertyNameCaseInsensitive = true,
+ Converters = { new JsonStringEnumConverter() }
+ };
+
+ public static void MapAiAssistantEndpoints(this WebApplication app)
+ {
+ // 1. Status
+ app.MapGet("/api/ai/status", async (
+ ISettingsService settingsService,
+ IComponentManagerService componentService,
+ IPromptManagementService promptService) =>
+ {
+ var settings = settingsService.LoadSettings();
+ var isInstalled = componentService.IsAiAssistantInstalled;
+ var promptDir = PromptManagementService.ResolvePromptDirectory(settings.AiAssistantPromptsDirectory);
+ var sections = await promptService.GetAllPromptsAsync(promptDir);
+
+ return Results.Ok(new
+ {
+ enabled = settings.AiAssistantEnabled,
+ isInstalled,
+ configured = !string.IsNullOrWhiteSpace(settings.AiAssistantEndpoint),
+ endpoint = settings.AiAssistantEndpoint,
+ model = settings.AiAssistantModel,
+ promptDirectory = promptDir,
+ loadedPromptSections = sections.Keys
+ });
+ });
+
+ // 2. Validate Connection
+ app.MapPost("/api/ai/validate", async (
+ HttpContext httpContext,
+ IAiAssistantService assistantService,
+ ISettingsService settingsService) =>
+ {
+ string? endpoint = null;
+ string? apiKey = null;
+ string? model = null;
+
+ if (httpContext.Request.HasJsonContentType())
+ {
+ try
+ {
+ using var doc = await JsonDocument.ParseAsync(httpContext.Request.Body, cancellationToken: httpContext.RequestAborted);
+ var root = doc.RootElement;
+ if (root.TryGetProperty("endpoint", out var epEl)) endpoint = epEl.GetString();
+ if (root.TryGetProperty("apiKey", out var keyEl)) apiKey = keyEl.GetString();
+ if (root.TryGetProperty("model", out var modelEl)) model = modelEl.GetString();
+ }
+ catch (JsonException ex)
+ {
+ return Results.BadRequest(new { error = $"Invalid JSON payload: {ex.Message}" });
+ }
+ }
+
+ var result = await assistantService.ValidateConnectionAsync(endpoint, apiKey, model, httpContext.RequestAborted);
+ return Results.Ok(result);
+ });
+
+ // 3. Models
+ app.MapGet("/api/ai/models", async (
+ HttpContext httpContext,
+ IAiAssistantService assistantService,
+ ISettingsService settingsService) =>
+ {
+ var query = httpContext.Request.Query;
+ string? endpoint = query.TryGetValue("endpoint", out var epVal) && !string.IsNullOrWhiteSpace(epVal)
+ ? epVal.ToString()
+ : null;
+
+ string? apiKey = query.TryGetValue("apiKey", out var keyVal) && !string.IsNullOrWhiteSpace(keyVal)
+ ? keyVal.ToString()
+ : null;
+
+ bool includeLocal = query.TryGetValue("includeLocal", out var localVal) &&
+ bool.TryParse(localVal.ToString(), out var parsedLocal) && parsedLocal;
+
+ try
+ {
+ var models = await assistantService.GetModelCapabilitiesAsync(endpoint, apiKey, includeLocal, httpContext.RequestAborted);
+ return Results.Ok(models);
+ }
+ catch (Exception ex)
+ {
+ return Results.BadRequest(new { error = ex.Message });
+ }
+ });
+
+ // 4. Prompts
+ app.MapGet("/api/ai/prompts", async (
+ ISettingsService settingsService,
+ IPromptManagementService promptService) =>
+ {
+ var settings = settingsService.LoadSettings();
+ var promptDir = PromptManagementService.ResolvePromptDirectory(settings.AiAssistantPromptsDirectory);
+ var sections = await promptService.GetAllPromptsAsync(promptDir);
+
+ return Results.Ok(new
+ {
+ promptsDirectory = promptDir,
+ sections
+ });
+ });
+
+ app.MapPost("/api/ai/prompts/reload", async (
+ ISettingsService settingsService,
+ IPromptManagementService promptService) =>
+ {
+ promptService.InvalidateCache();
+ var settings = settingsService.LoadSettings();
+ var promptDir = PromptManagementService.ResolvePromptDirectory(settings.AiAssistantPromptsDirectory);
+ var sections = await promptService.GetAllPromptsAsync(promptDir);
+
+ return Results.Ok(new
+ {
+ success = true,
+ promptsDirectory = promptDir,
+ sections
+ });
+ });
+
+ // 5. Chat completion (SSE stream or non-stream JSON)
+ app.MapPost("/api/ai/chat", async (
+ HttpContext httpContext,
+ AiChatRequest? request,
+ IAiAssistantService assistantService) =>
+ {
+ if (request == null || request.Messages == null || request.Messages.Count == 0)
+ {
+ return Results.BadRequest(new { error = "Request body must contain 'messages' array." });
+ }
+
+ bool wantsStream = request.Stream ||
+ httpContext.Request.Query.ContainsKey("stream") ||
+ (httpContext.Request.Headers.Accept.ToString().Contains("text/event-stream", StringComparison.OrdinalIgnoreCase));
+
+ if (wantsStream)
+ {
+ httpContext.Response.ContentType = "text/event-stream";
+ httpContext.Response.Headers.CacheControl = "no-cache";
+ httpContext.Response.Headers.Connection = "keep-alive";
+
+ try
+ {
+ await foreach (var chunk in assistantService.StreamChatAsync(request, cancellationToken: httpContext.RequestAborted))
+ {
+ var json = JsonSerializer.Serialize(chunk, JsonOptions);
+ await httpContext.Response.WriteAsync($"data: {json}\n\n", httpContext.RequestAborted);
+ await httpContext.Response.Body.FlushAsync(httpContext.RequestAborted);
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ // Request aborted by client
+ }
+ catch (Exception ex)
+ {
+ var errorChunk = new AiChatChunk(Error: ex.Message, IsDone: true);
+ var json = JsonSerializer.Serialize(errorChunk, JsonOptions);
+ try
+ {
+ await httpContext.Response.WriteAsync($"data: {json}\n\n");
+ await httpContext.Response.Body.FlushAsync();
+ }
+ catch { }
+ }
+
+ return Results.Empty;
+ }
+ else
+ {
+ var response = await assistantService.SendChatAsync(request, cancellationToken: httpContext.RequestAborted);
+ return Results.Ok(response);
+ }
+ });
+ }
+}
diff --git a/LocalLLMServerManager.Shared/Interfaces/IAiAppTools.cs b/LocalLLMServerManager.Shared/Interfaces/IAiAppTools.cs
new file mode 100644
index 0000000..d4361eb
--- /dev/null
+++ b/LocalLLMServerManager.Shared/Interfaces/IAiAppTools.cs
@@ -0,0 +1,72 @@
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace LocalLLMServerManager.Shared.Interfaces;
+
+///
+/// Natural Language Integration Bridge: allows the AI Copilot to query telemetry,
+/// manage engines, update settings, trigger workflows, and query documentation.
+///
+public interface IAiAppTools
+{
+ ///
+ /// Returns live GPU VRAM usage and hardware identity.
+ ///
+ Task GetGpuVramTelemetryAsync(CancellationToken cancellationToken = default);
+
+ ///
+ /// Checks health and connectivity of Ollama, Forge, ComfyUI, and Kokoro TTS runtimes.
+ ///
+ Task CheckServicesHealthAsync(CancellationToken cancellationToken = default);
+
+ ///
+ /// Lists all installed Ollama LLM models and local model assets.
+ ///
+ Task ListInstalledModelsAsync(CancellationToken cancellationToken = default);
+
+ ///
+ /// Starts a backend engine ('forge', 'comfyui', or 'ollama').
+ ///
+ Task StartAiEngineAsync(string engine, CancellationToken cancellationToken = default);
+
+ ///
+ /// Stops an active engine ('forge' or 'comfyui').
+ ///
+ Task StopAiEngineAsync(string engine, CancellationToken cancellationToken = default);
+
+ ///
+ /// Unloads all models from VRAM to free GPU memory.
+ ///
+ Task UnloadVramAsync(CancellationToken cancellationToken = default);
+
+ ///
+ /// Retrieves current application settings.
+ ///
+ Task GetAppSettingsAsync(CancellationToken cancellationToken = default);
+
+ ///
+ /// Modifies a specific application setting key and persists to disk.
+ ///
+ Task UpdateAppSettingAsync(string key, string value, CancellationToken cancellationToken = default);
+
+ ///
+ /// Evaluates whether a specified model fits in the user's GPU VRAM and RAM.
+ ///
+ Task CalculateHardwareFitAsync(string modelName, double? parametersBillions = null, string? quantization = null, string? modality = null, CancellationToken cancellationToken = default);
+
+ ///
+ /// Triggers an image generation workflow via Forge or ComfyUI.
+ ///
+ Task GenerateImageAsync(string prompt, string? negativePrompt = null, string? engine = null, int width = 1024, int height = 1024, CancellationToken cancellationToken = default);
+
+ ///
+ /// Synthesizes text to speech using local Kokoro TTS.
+ ///
+ Task SynthesizeSpeechAsync(string text, string voice = "af_heart", string format = "mp3", CancellationToken cancellationToken = default);
+
+ ///
+ /// Searches in-app technical documentation procedures and guides.
+ ///
+ Task QueryAppDocumentationAsync(string query, CancellationToken cancellationToken = default);
+}
diff --git a/LocalLLMServerManager.Shared/Interfaces/IAiAssistantService.cs b/LocalLLMServerManager.Shared/Interfaces/IAiAssistantService.cs
new file mode 100644
index 0000000..55bb080
--- /dev/null
+++ b/LocalLLMServerManager.Shared/Interfaces/IAiAssistantService.cs
@@ -0,0 +1,53 @@
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using LocalLLMServerManager.Shared.Models;
+
+namespace LocalLLMServerManager.Shared.Interfaces;
+
+///
+/// Orchestration service interface for OpenAI-compatible LLM routing, tool execution loop,
+/// and streaming chat completion.
+///
+public interface IAiAssistantService
+{
+ ///
+ /// Validates connectivity, authentication, and model availability against the target OpenAI-compatible API endpoint.
+ ///
+ Task ValidateConnectionAsync(
+ string? endpoint = null,
+ string? apiKey = null,
+ string? model = null,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Enumerate available model IDs from the remote endpoint (via /v1/models).
+ ///
+ Task> GetAvailableModelsAsync(
+ string? endpoint = null,
+ string? apiKey = null,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Enumerate available model capabilities from the remote LiteLLM or OpenAI-compatible endpoint.
+ ///
+ Task> GetModelCapabilitiesAsync(
+ string? endpoint = null,
+ string? apiKey = null,
+ bool includeLocal = false,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Executes a full non-streaming multi-turn chat completion including automated tool calling.
+ ///
+ Task SendChatAsync(
+ AiChatRequest request,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Streams tokens and tool execution events using async enumeration.
+ ///
+ IAsyncEnumerable StreamChatAsync(
+ AiChatRequest request,
+ CancellationToken cancellationToken = default);
+}
diff --git a/LocalLLMServerManager.Shared/Interfaces/IPromptManagementService.cs b/LocalLLMServerManager.Shared/Interfaces/IPromptManagementService.cs
new file mode 100644
index 0000000..6d92bb8
--- /dev/null
+++ b/LocalLLMServerManager.Shared/Interfaces/IPromptManagementService.cs
@@ -0,0 +1,46 @@
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace LocalLLMServerManager.Shared.Interfaces;
+
+///
+/// Service responsible for managing, loading, and assembling living prompt documents from the filesystem.
+///
+public interface IPromptManagementService
+{
+ ///
+ /// Gets the core system prompt including persona and tone.
+ ///
+ Task GetSystemPromptAsync(string? customPromptDir = null, CancellationToken cancellationToken = default);
+
+ ///
+ /// Gets the application capabilities reference prompt.
+ ///
+ Task GetCapabilitiesPromptAsync(string? customPromptDir = null, CancellationToken cancellationToken = default);
+
+ ///
+ /// Gets the workflows guide prompt.
+ ///
+ Task GetWorkflowsPromptAsync(string? customPromptDir = null, CancellationToken cancellationToken = default);
+
+ ///
+ /// Gets the natural language app-control guidelines prompt.
+ ///
+ Task GetAppControlPromptAsync(string? customPromptDir = null, CancellationToken cancellationToken = default);
+
+ ///
+ /// Assembles the complete consolidated system prompt containing system instructions, capabilities, workflows, and tool calling guidance.
+ ///
+ Task BuildFullSystemPromptAsync(string? customPromptDir = null, CancellationToken cancellationToken = default);
+
+ ///
+ /// Returns all living prompts as a key-value dictionary of document names to contents.
+ ///
+ Task> GetAllPromptsAsync(string? customPromptDir = null, CancellationToken cancellationToken = default);
+
+ ///
+ /// Clears any cached prompts so modifications on disk take effect immediately.
+ ///
+ void InvalidateCache();
+}
diff --git a/LocalLLMServerManager.Shared/Interfaces/ISettingsService.cs b/LocalLLMServerManager.Shared/Interfaces/ISettingsService.cs
new file mode 100644
index 0000000..dbf76a0
--- /dev/null
+++ b/LocalLLMServerManager.Shared/Interfaces/ISettingsService.cs
@@ -0,0 +1,10 @@
+using LocalLLMServerManager.Shared.Models;
+
+namespace LocalLLMServerManager.Shared.Interfaces;
+
+public interface ISettingsService
+{
+ string SettingsFilePath();
+ AppSettings LoadSettings();
+ void SaveSettings(AppSettings settings);
+}
diff --git a/LocalLLMServerManager.Shared/Models/AiAssistantModels.cs b/LocalLLMServerManager.Shared/Models/AiAssistantModels.cs
new file mode 100644
index 0000000..aa646cc
--- /dev/null
+++ b/LocalLLMServerManager.Shared/Models/AiAssistantModels.cs
@@ -0,0 +1,99 @@
+using System;
+using System.Collections.Generic;
+
+namespace LocalLLMServerManager.Shared.Models;
+
+public record AiToolExecutionItem(
+ string ToolName,
+ string Arguments = "",
+ string Result = "",
+ bool IsSuccess = true,
+ long ExecutionTimeMs = 0,
+ bool IsRunning = false
+);
+
+public class AiChatMessageAttachment
+{
+ public string Id { get; set; } = Guid.NewGuid().ToString("N");
+ public string FileName { get; set; } = "";
+ public string ContentType { get; set; } = "image/png";
+ public string Base64Data { get; set; } = "";
+ public byte[]? RawBytes { get; set; }
+}
+
+public record AiModelCapabilityInfo(
+ string Id,
+ string DisplayName,
+ string Provider,
+ string Mode = "chat",
+ bool SupportsVision = false,
+ bool SupportsFunctionCalling = true,
+ bool SupportsAudio = false,
+ int? MaxInputTokens = null,
+ int? MaxOutputTokens = null,
+ bool IsLocal = false
+)
+{
+ public string SummaryBadge
+ {
+ get
+ {
+ var parts = new List();
+ if (SupportsVision) parts.Add("ποΈ");
+ if (SupportsFunctionCalling) parts.Add("β‘");
+ if (MaxInputTokens.HasValue && MaxInputTokens.Value > 0) parts.Add(FormatTokenCount(MaxInputTokens.Value));
+ var badges = parts.Count > 0 ? $"{string.Join(" ", parts)} " : "";
+ return $"{badges}[{Provider}]";
+ }
+ }
+
+ private static string FormatTokenCount(int tokens) =>
+ tokens >= 1_000_000 ? $"{tokens / 1_000_000.0:0.#}M" : (tokens >= 1_000 ? $"{tokens / 1_000}k" : $"{tokens}");
+}
+
+public class AiChatMessageItem
+{
+ public string Id { get; set; } = Guid.NewGuid().ToString("N");
+ public string Role { get; set; } = "user"; // "user", "assistant", "system", "tool"
+ public string Content { get; set; } = "";
+ public DateTime Timestamp { get; set; } = DateTime.UtcNow;
+ public bool IsError { get; set; } = false;
+ public bool IsLoading { get; set; } = false;
+ public string? StatusText { get; set; }
+ public List ToolCalls { get; set; } = new();
+ public List Attachments { get; set; } = new();
+
+ public bool HasToolCalls => ToolCalls != null && ToolCalls.Count > 0;
+ public bool HasAttachments => Attachments != null && Attachments.Count > 0;
+ public bool IsUser => string.Equals(Role, "user", StringComparison.OrdinalIgnoreCase);
+ public bool IsAssistant => string.Equals(Role, "assistant", StringComparison.OrdinalIgnoreCase);
+ public bool IsSystem => string.Equals(Role, "system", StringComparison.OrdinalIgnoreCase);
+}
+
+public record AiValidationResult(
+ bool Success,
+ string Message,
+ List AvailableModels,
+ long LatencyMs = 0
+);
+
+public record AiChatRequest(
+ List Messages,
+ bool Stream = false,
+ string? Model = null,
+ double? Temperature = null
+);
+
+public record AiChatResponse(
+ bool Success,
+ AiChatMessageItem? Message = null,
+ string? Error = null,
+ int? TotalTokens = null
+);
+
+public record AiChatChunk(
+ string DeltaText = "",
+ AiToolExecutionItem? ToolCall = null,
+ bool IsDone = false,
+ string? Error = null
+);
diff --git a/LocalLLMServerManager.Shared/Models/AppSettings.cs b/LocalLLMServerManager.Shared/Models/AppSettings.cs
index 7492156..191a4fc 100644
--- a/LocalLLMServerManager.Shared/Models/AppSettings.cs
+++ b/LocalLLMServerManager.Shared/Models/AppSettings.cs
@@ -26,7 +26,13 @@ public record AppSettings(
string PreferredAudioVoice = "af_heart",
string VideoModelsPath = "",
string VideoOutputPath = "",
- List? CustomPresets = null
+ List? CustomPresets = null,
+ bool AiAssistantEnabled = false,
+ string AiAssistantEndpoint = "http://127.0.0.1:4000/v1",
+ string AiAssistantApiKey = "",
+ string AiAssistantModel = "google/gemini-2.5-flash",
+ string AiAssistantCustomSystemPrompt = "",
+ string AiAssistantPromptsDirectory = ""
);
diff --git a/LocalLLMServerManager.Shared/Services/PromptManagementService.cs b/LocalLLMServerManager.Shared/Services/PromptManagementService.cs
new file mode 100644
index 0000000..a21dc86
--- /dev/null
+++ b/LocalLLMServerManager.Shared/Services/PromptManagementService.cs
@@ -0,0 +1,166 @@
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.IO;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using LocalLLMServerManager.Shared.Interfaces;
+
+namespace LocalLLMServerManager.Shared.Services;
+
+public class PromptManagementService : IPromptManagementService
+{
+ private readonly ConcurrentDictionary _cache = new(StringComparer.OrdinalIgnoreCase);
+
+ public const string SystemPromptFileName = "system-prompt.md";
+ public const string CapabilitiesFileName = "capabilities.md";
+ public const string WorkflowsFileName = "workflows.md";
+ public const string AppControlFileName = "app-control.md";
+
+ public async Task GetSystemPromptAsync(string? customPromptDir = null, CancellationToken cancellationToken = default)
+ {
+ return await LoadPromptDocumentAsync(SystemPromptFileName, FallbackSystemPrompt, customPromptDir, cancellationToken);
+ }
+
+ public async Task GetCapabilitiesPromptAsync(string? customPromptDir = null, CancellationToken cancellationToken = default)
+ {
+ return await LoadPromptDocumentAsync(CapabilitiesFileName, FallbackCapabilitiesPrompt, customPromptDir, cancellationToken);
+ }
+
+ public async Task GetWorkflowsPromptAsync(string? customPromptDir = null, CancellationToken cancellationToken = default)
+ {
+ return await LoadPromptDocumentAsync(WorkflowsFileName, FallbackWorkflowsPrompt, customPromptDir, cancellationToken);
+ }
+
+ public async Task GetAppControlPromptAsync(string? customPromptDir = null, CancellationToken cancellationToken = default)
+ {
+ return await LoadPromptDocumentAsync(AppControlFileName, FallbackAppControlPrompt, customPromptDir, cancellationToken);
+ }
+
+ public async Task BuildFullSystemPromptAsync(string? customPromptDir = null, CancellationToken cancellationToken = default)
+ {
+ var sys = await GetSystemPromptAsync(customPromptDir, cancellationToken);
+ var cap = await GetCapabilitiesPromptAsync(customPromptDir, cancellationToken);
+ var work = await GetWorkflowsPromptAsync(customPromptDir, cancellationToken);
+ var ctrl = await GetAppControlPromptAsync(customPromptDir, cancellationToken);
+
+ var sb = new StringBuilder();
+ sb.AppendLine(sys.Trim());
+ sb.AppendLine();
+ sb.AppendLine("---");
+ sb.AppendLine();
+ sb.AppendLine(cap.Trim());
+ sb.AppendLine();
+ sb.AppendLine("---");
+ sb.AppendLine();
+ sb.AppendLine(work.Trim());
+ sb.AppendLine();
+ sb.AppendLine("---");
+ sb.AppendLine();
+ sb.AppendLine(ctrl.Trim());
+
+ return sb.ToString();
+ }
+
+ public async Task> GetAllPromptsAsync(string? customPromptDir = null, CancellationToken cancellationToken = default)
+ {
+ var sys = await GetSystemPromptAsync(customPromptDir, cancellationToken);
+ var cap = await GetCapabilitiesPromptAsync(customPromptDir, cancellationToken);
+ var work = await GetWorkflowsPromptAsync(customPromptDir, cancellationToken);
+ var ctrl = await GetAppControlPromptAsync(customPromptDir, cancellationToken);
+
+ var dict = new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ [SystemPromptFileName] = sys,
+ [CapabilitiesFileName] = cap,
+ [WorkflowsFileName] = work,
+ [AppControlFileName] = ctrl,
+ ["system-prompt"] = sys,
+ ["capabilities"] = cap,
+ ["workflows"] = work,
+ ["app-control"] = ctrl
+ };
+ return dict;
+ }
+
+ public void InvalidateCache()
+ {
+ _cache.Clear();
+ }
+
+ public static string? ResolvePromptDirectory(string? customPromptDir = null)
+ {
+ if (!string.IsNullOrWhiteSpace(customPromptDir) && Directory.Exists(customPromptDir))
+ {
+ return Path.GetFullPath(customPromptDir);
+ }
+
+ var candidateDirs = new[]
+ {
+ Path.Combine(AppContext.BaseDirectory, "Prompts"),
+ Path.Combine(Directory.GetCurrentDirectory(), "Prompts"),
+ Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "Prompts"),
+ Path.Combine(Directory.GetCurrentDirectory(), "..", "Prompts")
+ };
+
+ foreach (var dir in candidateDirs)
+ {
+ try
+ {
+ if (Directory.Exists(dir) && File.Exists(Path.Combine(dir, SystemPromptFileName)))
+ {
+ return Path.GetFullPath(dir);
+ }
+ }
+ catch { }
+ }
+
+ return null;
+ }
+
+ private async Task LoadPromptDocumentAsync(string fileName, string fallbackContent, string? customPromptDir, CancellationToken cancellationToken)
+ {
+ var resolvedDir = ResolvePromptDirectory(customPromptDir);
+ var cacheKey = $"{resolvedDir ?? "fallback"}::{fileName}";
+
+ if (_cache.TryGetValue(cacheKey, out var cached))
+ {
+ return cached;
+ }
+
+ if (resolvedDir != null)
+ {
+ var filePath = Path.Combine(resolvedDir, fileName);
+ if (File.Exists(filePath))
+ {
+ try
+ {
+ var content = await File.ReadAllTextAsync(filePath, cancellationToken);
+ if (!string.IsNullOrWhiteSpace(content))
+ {
+ _cache[cacheKey] = content;
+ return content;
+ }
+ }
+ catch { }
+ }
+ }
+
+ _cache[cacheKey] = fallbackContent;
+ 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.
+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
+LocalLLMServerManager supports Ollama LLMs, Stable Diffusion Forge, ComfyUI workflows, Kokoro TTS, and the Can I Run It hardware calculator.";
+
+ private const string FallbackWorkflowsPrompt = @"# Application Workflows Guide
+Workflows include LLM downloading and execution, image generation with Forge/ComfyUI, video rendering, and Kokoro speech synthesis.";
+
+ private const string FallbackAppControlPrompt = @"# In-App Natural Language Control Guidelines
+Use your registered tools to inspect VRAM, check health, start/stop engines, update settings, and trigger generation workflows.";
+}
diff --git a/LocalLLMServerManager.Shared/ViewModels/AiAssistantViewModel.cs b/LocalLLMServerManager.Shared/ViewModels/AiAssistantViewModel.cs
new file mode 100644
index 0000000..0acd5d9
--- /dev/null
+++ b/LocalLLMServerManager.Shared/ViewModels/AiAssistantViewModel.cs
@@ -0,0 +1,550 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.IO;
+using System.Linq;
+using System.Net.Http;
+using System.Net.Http.Json;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using System.Threading;
+using System.Threading.Tasks;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using LocalLLMServerManager.Shared.Interfaces;
+using LocalLLMServerManager.Shared.Models;
+using LocalLLMServerManager.Shared.Services;
+
+namespace LocalLLMServerManager.Shared.ViewModels;
+
+///
+/// ViewModel managing the In-App AI Assistant & Copilot interface.
+/// Provides multi-turn chat orchestration, streaming token rendering, tool invocation cards,
+/// setup wizard with connection validation, and natural language app interaction.
+///
+public partial class AiAssistantViewModel : ObservableObject
+{
+ private readonly IAiAssistantService? _assistantService;
+ private readonly ISettingsService? _settingsService;
+ private readonly IPromptManagementService? _promptService;
+ private readonly HttpClient? _httpClient;
+ private CancellationTokenSource? _generationCts;
+
+ private static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ PropertyNameCaseInsensitive = true,
+ Converters = { new JsonStringEnumConverter() }
+ };
+
+ // Chat History
+ public ObservableCollection Messages { get; } = new();
+
+ // Setup & Configuration
+ [ObservableProperty] private string _endpoint = "http://127.0.0.1:4000/v1";
+ [ObservableProperty] private string _apiKey = "";
+ [ObservableProperty] private string _selectedModel = "vertex_ai/gemini-2.5-flash";
+ [ObservableProperty] private ObservableCollection _availableModels = new();
+ [ObservableProperty] private ObservableCollection _availableModelCapabilities = new();
+ [ObservableProperty] private AiModelCapabilityInfo? _selectedModelCapability;
+ [ObservableProperty] private bool _isEnabled = true;
+ [ObservableProperty] private bool _isSetupCardVisible = false;
+ [ObservableProperty] private bool _isTestingConnection = false;
+ [ObservableProperty] private string _connectionStatusMessage = "";
+ [ObservableProperty] private bool? _isConnectionSuccess = null;
+ [ObservableProperty] private string _promptDirectory = "";
+
+ // Chat UI States
+ [ObservableProperty] private string _inputText = "";
+ [ObservableProperty] private bool _isGenerating = false;
+ [ObservableProperty] private string _currentStatus = "";
+ [ObservableProperty] private bool _autoScrollEnabled = true;
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(HasStagedAttachments))]
+ private ObservableCollection _stagedAttachments = new();
+
+ public bool HasStagedAttachments => StagedAttachments.Count > 0;
+
+ partial void OnSelectedModelCapabilityChanged(AiModelCapabilityInfo? value)
+ {
+ var newId = value?.Id ?? "";
+ if (!string.IsNullOrEmpty(newId) && SelectedModel != newId)
+ {
+ SelectedModel = newId;
+ }
+ }
+
+ partial void OnSelectedModelChanged(string value)
+ {
+ if (SelectedModelCapability?.Id != value)
+ {
+ var match = AvailableModelCapabilities.FirstOrDefault(c => string.Equals(c.Id, value, StringComparison.OrdinalIgnoreCase));
+ SelectedModelCapability = match;
+ }
+ }
+
+ partial void OnStagedAttachmentsChanged(ObservableCollection? oldValue, ObservableCollection newValue)
+ {
+ if (oldValue != null)
+ {
+ oldValue.CollectionChanged -= StagedAttachments_CollectionChanged;
+ }
+ if (newValue != null)
+ {
+ newValue.CollectionChanged += StagedAttachments_CollectionChanged;
+ }
+ OnPropertyChanged(nameof(HasStagedAttachments));
+ }
+
+ private void StagedAttachments_CollectionChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
+ {
+ OnPropertyChanged(nameof(HasStagedAttachments));
+ }
+
+ // Quick Suggestion Chips
+ public IReadOnlyList SuggestionChips { get; } = new[]
+ {
+ "β‘ Check live VRAM and GPU telemetry",
+ "π¦ Can I run Llama 3.3 70B on my current hardware?",
+ "π¨ Generate a scenic mountain landscape with Flux",
+ "π¦ List all installed models and their sizes",
+ "π£οΈ Speak 'Welcome to Local LLM Server Manager' using Kokoro",
+ "π Unload all models from VRAM"
+ };
+
+ public AiAssistantViewModel()
+ : this(null, null, null, null)
+ {
+ }
+
+ public AiAssistantViewModel(
+ IAiAssistantService? assistantService,
+ ISettingsService? settingsService = null,
+ IPromptManagementService? promptService = null,
+ HttpClient? httpClient = null)
+ {
+ _assistantService = assistantService;
+ _settingsService = settingsService;
+ _promptService = promptService;
+ _httpClient = httpClient;
+
+ _stagedAttachments.CollectionChanged += StagedAttachments_CollectionChanged;
+
+ if (_settingsService != null)
+ {
+ var s = _settingsService.LoadSettings();
+ _isEnabled = s.AiAssistantEnabled;
+ _endpoint = string.IsNullOrWhiteSpace(s.AiAssistantEndpoint) ? "http://127.0.0.1:4000/v1" : s.AiAssistantEndpoint;
+ _apiKey = s.AiAssistantApiKey ?? "";
+ _selectedModel = string.IsNullOrWhiteSpace(s.AiAssistantModel) ? "vertex_ai/gemini-2.5-flash" : s.AiAssistantModel;
+ _promptDirectory = PromptManagementService.ResolvePromptDirectory(s.AiAssistantPromptsDirectory) ?? "";
+ }
+ else
+ {
+ _promptDirectory = PromptManagementService.ResolvePromptDirectory() ?? "";
+ }
+
+ // Add initial model to dropdown if empty
+ if (!string.IsNullOrWhiteSpace(_selectedModel))
+ {
+ _availableModels.Add(_selectedModel);
+ var initialCap = new AiModelCapabilityInfo(_selectedModel, _selectedModel, "default");
+ _availableModelCapabilities.Add(initialCap);
+ _selectedModelCapability = initialCap;
+ }
+
+ // Add welcome message
+ 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!",
+ Timestamp = DateTime.UtcNow
+ });
+ }
+
+ public void AddStagedAttachment(AiChatMessageAttachment attachment)
+ {
+ if (attachment == null) return;
+ StagedAttachments.Add(attachment);
+ OnPropertyChanged(nameof(HasStagedAttachments));
+ }
+
+ [RelayCommand]
+ public void RemoveStagedAttachment(string? attachmentId)
+ {
+ if (string.IsNullOrWhiteSpace(attachmentId)) return;
+ var existing = StagedAttachments.FirstOrDefault(a => a.Id == attachmentId);
+ if (existing != null)
+ {
+ StagedAttachments.Remove(existing);
+ OnPropertyChanged(nameof(HasStagedAttachments));
+ }
+ }
+
+ [RelayCommand]
+ public void RemoveAttachment(string? attachmentId) => RemoveStagedAttachment(attachmentId);
+
+ [RelayCommand]
+ public void ClearStagedAttachments()
+ {
+ StagedAttachments.Clear();
+ OnPropertyChanged(nameof(HasStagedAttachments));
+ }
+
+ [RelayCommand]
+ public void AttachImage(AiChatMessageAttachment attachment) => AddStagedAttachment(attachment);
+
+ public void PasteImageBytes(byte[] bytes, string mimeType)
+ {
+ if (bytes == null || bytes.Length == 0) return;
+
+ var ext = mimeType switch
+ {
+ "image/jpeg" => ".jpg",
+ "image/jpg" => ".jpg",
+ "image/webp" => ".webp",
+ "image/gif" => ".gif",
+ _ => ".png"
+ };
+
+ var att = new AiChatMessageAttachment
+ {
+ FileName = $"pasted_image_{DateTime.UtcNow:yyyyMMdd_HHmmss_fff}{ext}",
+ ContentType = string.IsNullOrWhiteSpace(mimeType) ? "image/png" : mimeType,
+ Base64Data = Convert.ToBase64String(bytes),
+ RawBytes = bytes
+ };
+
+ AddStagedAttachment(att);
+ }
+
+ [RelayCommand]
+ public async Task SendMessageAsync()
+ {
+ var text = InputText?.Trim() ?? "";
+ if ((string.IsNullOrWhiteSpace(text) && StagedAttachments.Count == 0) || IsGenerating)
+ {
+ return;
+ }
+
+ // 1. Add user message
+ var userMsg = new AiChatMessageItem
+ {
+ Role = "user",
+ Content = text,
+ Timestamp = DateTime.UtcNow
+ };
+
+ if (StagedAttachments.Count > 0)
+ {
+ userMsg.Attachments.AddRange(StagedAttachments);
+ ClearStagedAttachments();
+ }
+
+ Messages.Add(userMsg);
+ InputText = "";
+
+ // 2. Prepare assistant placeholder
+ var assistantMsg = new AiChatMessageItem
+ {
+ Role = "assistant",
+ Content = "",
+ IsLoading = true,
+ StatusText = "Connecting to LLM...",
+ Timestamp = DateTime.UtcNow
+ };
+ Messages.Add(assistantMsg);
+
+ IsGenerating = true;
+ CurrentStatus = "Thinking...";
+ _generationCts = new CancellationTokenSource();
+ var ct = _generationCts.Token;
+
+ try
+ {
+ var chatMessages = Messages
+ .Where(m => !m.IsError && !m.IsLoading && (!string.IsNullOrWhiteSpace(m.Content) || m.HasAttachments))
+ .ToList();
+
+ var request = new AiChatRequest(
+ Messages: chatMessages,
+ Stream: true,
+ Model: SelectedModel
+ );
+
+ if (_assistantService != null)
+ {
+ // Direct local in-process streaming
+ await foreach (var chunk in _assistantService.StreamChatAsync(request, ct))
+ {
+ if (chunk.ToolCall != null)
+ {
+ assistantMsg.ToolCalls.Add(chunk.ToolCall);
+ assistantMsg.StatusText = $"Executing {chunk.ToolCall.ToolName}...";
+ }
+
+ if (!string.IsNullOrEmpty(chunk.DeltaText))
+ {
+ assistantMsg.Content += chunk.DeltaText;
+ assistantMsg.StatusText = null;
+ }
+
+ if (!string.IsNullOrEmpty(chunk.Error))
+ {
+ assistantMsg.IsError = true;
+ assistantMsg.Content += $"\nβ οΈ {chunk.Error}";
+ }
+ }
+ }
+ 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);
+ if (response.IsSuccessStatusCode)
+ {
+ var chatResp = await response.Content.ReadFromJsonAsync(JsonOptions, ct);
+ if (chatResp != null && chatResp.Success && chatResp.Message != null)
+ {
+ assistantMsg.Content = chatResp.Message.Content;
+ if (chatResp.Message.ToolCalls != null)
+ {
+ foreach (var tc in chatResp.Message.ToolCalls)
+ {
+ assistantMsg.ToolCalls.Add(tc);
+ }
+ }
+ }
+ else
+ {
+ assistantMsg.IsError = true;
+ assistantMsg.Content = chatResp?.Error ?? "Unknown error received from AI server.";
+ }
+ }
+ else
+ {
+ assistantMsg.IsError = true;
+ assistantMsg.Content = $"Server error {(int)response.StatusCode}: {response.ReasonPhrase}";
+ }
+ }
+ else
+ {
+ assistantMsg.IsError = true;
+ assistantMsg.Content = "AI service is not configured or available.";
+ }
+
+ if (string.IsNullOrWhiteSpace(assistantMsg.Content) && assistantMsg.ToolCalls.Count > 0)
+ {
+ assistantMsg.Content = "Tools executed successfully.";
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ if (string.IsNullOrWhiteSpace(assistantMsg.Content))
+ {
+ assistantMsg.Content = "βΉοΈ Generation stopped by user.";
+ }
+ }
+ catch (Exception ex)
+ {
+ assistantMsg.IsError = true;
+ assistantMsg.Content = $"β οΈ Error: {ex.Message}";
+ }
+ finally
+ {
+ assistantMsg.IsLoading = false;
+ assistantMsg.StatusText = null;
+ IsGenerating = false;
+ CurrentStatus = "";
+ _generationCts = null;
+ }
+ }
+
+ [RelayCommand]
+ public void StopGeneration()
+ {
+ _generationCts?.Cancel();
+ }
+
+ [RelayCommand]
+ public void ClearChat()
+ {
+ Messages.Clear();
+ Messages.Add(new AiChatMessageItem
+ {
+ Role = "assistant",
+ Content = "Conversation cleared. Ready for your next question or task!",
+ Timestamp = DateTime.UtcNow
+ });
+ }
+
+ [RelayCommand]
+ public async Task SelectSuggestionAsync(string? suggestion)
+ {
+ if (string.IsNullOrWhiteSpace(suggestion) || IsGenerating)
+ {
+ return;
+ }
+
+ InputText = suggestion;
+ await SendMessageAsync();
+ }
+
+ [RelayCommand]
+ public void ToggleSetupCard()
+ {
+ IsSetupCardVisible = !IsSetupCardVisible;
+ }
+
+ [RelayCommand]
+ public async Task TestConnectionAsync()
+ {
+ IsTestingConnection = true;
+ ConnectionStatusMessage = "Testing endpoint connectivity and model availability...";
+ IsConnectionSuccess = null;
+
+ try
+ {
+ AiValidationResult? result = null;
+ if (_assistantService != null)
+ {
+ result = await _assistantService.ValidateConnectionAsync(Endpoint, ApiKey, SelectedModel);
+ }
+ else if (_httpClient != null)
+ {
+ var payload = new { endpoint = Endpoint, apiKey = ApiKey, model = SelectedModel };
+ var resp = await _httpClient.PostAsJsonAsync("/api/ai/validate", payload);
+ if (resp.IsSuccessStatusCode)
+ {
+ result = await resp.Content.ReadFromJsonAsync(JsonOptions);
+ }
+ }
+
+ if (result != null)
+ {
+ IsConnectionSuccess = result.Success;
+ ConnectionStatusMessage = result.Message;
+
+ if (result.AvailableModels != null && result.AvailableModels.Count > 0)
+ {
+ AvailableModels.Clear();
+ AvailableModelCapabilities.Clear();
+ foreach (var m in result.AvailableModels)
+ {
+ AvailableModels.Add(m);
+ AvailableModelCapabilities.Add(new AiModelCapabilityInfo(m, m, "unknown"));
+ }
+ if (!AvailableModels.Contains(SelectedModel) && AvailableModels.Count > 0)
+ {
+ SelectedModel = AvailableModels[0];
+ }
+ SelectedModelCapability = AvailableModelCapabilities.FirstOrDefault(c => c.Id == SelectedModel);
+ }
+
+ if (result.Success)
+ {
+ await LoadAvailableModelsAsync(refresh: true);
+ }
+ }
+ else
+ {
+ IsConnectionSuccess = false;
+ ConnectionStatusMessage = "Failed to communicate with the validation service.";
+ }
+ }
+ catch (Exception ex)
+ {
+ IsConnectionSuccess = false;
+ ConnectionStatusMessage = $"Connection failed: {ex.Message}";
+ }
+ finally
+ {
+ IsTestingConnection = false;
+ }
+ }
+
+ [RelayCommand]
+ public async Task LoadAvailableModelsAsync(bool refresh = false)
+ {
+ if (!refresh && AvailableModelCapabilities.Count > 1)
+ {
+ return;
+ }
+
+ try
+ {
+ List? caps = null;
+ if (_assistantService != null)
+ {
+ caps = await _assistantService.GetModelCapabilitiesAsync(Endpoint, ApiKey, includeLocal: false);
+ }
+ else if (_httpClient != null)
+ {
+ var ep = Uri.EscapeDataString(Endpoint ?? "");
+ var key = Uri.EscapeDataString(ApiKey ?? "");
+ var url = $"/api/ai/models?endpoint={ep}&apiKey={key}&includeLocal=false";
+ caps = await _httpClient.GetFromJsonAsync>(url, JsonOptions);
+ }
+
+ if (caps != null && caps.Count > 0)
+ {
+ AvailableModelCapabilities.Clear();
+ AvailableModels.Clear();
+ foreach (var cap in caps)
+ {
+ AvailableModelCapabilities.Add(cap);
+ AvailableModels.Add(cap.Id);
+ }
+
+ var matching = AvailableModelCapabilities.FirstOrDefault(c => string.Equals(c.Id, SelectedModel, StringComparison.OrdinalIgnoreCase));
+ if (matching != null)
+ {
+ SelectedModelCapability = matching;
+ }
+ else if (AvailableModelCapabilities.Count > 0)
+ {
+ SelectedModelCapability = AvailableModelCapabilities[0];
+ SelectedModel = SelectedModelCapability.Id;
+ }
+ }
+ }
+ catch
+ {
+ // Suppress error; avoid crashing caller
+ }
+ }
+
+ [RelayCommand]
+ public void SaveConfiguration()
+ {
+ if (_settingsService != null)
+ {
+ var s = _settingsService.LoadSettings();
+ var updated = s with
+ {
+ AiAssistantEnabled = IsEnabled,
+ AiAssistantEndpoint = Endpoint,
+ AiAssistantApiKey = ApiKey,
+ AiAssistantModel = SelectedModel
+ };
+ _settingsService.SaveSettings(updated);
+ }
+
+ IsSetupCardVisible = false;
+ ConnectionStatusMessage = "Settings saved successfully.";
+ }
+
+ [RelayCommand]
+ public async Task ReloadPromptsAsync()
+ {
+ if (_promptService != null)
+ {
+ _promptService.InvalidateCache();
+ PromptDirectory = PromptManagementService.ResolvePromptDirectory() ?? "";
+ ConnectionStatusMessage = "Living prompts reloaded from disk.";
+ }
+ else if (_httpClient != null)
+ {
+ await _httpClient.PostAsync("/api/ai/prompts/reload", null);
+ ConnectionStatusMessage = "Prompts reloaded via API.";
+ }
+ }
+}
diff --git a/LocalLLMServerManager.Shared/ViewModels/MainViewModel.cs b/LocalLLMServerManager.Shared/ViewModels/MainViewModel.cs
index a19f98f..64e3e63 100644
--- a/LocalLLMServerManager.Shared/ViewModels/MainViewModel.cs
+++ b/LocalLLMServerManager.Shared/ViewModels/MainViewModel.cs
@@ -90,6 +90,7 @@ public HttpClient Http
public AudioStudioViewModel Audio { get; }
public CanIRunItViewModel HardwareFit { get; }
public DocumentationViewModel Documentation { get; } = new();
+ public AiAssistantViewModel Assistant { get; }
[ObservableProperty]
private int _selectedTabIndex = 0;
@@ -116,7 +117,10 @@ public MainViewModel(
IHuggingFaceSearchService hfSearchService,
ICivitaiSearchService civitaiSearchService,
IStudioPresetService? studioPresetService = null,
- ICanIRunItService? canIRunItService = null)
+ ICanIRunItService? canIRunItService = null,
+ IAiAssistantService? assistantService = null,
+ ISettingsService? settingsService = null,
+ IPromptManagementService? promptService = null)
{
if (httpClient != null) _customHttp = httpClient;
@@ -154,6 +158,7 @@ public MainViewModel(
};
Settings = new SettingsViewModel(PresetService) { ApiBase = ApiBase };
Audio = new AudioStudioViewModel(PresetService, _canIRunItService) { ApiBase = ApiBase };
+ Assistant = new AiAssistantViewModel(assistantService, settingsService, promptService, httpClient ?? Http);
LoadStudioPresets();
RecalculateVideoHardwareFit();
@@ -863,6 +868,12 @@ public void NavigateToModelsTab()
SelectedTabIndex = 0;
}
+ [RelayCommand]
+ public void NavigateToAssistantTab()
+ {
+ SelectedTabIndex = 3;
+ }
+
[RelayCommand]
public async Task LoadInstalledModelsAsync() => await Ollama.LoadInstalledModelsAsync(ApiBase, Http);
diff --git a/LocalLLMServerManager.Shared/Views/Controls/AiAssistantTabControl.axaml b/LocalLLMServerManager.Shared/Views/Controls/AiAssistantTabControl.axaml
new file mode 100644
index 0000000..8eaff51
--- /dev/null
+++ b/LocalLLMServerManager.Shared/Views/Controls/AiAssistantTabControl.axaml
@@ -0,0 +1,309 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/LocalLLMServerManager.Shared/Views/Controls/AiAssistantTabControl.axaml.cs b/LocalLLMServerManager.Shared/Views/Controls/AiAssistantTabControl.axaml.cs
new file mode 100644
index 0000000..1754b56
--- /dev/null
+++ b/LocalLLMServerManager.Shared/Views/Controls/AiAssistantTabControl.axaml.cs
@@ -0,0 +1,222 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Runtime.CompilerServices;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Data.Converters;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+using Avalonia.Media.Imaging;
+using Avalonia.Platform.Storage;
+using LocalLLMServerManager.Shared.Models;
+using LocalLLMServerManager.Shared.ViewModels;
+
+namespace LocalLLMServerManager.Shared.Views.Controls;
+
+public class AttachmentToImageConverter : IValueConverter
+{
+ public static readonly AttachmentToImageConverter Instance = new();
+ private static readonly ConditionalWeakTable _cache = new();
+
+ public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
+ {
+ if (value is AiChatMessageAttachment attachment)
+ {
+ if (_cache.TryGetValue(attachment, out var cached))
+ {
+ return cached;
+ }
+
+ Bitmap? bitmap = null;
+ if (attachment.RawBytes != null && attachment.RawBytes.Length > 0)
+ {
+ try
+ {
+ using var ms = new MemoryStream(attachment.RawBytes);
+ bitmap = new Bitmap(ms);
+ }
+ catch
+ {
+ // Invalid image bytes
+ }
+ }
+
+ if (bitmap == null && !string.IsNullOrWhiteSpace(attachment.Base64Data))
+ {
+ try
+ {
+ var bytes = System.Convert.FromBase64String(attachment.Base64Data);
+ using var ms = new MemoryStream(bytes);
+ bitmap = new Bitmap(ms);
+ }
+ catch
+ {
+ // Invalid base64
+ }
+ }
+
+ if (bitmap != null)
+ {
+ _cache.AddOrUpdate(attachment, bitmap);
+ }
+
+ return bitmap;
+ }
+
+ return null;
+ }
+
+ public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) =>
+ throw new NotSupportedException();
+}
+
+public partial class AiAssistantTabControl : UserControl
+{
+ public AiAssistantTabControl()
+ {
+ InitializeComponent();
+ }
+
+ protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
+ {
+ base.OnAttachedToVisualTree(e);
+ if (DataContext is AiAssistantViewModel vm && vm.AvailableModelCapabilities.Count <= 1)
+ {
+ _ = vm.LoadAvailableModelsAsync();
+ }
+ }
+
+ public async void OnAttachFileButtonClick(object? sender, RoutedEventArgs e)
+ {
+ if (DataContext is not AiAssistantViewModel vm) return;
+ var topLevel = TopLevel.GetTopLevel(this);
+ if (topLevel?.StorageProvider == null) return;
+
+ try
+ {
+ var options = new FilePickerOpenOptions
+ {
+ Title = "Attach Image",
+ AllowMultiple = true,
+ FileTypeFilter = new List
+ {
+ FilePickerFileTypes.ImageAll,
+ new("All Files (*.*)") { Patterns = new[] { "*.*" } }
+ }
+ };
+
+ var files = await topLevel.StorageProvider.OpenFilePickerAsync(options);
+ if (files != null && files.Count > 0)
+ {
+ foreach (var file in files)
+ {
+ try
+ {
+ using var stream = await file.OpenReadAsync();
+ using var ms = new MemoryStream();
+ await stream.CopyToAsync(ms);
+ var bytes = ms.ToArray();
+ var ext = Path.GetExtension(file.Name).ToLowerInvariant();
+ var contentType = ext switch
+ {
+ ".jpg" or ".jpeg" => "image/jpeg",
+ ".webp" => "image/webp",
+ ".gif" => "image/gif",
+ _ => "image/png"
+ };
+
+ vm.AddStagedAttachment(new AiChatMessageAttachment
+ {
+ FileName = file.Name,
+ ContentType = contentType,
+ Base64Data = Convert.ToBase64String(bytes),
+ RawBytes = bytes
+ });
+ }
+ catch
+ {
+ // Ignore unreadable individual file
+ }
+ }
+ }
+ }
+ catch
+ {
+ // Ignore file picker cancellation or error
+ }
+ }
+
+ public async void OnPromptTextBoxKeyDown(object? sender, KeyEventArgs e)
+ {
+ if (e.Key == Key.V && (e.KeyModifiers.HasFlag(KeyModifiers.Control) || e.KeyModifiers.HasFlag(KeyModifiers.Meta)))
+ {
+ var topLevel = TopLevel.GetTopLevel(this);
+ if (topLevel?.Clipboard != null && DataContext is AiAssistantViewModel vm)
+ {
+ try
+ {
+ var dataTransfer = await topLevel.Clipboard.TryGetDataAsync();
+ if (dataTransfer != null)
+ {
+ // 1. Check for clipboard bitmap
+ var bitmap = await dataTransfer.TryGetBitmapAsync();
+ if (bitmap != null)
+ {
+ using var ms = new MemoryStream();
+ bitmap.Save(ms);
+ var bytes = ms.ToArray();
+ if (bytes.Length > 0)
+ {
+ vm.PasteImageBytes(bytes, "image/png");
+ e.Handled = true;
+ return;
+ }
+ }
+
+ // 2. Check for clipboard files (e.g. copied image file from Explorer)
+ var files = await dataTransfer.TryGetFilesAsync();
+ if (files != null && files.Length > 0)
+ {
+ bool pasted = false;
+ foreach (var item in files)
+ {
+ if (item is IStorageFile file)
+ {
+ var ext = Path.GetExtension(file.Name).ToLowerInvariant();
+ if (ext is ".png" or ".jpg" or ".jpeg" or ".webp" or ".gif" or ".bmp")
+ {
+ using var s = await file.OpenReadAsync();
+ using var ms = new MemoryStream();
+ await s.CopyToAsync(ms);
+ var bytes = ms.ToArray();
+ var mime = ext switch
+ {
+ ".jpg" or ".jpeg" => "image/jpeg",
+ ".webp" => "image/webp",
+ ".gif" => "image/gif",
+ _ => "image/png"
+ };
+ vm.PasteImageBytes(bytes, mime);
+ pasted = true;
+ }
+ }
+ }
+
+ if (pasted)
+ {
+ e.Handled = true;
+ return;
+ }
+ }
+ }
+ }
+ catch
+ {
+ // If clipboard access fails, let default paste behavior proceed
+ }
+ }
+ }
+ }
+}
diff --git a/LocalLLMServerManager.Shared/Views/MainView.axaml b/LocalLLMServerManager.Shared/Views/MainView.axaml
index c89e7ef..5426370 100644
--- a/LocalLLMServerManager.Shared/Views/MainView.axaml
+++ b/LocalLLMServerManager.Shared/Views/MainView.axaml
@@ -42,7 +42,12 @@
-
+
+
+
+
+
+
diff --git a/LocalLLMServerManager.Tests/AiAppToolsTests.cs b/LocalLLMServerManager.Tests/AiAppToolsTests.cs
new file mode 100644
index 0000000..5436b6f
--- /dev/null
+++ b/LocalLLMServerManager.Tests/AiAppToolsTests.cs
@@ -0,0 +1,171 @@
+using System;
+using System.Collections.Generic;
+using System.Net;
+using System.Net.Http;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using LocalLLMServerManager.Services;
+using LocalLLMServerManager.Shared.Interfaces;
+using LocalLLMServerManager.Shared.Models;
+using LocalLLMServerManager.Shared.Services;
+using LocalLLMServerManager.Shared.ViewModels;
+using Moq;
+using Moq.Protected;
+using Xunit;
+
+namespace LocalLLMServerManager.Tests;
+
+public class AiAppToolsTests
+{
+ private readonly Mock _mockTelemetry = new();
+ private readonly Mock _mockEngine = new();
+ private readonly Mock _mockOllama = new();
+ private readonly Mock _mockSettings = new();
+ private readonly Mock _mockCanIRunItService = new();
+ private readonly Mock _mockHttpFactory = new();
+
+ private AiAppTools CreateTools(HttpMessageHandler? handler = null, AppSettings? customSettings = null)
+ {
+ var httpHandler = handler ?? new MockHttpMessageHandler(HttpStatusCode.OK, "{}");
+ var client = new HttpClient(httpHandler);
+ _mockHttpFactory.Setup(f => f.CreateClient(It.IsAny())).Returns(client);
+
+ _mockSettings.Setup(s => s.LoadSettings()).Returns(customSettings ?? new AppSettings());
+
+ return new AiAppTools(
+ _mockTelemetry.Object,
+ _mockEngine.Object,
+ _mockOllama.Object,
+ _mockSettings.Object,
+ _mockCanIRunItService.Object,
+ _mockHttpFactory.Object
+ );
+ }
+
+ [Fact]
+ public async Task GetGpuVramTelemetry_ReturnsValidJsonTelemetry()
+ {
+ _mockTelemetry.Setup(t => t.GetTelemetryAsync())
+ .ReturnsAsync(new GpuTelemetryResult("NVIDIA RTX 4090", 24576, 4096, 20480, 16.7));
+
+ var tools = CreateTools();
+ var json = await tools.GetGpuVramTelemetryAsync();
+
+ Assert.False(string.IsNullOrWhiteSpace(json));
+ Assert.Contains("NVIDIA RTX 4090", json);
+ Assert.Contains("24576", json);
+ }
+
+ [Fact]
+ public async Task ListInstalledModels_ReturnsJsonModelList()
+ {
+ _mockOllama.Setup(o => o.GetInstalledModelsAsync())
+ .ReturnsAsync(new List
+ {
+ new("llama3.2:latest", "4.7 GB", "General", "#00FF00", true)
+ });
+
+ var tools = CreateTools();
+ var json = await tools.ListInstalledModelsAsync();
+
+ Assert.Contains("llama3.2:latest", json);
+ Assert.Contains("4.7 GB", json);
+ }
+
+ [Fact]
+ public async Task StartAiEngine_CallsEngineManagerAndReturnsResult()
+ {
+ _mockEngine.Setup(e => e.StartEngineAsync("comfyui"))
+ .ReturnsAsync(new EngineOperationResult(true, "comfyui", "Started"));
+
+ var tools = CreateTools();
+ var json = await tools.StartAiEngineAsync("comfyui");
+
+ Assert.Contains("comfyui", json);
+ Assert.Contains("Started", json);
+ }
+
+ [Fact]
+ public async Task StopAiEngine_CallsEngineManagerAndReturnsResult()
+ {
+ _mockEngine.Setup(e => e.StopEngineAsync("forge"))
+ .ReturnsAsync(new EngineOperationResult(true, "forge", "Stopped"));
+
+ var tools = CreateTools();
+ var json = await tools.StopAiEngineAsync("forge");
+
+ Assert.Contains("forge", json);
+ Assert.Contains("Stopped", json);
+ }
+
+ [Fact]
+ public async Task GetAppSettings_ReturnsMaskedApiKey()
+ {
+ var tools = CreateTools(customSettings: new AppSettings(
+ AiAssistantApiKey: "secret-production-key-12345",
+ PreferredImageEngine: "comfy"
+ ));
+ var json = await tools.GetAppSettingsAsync();
+
+ Assert.Contains("******", json);
+ Assert.DoesNotContain("secret-production-key-12345", json);
+ Assert.Contains("comfy", json);
+ }
+
+ [Fact]
+ public async Task UpdateAppSetting_UpdatesKnownKeyAndPersists()
+ {
+ var initial = new AppSettings(PreferredImageEngine: "forge");
+ _mockSettings.Setup(s => s.LoadSettings()).Returns(initial);
+
+ AppSettings? saved = null;
+ _mockSettings.Setup(s => s.SaveSettings(It.IsAny()))
+ .Callback(s => saved = s);
+
+ var tools = CreateTools();
+ var json = await tools.UpdateAppSettingAsync("PreferredImageEngine", "comfy");
+
+ Assert.Contains("true", json);
+ Assert.NotNull(saved);
+ Assert.Equal("comfy", saved!.PreferredImageEngine);
+ }
+
+ [Fact]
+ public async Task CalculateHardwareFit_CallsCanIRunItService()
+ {
+ _mockTelemetry.Setup(t => t.GetTelemetryAsync())
+ .ReturnsAsync(new GpuTelemetryResult("NVIDIA RTX 4090", 24576, 4096, 20480, 16.7));
+
+ _mockCanIRunItService.Setup(c => c.EvaluateLlmFit(It.IsAny()))
+ .Returns(new LlmFitResult(
+ ModelWeightMb: 16000,
+ KvCacheMb: 1000,
+ OverheadMb: 600,
+ TotalVramMb: 17600,
+ TotalRamMb: 0,
+ GpuLayers: 64,
+ CpuLayers: 0,
+ TotalLayers: 64,
+ FitVerdict: FitVerdict.FullVram,
+ EstimatedTokPerSec: 25.0,
+ RecommendationMessage: "100% in GPU VRAM"
+ ));
+
+ var tools = CreateTools();
+ var json = await tools.CalculateHardwareFitAsync("llama3.3:70b", parametersBillions: 70.0, quantization: "Q4_K_M");
+
+ Assert.Contains("FullVram", json);
+ Assert.Contains("100% in GPU VRAM", json);
+ }
+
+ [Fact]
+ public async Task QueryAppDocumentation_ReturnsMatchingProcedures()
+ {
+ var tools = CreateTools();
+ var json = await tools.QueryAppDocumentationAsync("image");
+
+ Assert.False(string.IsNullOrWhiteSpace(json));
+ Assert.Contains("Image", json, StringComparison.OrdinalIgnoreCase);
+ }
+}
diff --git a/LocalLLMServerManager.Tests/AiAssistantEndpointsTests.cs b/LocalLLMServerManager.Tests/AiAssistantEndpointsTests.cs
new file mode 100644
index 0000000..137ca17
--- /dev/null
+++ b/LocalLLMServerManager.Tests/AiAssistantEndpointsTests.cs
@@ -0,0 +1,332 @@
+using System.Net;
+using System.Net.Http.Json;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using LocalLLMServerManager.Endpoints;
+using LocalLLMServerManager.Services;
+using LocalLLMServerManager.Shared.Interfaces;
+using LocalLLMServerManager.Shared.Models;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.Extensions.DependencyInjection;
+using Moq;
+using Xunit;
+
+namespace LocalLLMServerManager.Tests;
+
+public class AiAssistantEndpointsTests : IClassFixture
+{
+ private readonly HttpClient _client;
+ private static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ PropertyNameCaseInsensitive = true,
+ Converters = { new JsonStringEnumConverter() }
+ };
+
+ public AiAssistantEndpointsTests(AppTestServerFixture fixture)
+ {
+ _client = fixture.CreateClient();
+ }
+
+ [Fact]
+ public async Task GetAiStatus_Returns200AndStatusInformation()
+ {
+ var response = await _client.GetAsync("/api/ai/status");
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+
+ var content = await response.Content.ReadAsStringAsync();
+ using var doc = JsonDocument.Parse(content);
+ var root = doc.RootElement;
+
+ Assert.True(root.TryGetProperty("enabled", out _));
+ Assert.True(root.TryGetProperty("isInstalled", out var isInstalledProp));
+ Assert.True(isInstalledProp.GetBoolean());
+ Assert.True(root.TryGetProperty("configured", out _));
+ Assert.True(root.TryGetProperty("loadedPromptSections", out var sectionsProp));
+ Assert.True(sectionsProp.GetArrayLength() > 0);
+ }
+
+ [Fact]
+ public async Task GetAiPrompts_Returns200AndPromptSections()
+ {
+ var response = await _client.GetAsync("/api/ai/prompts");
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+
+ var content = await response.Content.ReadAsStringAsync();
+ using var doc = JsonDocument.Parse(content);
+ var root = doc.RootElement;
+
+ Assert.True(root.TryGetProperty("promptsDirectory", out var dirProp));
+ Assert.NotNull(dirProp.GetString());
+
+ Assert.True(root.TryGetProperty("sections", out var secProp));
+ Assert.True(secProp.TryGetProperty("system-prompt", out _));
+ Assert.True(secProp.TryGetProperty("capabilities", out _));
+ Assert.True(secProp.TryGetProperty("workflows", out _));
+ Assert.True(secProp.TryGetProperty("app-control", out _));
+ }
+
+ [Fact]
+ public async Task PostAiPromptsReload_Returns200AndSuccess()
+ {
+ var response = await _client.PostAsync("/api/ai/prompts/reload", null);
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+
+ var content = await response.Content.ReadAsStringAsync();
+ using var doc = JsonDocument.Parse(content);
+ var root = doc.RootElement;
+
+ Assert.True(root.GetProperty("success").GetBoolean());
+ Assert.True(root.GetProperty("sections").TryGetProperty("system-prompt", out _));
+ }
+
+ [Fact]
+ public async Task PostAiValidate_InvalidEndpoint_ReturnsValidationResultWithFailure()
+ {
+ var payload = new
+ {
+ endpoint = "http://127.0.0.1:9999/v1",
+ apiKey = "test-key",
+ model = "gpt-4o"
+ };
+
+ var response = await _client.PostAsJsonAsync("/api/ai/validate", payload);
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+
+ var result = await response.Content.ReadFromJsonAsync(JsonOptions);
+ Assert.NotNull(result);
+ Assert.False(result.Success);
+ Assert.NotEmpty(result.Message);
+ }
+
+ [Fact]
+ public async Task GetAiModels_InvalidEndpoint_ReturnsBadRequest()
+ {
+ var response = await _client.GetAsync("/api/ai/models?endpoint=http://invalid-endpoint-xyz-999:1234/v1");
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task GetAiModels_WithMockOrInvalidEndpoint_BehavesPredictably()
+ {
+ var response = await _client.GetAsync("/api/ai/models?endpoint=http://invalid-endpoint-xyz-999:1234/v1&includeLocal=false");
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task GetModels_ReturnsModelCapabilitiesList()
+ {
+ var mockService = new Mock();
+ var expectedList = new List
+ {
+ new("vertex_ai/gemini-2.5-flash", "Gemini 2.5 Flash", "vertex_ai", SupportsVision: true, SupportsFunctionCalling: true)
+ };
+ mockService
+ .Setup(s => s.GetModelCapabilitiesAsync(It.IsAny(), It.IsAny(), false, It.IsAny()))
+ .ReturnsAsync(expectedList);
+
+ var result = await mockService.Object.GetModelCapabilitiesAsync(null, null, false);
+ Assert.Single(result);
+ Assert.Equal("vertex_ai/gemini-2.5-flash", result[0].Id);
+ Assert.True(result[0].SupportsVision);
+ }
+
+ [Fact]
+ public async Task GetAiModels_WhenSuccessful_ReturnsModelCapabilitiesList()
+ {
+ var mockService = new Mock();
+ var expectedList = new List
+ {
+ new(
+ Id: "openai/gpt-4o",
+ DisplayName: "GPT-4o",
+ Provider: "openai",
+ Mode: "chat",
+ SupportsVision: true,
+ SupportsFunctionCalling: true,
+ SupportsAudio: false,
+ MaxInputTokens: 128000,
+ MaxOutputTokens: 4096,
+ IsLocal: false
+ )
+ };
+
+ mockService
+ .Setup(s => s.GetModelCapabilitiesAsync("http://custom-endpoint:1234/v1", "test-key", true, It.IsAny()))
+ .ReturnsAsync(expectedList);
+
+ var builder = WebApplication.CreateBuilder();
+ builder.Services.AddSingleton(mockService.Object);
+ builder.Services.AddSingleton(new Mock().Object);
+ builder.Services.AddSingleton(new Mock().Object);
+ builder.Services.AddSingleton(new Mock().Object);
+
+ var tcp = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0);
+ tcp.Start();
+ int port = ((System.Net.IPEndPoint)tcp.LocalEndpoint).Port;
+ tcp.Stop();
+
+ builder.WebHost.UseUrls($"http://127.0.0.1:{port}");
+ var app = builder.Build();
+ app.MapAiAssistantEndpoints();
+ await app.StartAsync();
+
+ try
+ {
+ using var client = new HttpClient { BaseAddress = new Uri($"http://127.0.0.1:{port}") };
+ var response = await client.GetAsync("/api/ai/models?endpoint=http://custom-endpoint:1234/v1&apiKey=test-key&includeLocal=true");
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+
+ var models = await response.Content.ReadFromJsonAsync>(JsonOptions);
+ Assert.NotNull(models);
+ Assert.Single(models);
+ Assert.Equal("openai/gpt-4o", models[0].Id);
+ Assert.Equal("GPT-4o", models[0].DisplayName);
+ Assert.Equal("openai", models[0].Provider);
+ Assert.True(models[0].SupportsVision);
+ Assert.True(models[0].SupportsFunctionCalling);
+ Assert.Equal(128000, models[0].MaxInputTokens);
+
+ mockService.Verify(s => s.GetModelCapabilitiesAsync("http://custom-endpoint:1234/v1", "test-key", true, It.IsAny()), Times.Once);
+ }
+ finally
+ {
+ await app.StopAsync();
+ await app.DisposeAsync();
+ }
+ }
+
+ [Fact]
+ public async Task GetAiModels_DefaultIncludeLocal_PassesFalseToService()
+ {
+ var mockService = new Mock();
+ mockService
+ .Setup(s => s.GetModelCapabilitiesAsync(It.IsAny(), It.IsAny(), false, It.IsAny()))
+ .ReturnsAsync(new List());
+
+ var builder = WebApplication.CreateBuilder();
+ builder.Services.AddSingleton(mockService.Object);
+ builder.Services.AddSingleton(new Mock().Object);
+ builder.Services.AddSingleton(new Mock().Object);
+ builder.Services.AddSingleton(new Mock().Object);
+
+ var tcp = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0);
+ tcp.Start();
+ int port = ((System.Net.IPEndPoint)tcp.LocalEndpoint).Port;
+ tcp.Stop();
+
+ builder.WebHost.UseUrls($"http://127.0.0.1:{port}");
+ var app = builder.Build();
+ app.MapAiAssistantEndpoints();
+ await app.StartAsync();
+
+ try
+ {
+ using var client = new HttpClient { BaseAddress = new Uri($"http://127.0.0.1:{port}") };
+ var response = await client.GetAsync("/api/ai/models");
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+
+ mockService.Verify(s => s.GetModelCapabilitiesAsync(null, null, false, It.IsAny()), Times.Once);
+ }
+ finally
+ {
+ await app.StopAsync();
+ await app.DisposeAsync();
+ }
+ }
+
+ [Fact]
+ public async Task GetAiModels_WhenServiceThrows_ReturnsBadRequestWithError()
+ {
+ var mockService = new Mock();
+ mockService
+ .Setup(s => s.GetModelCapabilitiesAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()))
+ .ThrowsAsync(new InvalidOperationException("Endpoint failure simulation"));
+
+ var builder = WebApplication.CreateBuilder();
+ builder.Services.AddSingleton(mockService.Object);
+ builder.Services.AddSingleton(new Mock().Object);
+ builder.Services.AddSingleton(new Mock().Object);
+ builder.Services.AddSingleton(new Mock().Object);
+
+ var tcp = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0);
+ tcp.Start();
+ int port = ((System.Net.IPEndPoint)tcp.LocalEndpoint).Port;
+ tcp.Stop();
+
+ builder.WebHost.UseUrls($"http://127.0.0.1:{port}");
+ var app = builder.Build();
+ app.MapAiAssistantEndpoints();
+ await app.StartAsync();
+
+ try
+ {
+ using var client = new HttpClient { BaseAddress = new Uri($"http://127.0.0.1:{port}") };
+ var response = await client.GetAsync("/api/ai/models");
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+
+ var errorDoc = await response.Content.ReadFromJsonAsync();
+ Assert.True(errorDoc.TryGetProperty("error", out var errorProp));
+ Assert.Equal("Endpoint failure simulation", errorProp.GetString());
+ }
+ finally
+ {
+ await app.StopAsync();
+ await app.DisposeAsync();
+ }
+ }
+
+ [Fact]
+ public async Task PostAiChat_EmptyMessages_ReturnsBadRequest()
+ {
+ var request = new AiChatRequest(new List());
+ var response = await _client.PostAsJsonAsync("/api/ai/chat", request);
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task PostAiChat_NullBody_ReturnsBadRequest()
+ {
+ var content = new StringContent("{}", System.Text.Encoding.UTF8, "application/json");
+ var response = await _client.PostAsync("/api/ai/chat", content);
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task PostAiChat_UnconfiguredEndpoint_ReturnsGracefulFailure()
+ {
+ var request = new AiChatRequest(new List
+ {
+ new() { Role = "user", Content = "Hello AI" }
+ });
+
+ var response = await _client.PostAsJsonAsync("/api/ai/chat", request);
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+
+ var chatResponse = await response.Content.ReadFromJsonAsync(JsonOptions);
+ Assert.NotNull(chatResponse);
+ // Since endpoint is unconfigured in default settings, it should report failure gracefully
+ Assert.False(chatResponse.Success);
+ Assert.False(string.IsNullOrWhiteSpace(chatResponse.Error));
+ }
+
+ [Fact]
+ public async Task PostAiChat_StreamMode_UnconfiguredEndpoint_StreamsFinalChunkWithError()
+ {
+ var request = new AiChatRequest(
+ Messages: new List
+ {
+ new() { Role = "user", Content = "Hello AI stream" }
+ },
+ Stream: true
+ );
+
+ var response = await _client.PostAsJsonAsync("/api/ai/chat", request);
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ Assert.Equal("text/event-stream", response.Content.Headers.ContentType?.MediaType);
+
+ var streamData = await response.Content.ReadAsStringAsync();
+ Assert.Contains("data:", streamData);
+ Assert.Contains("error", streamData, StringComparison.OrdinalIgnoreCase);
+ }
+}
diff --git a/LocalLLMServerManager.Tests/AiAssistantLiveLlmTests.cs b/LocalLLMServerManager.Tests/AiAssistantLiveLlmTests.cs
new file mode 100644
index 0000000..6a7619d
--- /dev/null
+++ b/LocalLLMServerManager.Tests/AiAssistantLiveLlmTests.cs
@@ -0,0 +1,316 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+using LocalLLMServerManager.Services;
+using LocalLLMServerManager.Shared.Interfaces;
+using LocalLLMServerManager.Shared.Models;
+using LocalLLMServerManager.Shared.Services;
+using Moq;
+using Xunit;
+
+namespace LocalLLMServerManager.Tests;
+
+///
+/// Live integration test suite for verifying connectivity, tool calling, and streaming
+/// against real OpenAI-compatible endpoints (such as LiteLLM, Vertex AI Gemini Flash, or Ollama).
+///
+/// To run these live tests against your local or hosted LiteLLM proxy:
+/// Set the environment variables:
+/// - LITELLM_ENDPOINT (e.g. http://127.0.0.1:4000/v1)
+/// - LITELLM_API_KEY (e.g. sk-litellm-secret-key)
+/// - LITELLM_MODEL (e.g. vertex_ai/gemini-2.5-flash)
+///
+/// If these variables are not set or the endpoint is unreachable, tests skip gracefully.
+///
+public class AiAssistantLiveLlmTests
+{
+ private static string? GetLiveEndpoint() =>
+ Environment.GetEnvironmentVariable("LITELLM_ENDPOINT")
+ ?? Environment.GetEnvironmentVariable("AI_ASSISTANT_ENDPOINT")
+ ?? Environment.GetEnvironmentVariable("OPENAI_BASE_URL");
+
+ private static string? GetLiveApiKey() =>
+ Environment.GetEnvironmentVariable("LITELLM_API_KEY")
+ ?? Environment.GetEnvironmentVariable("AI_ASSISTANT_API_KEY")
+ ?? Environment.GetEnvironmentVariable("OPENAI_API_KEY");
+
+ private static string GetLiveModel() =>
+ Environment.GetEnvironmentVariable("LITELLM_MODEL")
+ ?? Environment.GetEnvironmentVariable("AI_ASSISTANT_MODEL")
+ ?? "vertex_ai/gemini-2.5-flash";
+
+ private static async Task IsEndpointReachableAsync(string endpoint)
+ {
+ try
+ {
+ using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(3) };
+ var url = endpoint.TrimEnd('/') + "/models";
+ var resp = await client.GetAsync(url);
+ return resp.IsSuccessStatusCode;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ private static AiAssistantService CreateLiveAssistantService(string? endpoint, string? apiKey, string? model)
+ {
+ var mockHttpFactory = new Mock();
+ mockHttpFactory.Setup(f => f.CreateClient(It.IsAny())).Returns(new HttpClient());
+
+ var mockSettings = new Mock();
+ mockSettings.Setup(s => s.LoadSettings()).Returns(new AppSettings(
+ AiAssistantEnabled: true,
+ AiAssistantEndpoint: endpoint,
+ AiAssistantApiKey: apiKey,
+ AiAssistantModel: model
+ ));
+
+ var promptService = new PromptManagementService();
+ var telemetryProvider = new Mock();
+ telemetryProvider.Setup(t => t.GetGpuInfo()).Returns(("NVIDIA GeForce RTX 4070 Ti SUPER", 16384L * 1024 * 1024, 4096L * 1024 * 1024));
+
+ var tools = new AiAppTools(
+ telemetryProvider.Object,
+ new Mock().Object,
+ new Mock().Object,
+ mockSettings.Object,
+ new Mock().Object,
+ mockHttpFactory.Object
+ );
+
+ return new AiAssistantService(mockSettings.Object, promptService, tools, mockHttpFactory.Object);
+ }
+
+ [Fact]
+ public void EnvironmentConfiguration_HasReasonableDefaults()
+ {
+ var model = GetLiveModel();
+ Assert.False(string.IsNullOrWhiteSpace(model));
+ }
+
+ [Fact]
+ public async Task LiveEndpoint_ValidateConnection_ReturnsSuccess_WhenReachable()
+ {
+ var endpoint = GetLiveEndpoint();
+ if (string.IsNullOrWhiteSpace(endpoint))
+ {
+ Assert.Skip("Live LLM endpoint not configured (set LITELLM_ENDPOINT or AI_ASSISTANT_ENDPOINT).");
+ return;
+ }
+
+ var isReachable = await IsEndpointReachableAsync(endpoint);
+ if (!isReachable)
+ {
+ Assert.Skip($"Configured endpoint '{endpoint}' is currently unreachable.");
+ return;
+ }
+
+ var apiKey = GetLiveApiKey();
+ var model = GetLiveModel();
+
+ var service = CreateLiveAssistantService(endpoint, apiKey, model);
+ var result = await service.ValidateConnectionAsync(endpoint, apiKey, model);
+
+ Assert.True(result.Success, $"Expected connection success but got: {result.Message}");
+ Assert.True(result.LatencyMs > 0);
+ }
+
+ [Fact]
+ public async Task LiveEndpoint_SendChat_ExecutesToolCall_WhenReachable()
+ {
+ var endpoint = GetLiveEndpoint();
+ if (string.IsNullOrWhiteSpace(endpoint))
+ {
+ Assert.Skip("Live LLM endpoint not configured (set LITELLM_ENDPOINT or AI_ASSISTANT_ENDPOINT).");
+ return;
+ }
+
+ var isReachable = await IsEndpointReachableAsync(endpoint);
+ if (!isReachable)
+ {
+ Assert.Skip($"Configured endpoint '{endpoint}' is currently unreachable.");
+ return;
+ }
+
+ var apiKey = GetLiveApiKey();
+ var model = GetLiveModel();
+
+ var service = CreateLiveAssistantService(endpoint, apiKey, model);
+
+ var request = new AiChatRequest(new List
+ {
+ new() { Role = "user", Content = "What is my current VRAM usage? Call the tool to check." }
+ }, Model: model);
+
+ var response = await service.SendChatAsync(request);
+
+ Assert.True(response.Success);
+ Assert.NotNull(response.Message);
+ Assert.False(string.IsNullOrWhiteSpace(response.Message.Content));
+ Assert.True(response.Message.HasToolCalls);
+ }
+
+ [Fact]
+ public async Task LiveEndpoint_StreamChat_YieldsTokens_WhenReachable()
+ {
+ var endpoint = GetLiveEndpoint();
+ if (string.IsNullOrWhiteSpace(endpoint))
+ {
+ Assert.Skip("Live LLM endpoint not configured (set LITELLM_ENDPOINT or AI_ASSISTANT_ENDPOINT).");
+ return;
+ }
+
+ var isReachable = await IsEndpointReachableAsync(endpoint);
+ if (!isReachable)
+ {
+ Assert.Skip($"Configured endpoint '{endpoint}' is currently unreachable.");
+ return;
+ }
+
+ var apiKey = GetLiveApiKey();
+ var model = GetLiveModel();
+
+ var service = CreateLiveAssistantService(endpoint, apiKey, model);
+
+ var request = new AiChatRequest(new List
+ {
+ new() { Role = "user", Content = "Say 'Antigravity AI is live!' and nothing else." }
+ }, Stream: true, Model: model);
+
+ var chunks = new List();
+ await foreach (var chunk in service.StreamChatAsync(request))
+ {
+ chunks.Add(chunk);
+ }
+
+ Assert.NotEmpty(chunks);
+ }
+
+ [Fact]
+ public async Task LiveEndpoint_DiscoverModelCapabilities_ExcludesLocal_WhenReachable()
+ {
+ var endpoint = GetLiveEndpoint();
+ if (string.IsNullOrWhiteSpace(endpoint))
+ {
+ Assert.Skip("Live LLM endpoint not configured.");
+ return;
+ }
+
+ if (!await IsEndpointReachableAsync(endpoint))
+ {
+ Assert.Skip($"Configured endpoint '{endpoint}' is currently unreachable.");
+ return;
+ }
+
+ var service = CreateLiveAssistantService(endpoint, GetLiveApiKey(), GetLiveModel());
+ var models = await service.GetModelCapabilitiesAsync(endpoint, GetLiveApiKey(), includeLocal: false);
+
+ Assert.NotEmpty(models);
+ Assert.DoesNotContain(models, m => m.Id.StartsWith("ollama/", StringComparison.OrdinalIgnoreCase));
+ Assert.DoesNotContain(models, m => m.IsLocal);
+ }
+
+ [Fact]
+ public async Task LiveEndpoint_MultimodalChat_SendsImageAndReceivesResponse_WhenReachable()
+ {
+ var endpoint = GetLiveEndpoint();
+ if (string.IsNullOrWhiteSpace(endpoint))
+ {
+ Assert.Skip("Live LLM endpoint not configured.");
+ return;
+ }
+
+ if (!await IsEndpointReachableAsync(endpoint))
+ {
+ Assert.Skip($"Configured endpoint '{endpoint}' is currently unreachable.");
+ return;
+ }
+
+ var service = CreateLiveAssistantService(endpoint, GetLiveApiKey(), GetLiveModel());
+ var models = await service.GetModelCapabilitiesAsync(endpoint, GetLiveApiKey(), includeLocal: false);
+ var visionModel = models.FirstOrDefault(m => m.SupportsVision)?.Id ?? GetLiveModel();
+
+ // 1x1 red PNG base64
+ var samplePngBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==";
+
+ var request = new AiChatRequest(
+ Messages: new List
+ {
+ new()
+ {
+ Role = "user",
+ Content = "What color is this single pixel image? Respond in one word.",
+ Attachments = new List
+ {
+ new()
+ {
+ FileName = "pixel.png",
+ ContentType = "image/png",
+ Base64Data = samplePngBase64
+ }
+ }
+ }
+ },
+ Model: visionModel
+ );
+
+ var response = await service.SendChatAsync(request);
+ Assert.True(response.Success);
+ Assert.NotNull(response.Message);
+ Assert.False(string.IsNullOrWhiteSpace(response.Message.Content));
+ }
+
+ [Fact]
+ public async Task LiveEndpoint_MultimodalToolCall_ExecutesSuccessfully_WhenReachable()
+ {
+ var endpoint = GetLiveEndpoint();
+ if (string.IsNullOrWhiteSpace(endpoint))
+ {
+ Assert.Skip("Live LLM endpoint not configured.");
+ return;
+ }
+
+ if (!await IsEndpointReachableAsync(endpoint))
+ {
+ Assert.Skip($"Configured endpoint '{endpoint}' is currently unreachable.");
+ return;
+ }
+
+ var service = CreateLiveAssistantService(endpoint, GetLiveApiKey(), GetLiveModel());
+ var models = await service.GetModelCapabilitiesAsync(endpoint, GetLiveApiKey(), includeLocal: false);
+ var visionModel = models.FirstOrDefault(m => m.SupportsVision && m.SupportsFunctionCalling)?.Id ?? GetLiveModel();
+
+ var samplePngBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==";
+
+ var request = new AiChatRequest(
+ Messages: new List
+ {
+ new()
+ {
+ Role = "user",
+ Content = "Here is an image. Query live VRAM telemetry and tell me how much VRAM is free.",
+ Attachments = new List
+ {
+ new()
+ {
+ FileName = "status.png",
+ ContentType = "image/png",
+ Base64Data = samplePngBase64
+ }
+ }
+ }
+ },
+ Model: visionModel
+ );
+
+ var response = await service.SendChatAsync(request);
+ Assert.True(response.Success);
+ Assert.NotNull(response.Message);
+ Assert.True(response.Message.HasToolCalls);
+ }
+}
diff --git a/LocalLLMServerManager.Tests/AiAssistantServiceTests.cs b/LocalLLMServerManager.Tests/AiAssistantServiceTests.cs
new file mode 100644
index 0000000..4352afb
--- /dev/null
+++ b/LocalLLMServerManager.Tests/AiAssistantServiceTests.cs
@@ -0,0 +1,551 @@
+using System;
+using System.Collections.Generic;
+using System.Net;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+using LocalLLMServerManager.Services;
+using LocalLLMServerManager.Shared.Interfaces;
+using LocalLLMServerManager.Shared.Models;
+using LocalLLMServerManager.Shared.Services;
+using Moq;
+using Moq.Protected;
+using Xunit;
+
+namespace LocalLLMServerManager.Tests;
+
+public class AiAssistantServiceTests
+{
+ private readonly Mock _mockSettings = new();
+ private readonly Mock _mockPromptService = new();
+ private readonly Mock _mockAppTools = new();
+ private readonly Mock _mockHttpFactory = new();
+
+ private AiAssistantService CreateService(HttpMessageHandler? handler = null, AppSettings? customSettings = null)
+ {
+ var httpHandler = handler ?? new MockHttpMessageHandler(HttpStatusCode.OK, "{\"data\":[{\"id\":\"google/gemini-2.5-flash\"}]}");
+ var client = new HttpClient(httpHandler);
+ _mockHttpFactory.Setup(f => f.CreateClient(It.IsAny())).Returns(client);
+
+ _mockSettings.Setup(s => s.LoadSettings()).Returns(customSettings ?? new AppSettings(
+ AiAssistantEndpoint: "http://127.0.0.1:4000/v1",
+ AiAssistantApiKey: "test-key-123",
+ AiAssistantModel: "google/gemini-2.5-flash"
+ ));
+
+ _mockPromptService.Setup(p => p.BuildFullSystemPromptAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync("System instructions");
+
+ return new AiAssistantService(
+ _mockSettings.Object,
+ _mockPromptService.Object,
+ _mockAppTools.Object,
+ _mockHttpFactory.Object
+ );
+ }
+
+ [Fact]
+ public async Task ValidateConnection_EmptyEndpoint_ReturnsFailure()
+ {
+ var service = CreateService(customSettings: new AppSettings(AiAssistantEndpoint: ""));
+
+ var result = await service.ValidateConnectionAsync(endpoint: "");
+
+ Assert.False(result.Success);
+ Assert.Contains("Endpoint URL is required", result.Message);
+ }
+
+ [Fact]
+ public async Task ValidateConnection_SuccessfulResponse_ParsesModelsAndLatency()
+ {
+ var handler = new MockHttpMessageHandler(HttpStatusCode.OK, "{\"data\":[{\"id\":\"google/gemini-2.5-flash\"},{\"id\":\"vertex/gemini-1.5-flash\"}]}");
+ var service = CreateService(handler);
+
+ var result = await service.ValidateConnectionAsync(
+ endpoint: "http://127.0.0.1:4000/v1",
+ apiKey: "valid-key",
+ model: null
+ );
+
+ Assert.True(result.Success);
+ Assert.Equal(2, result.AvailableModels.Count);
+ Assert.Contains("google/gemini-2.5-flash", result.AvailableModels);
+ }
+
+ [Fact]
+ public async Task ValidateConnection_ServerError_ReturnsFormattedErrorMessage()
+ {
+ var handler = new MockHttpMessageHandler(HttpStatusCode.Unauthorized, "{\"error\":{\"message\":\"Invalid API key provided\"}}");
+ var service = CreateService(handler);
+
+ var result = await service.ValidateConnectionAsync(
+ endpoint: "http://127.0.0.1:4000/v1",
+ apiKey: "bad-key",
+ model: "google/gemini-2.5-flash"
+ );
+
+ Assert.False(result.Success);
+ Assert.Contains("401", result.Message);
+ Assert.Contains("Invalid API key", result.Message);
+ }
+
+ [Fact]
+ public void BuildAiFunctions_RegistersAll12AppBridgeTools()
+ {
+ var service = CreateService();
+ var functions = service.BuildAiFunctions();
+
+ Assert.NotNull(functions);
+ Assert.Equal(12, functions.Count);
+
+ var names = new HashSet();
+ foreach (var f in functions)
+ {
+ names.Add(f.Name);
+ }
+
+ Assert.Contains("get_gpu_vram_telemetry", names);
+ Assert.Contains("check_services_health", names);
+ Assert.Contains("list_installed_models", names);
+ Assert.Contains("start_ai_engine", names);
+ Assert.Contains("stop_ai_engine", names);
+ Assert.Contains("unload_vram", names);
+ Assert.Contains("get_app_settings", names);
+ Assert.Contains("update_app_setting", names);
+ Assert.Contains("calculate_hardware_fit", names);
+ Assert.Contains("generate_image", names);
+ Assert.Contains("synthesize_speech", names);
+ Assert.Contains("query_app_documentation", names);
+ }
+
+ [Fact]
+ public async Task SendChat_UnreachableEndpoint_ReturnsFailureMessage()
+ {
+ var service = CreateService();
+
+ var request = new AiChatRequest(new List
+ {
+ new() { Role = "user", Content = "Hello copilot" }
+ });
+
+ var response = await service.SendChatAsync(request);
+
+ Assert.False(response.Success);
+ Assert.False(string.IsNullOrWhiteSpace(response.Error));
+ }
+
+ [Fact]
+ public async Task SendChat_WithImageAttachment_HandlesMultimodalMessage()
+ {
+ var service = CreateService();
+
+ var request = new AiChatRequest(new List
+ {
+ new()
+ {
+ Role = "user",
+ Content = "Look at this",
+ Attachments = new List
+ {
+ new() { FileName = "pic.png", ContentType = "image/png", Base64Data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=" }
+ }
+ }
+ });
+
+ var response = await service.SendChatAsync(request);
+
+ Assert.False(response.Success);
+ Assert.False(string.IsNullOrWhiteSpace(response.Error));
+ }
+
+ [Fact]
+ public void AiModelCapabilityInfo_FormattingAndProperties_AreValid()
+ {
+ var model = new AiModelCapabilityInfo(
+ Id: "vertex_ai/gemini-2.5-flash",
+ DisplayName: "Gemini 2.5 Flash",
+ Provider: "vertex_ai",
+ Mode: "chat",
+ SupportsVision: true,
+ SupportsFunctionCalling: true,
+ MaxInputTokens: 1000000,
+ MaxOutputTokens: 8192,
+ IsLocal: false
+ );
+
+ Assert.Equal("vertex_ai/gemini-2.5-flash", model.Id);
+ Assert.True(model.SupportsVision);
+ Assert.True(model.SupportsFunctionCalling);
+ Assert.False(model.IsLocal);
+ Assert.Contains("ποΈ", model.SummaryBadge);
+ Assert.Contains("β‘", model.SummaryBadge);
+ Assert.Contains("1M", model.SummaryBadge);
+ Assert.Contains("[vertex_ai]", model.SummaryBadge);
+ }
+
+ [Fact]
+ public void AiModelCapabilityInfo_SummaryBadge_FormatsDifferentTokenCountsAndCapabilities()
+ {
+ var modelKilo = new AiModelCapabilityInfo("ollama/llama3", "Llama 3", "ollama", MaxInputTokens: 8192, SupportsVision: false, SupportsFunctionCalling: false);
+ Assert.DoesNotContain("ποΈ", modelKilo.SummaryBadge);
+ Assert.DoesNotContain("β‘", modelKilo.SummaryBadge);
+ Assert.Contains("8k", modelKilo.SummaryBadge);
+ Assert.Contains("[ollama]", modelKilo.SummaryBadge);
+
+ var modelSmall = new AiModelCapabilityInfo("local/tiny", "Tiny", "local", MaxInputTokens: 500, SupportsVision: false, SupportsFunctionCalling: false);
+ Assert.Contains("500", modelSmall.SummaryBadge);
+
+ var modelNone = new AiModelCapabilityInfo("unknown/model", "Model", "unknown", MaxInputTokens: null, SupportsVision: false, SupportsFunctionCalling: false);
+ Assert.Equal("[unknown]", modelNone.SummaryBadge);
+ }
+
+ [Fact]
+ public void AiChatMessageItem_WithAttachments_ReportsHasAttachmentsTrue()
+ {
+ var defaultItem = new AiChatMessageItem();
+ Assert.NotNull(defaultItem.Attachments);
+ Assert.Empty(defaultItem.Attachments);
+ Assert.False(defaultItem.HasAttachments);
+
+ var msg = new AiChatMessageItem
+ {
+ Role = "user",
+ Content = "Inspect this image",
+ Attachments = new List
+ {
+ new() { FileName = "test.png", ContentType = "image/png", Base64Data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=" }
+ }
+ };
+
+ Assert.True(msg.HasAttachments);
+ Assert.Single(msg.Attachments);
+ Assert.Equal("image/png", msg.Attachments[0].ContentType);
+ Assert.Equal("test.png", msg.Attachments[0].FileName);
+ Assert.False(string.IsNullOrWhiteSpace(msg.Attachments[0].Id));
+ }
+
+ [Fact]
+ public void ParseModelCapabilities_ExcludesLocalModels_WhenIncludeLocalIsFalse()
+ {
+ var sampleLiteLlmJson = """
+ {
+ "data": [
+ {
+ "id": "vertex_ai/gemini-2.5-flash",
+ "model_info": {
+ "mode": "chat",
+ "litellm_provider": "vertex_ai",
+ "supports_vision": true,
+ "supports_function_calling": true,
+ "max_input_tokens": 1048576,
+ "max_output_tokens": 8192
+ }
+ },
+ {
+ "id": "openai/gpt-4o",
+ "model_info": {
+ "mode": "chat",
+ "litellm_provider": "openai",
+ "supports_vision": true,
+ "supports_function_calling": true,
+ "max_tokens": 128000
+ }
+ },
+ {
+ "id": "ollama/llama3.2:latest",
+ "model_info": {
+ "mode": "chat",
+ "litellm_provider": "ollama",
+ "supports_vision": false,
+ "supports_function_calling": true
+ }
+ },
+ {
+ "id": "local/mistral-7b",
+ "model_info": {
+ "mode": "chat",
+ "litellm_provider": "local"
+ }
+ }
+ ]
+ }
+ """;
+
+ var models = AiAssistantService.ParseModelCapabilitiesFromJson(sampleLiteLlmJson, includeLocal: false);
+
+ Assert.Equal(2, models.Count);
+ Assert.Contains(models, m => m.Id == "vertex_ai/gemini-2.5-flash" && m.SupportsVision && m.MaxInputTokens == 1048576);
+ Assert.Contains(models, m => m.Id == "openai/gpt-4o" && m.SupportsVision && m.MaxInputTokens == 128000);
+ Assert.DoesNotContain(models, m => m.Id.StartsWith("ollama/"));
+ Assert.DoesNotContain(models, m => m.Id.StartsWith("local/"));
+ }
+
+ [Fact]
+ public void ParseModelCapabilities_IncludesLocalModels_WhenIncludeLocalIsTrue()
+ {
+ var sampleLiteLlmJson = """
+ {
+ "data": [
+ {
+ "id": "vertex_ai/gemini-2.5-flash",
+ "model_info": { "litellm_provider": "vertex_ai" }
+ },
+ {
+ "id": "ollama/llama3.2",
+ "model_info": { "litellm_provider": "ollama" }
+ }
+ ]
+ }
+ """;
+
+ var models = AiAssistantService.ParseModelCapabilitiesFromJson(sampleLiteLlmJson, includeLocal: true);
+
+ Assert.Equal(2, models.Count);
+ Assert.Contains(models, m => m.Id == "ollama/llama3.2" && m.IsLocal);
+ }
+
+ [Theory]
+ [InlineData("custom-model", "ollama", true)]
+ [InlineData("custom-model", "local", true)]
+ [InlineData("custom-model", "llama.cpp", true)]
+ [InlineData("custom-model", "vllm_local", true)]
+ [InlineData("ollama/llama3.2", null, true)]
+ [InlineData("local/mistral", null, true)]
+ [InlineData("llama/model-7b", null, true)]
+ [InlineData("ollama_chat/qwen", null, true)]
+ [InlineData("vertex_ai/gemini-2.5-flash", "vertex_ai", false)]
+ [InlineData("openai/gpt-4o", "openai", false)]
+ [InlineData("anthropic/claude-3-5-sonnet", "anthropic", false)]
+ public void IsLocalModel_DetectsExpectedLocalPatterns(string id, string? provider, bool expectedLocal)
+ {
+ var isLocal = AiAssistantService.IsLocalModel(id, provider);
+ Assert.Equal(expectedLocal, isLocal);
+ }
+
+ [Fact]
+ public void ParseModelCapabilities_SparseJson_AppliesHeuristics()
+ {
+ var sparseJson = """
+ {
+ "data": [
+ { "id": "google/gemini-2.5-flash" },
+ { "id": "openai/gpt-4o" },
+ { "id": "anthropic/claude-3-opus" }
+ ]
+ }
+ """;
+
+ var models = AiAssistantService.ParseModelCapabilitiesFromJson(sparseJson, includeLocal: false);
+ Assert.Equal(3, models.Count);
+ Assert.All(models, m => Assert.True(m.SupportsVision));
+ Assert.All(models, m => Assert.True(m.SupportsFunctionCalling));
+ }
+
+ [Fact]
+ public async Task GetModelCapabilitiesAsync_WhenModelInfoReturns404_FallsBackToModelsEndpoint()
+ {
+ var handler = new RoutingHttpMessageHandler(req =>
+ {
+ if (req.RequestUri != null && (req.RequestUri.AbsolutePath.EndsWith("/model/info") || req.RequestUri.AbsolutePath.EndsWith("/v1/model_info")))
+ {
+ return new HttpResponseMessage(HttpStatusCode.NotFound);
+ }
+ return new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent("{\"data\":[{\"id\":\"openai/gpt-4o\"}]}", System.Text.Encoding.UTF8, "application/json")
+ };
+ });
+
+ var service = CreateService(handler);
+ var capabilities = await service.GetModelCapabilitiesAsync("http://127.0.0.1:4000/v1");
+
+ Assert.Single(capabilities);
+ Assert.Equal("openai/gpt-4o", capabilities[0].Id);
+ Assert.True(capabilities[0].SupportsVision);
+ }
+
+ [Fact]
+ public async Task GetModelCapabilitiesAsync_WhenPrimaryModelInfoReturns404_FallsBackToV1ModelInfo()
+ {
+ var handler = new RoutingHttpMessageHandler(req =>
+ {
+ if (req.RequestUri != null && req.RequestUri.AbsolutePath.EndsWith("/model/info"))
+ {
+ return new HttpResponseMessage(HttpStatusCode.NotFound);
+ }
+ if (req.RequestUri != null && req.RequestUri.AbsolutePath.EndsWith("/v1/model_info"))
+ {
+ return new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent("{\"data\":[{\"id\":\"from-v1-model-info\",\"model_info\":{\"max_tokens\":128000.0}}]}", System.Text.Encoding.UTF8, "application/json")
+ };
+ }
+ return new HttpResponseMessage(HttpStatusCode.NotFound);
+ });
+
+ var service = CreateService(handler);
+ var capabilities = await service.GetModelCapabilitiesAsync("http://127.0.0.1:4000/v1");
+
+ Assert.Single(capabilities);
+ Assert.Equal("from-v1-model-info", capabilities[0].Id);
+ Assert.Equal(128000, capabilities[0].MaxInputTokens);
+ }
+
+ [Fact]
+ public void ParseModelCapabilities_FloatingPointTokenValues_ParsesSuccessfully()
+ {
+ var json = """
+ {
+ "data": [
+ {
+ "id": "openai/gpt-4o",
+ "model_info": {
+ "max_input_tokens": 128000.0,
+ "max_output_tokens": 4096.0
+ }
+ },
+ {
+ "id": "google/gemini-2.5-pro",
+ "max_tokens": 2000000.0
+ }
+ ]
+ }
+ """;
+
+ var models = AiAssistantService.ParseModelCapabilitiesFromJson(json);
+ Assert.Equal(2, models.Count);
+ Assert.Equal(128000, models[0].MaxInputTokens);
+ Assert.Equal(4096, models[0].MaxOutputTokens);
+ Assert.Equal(2000000, models[1].MaxInputTokens);
+ }
+
+ [Fact]
+ public async Task GetAvailableModelsAsync_ReturnsExtractedModelIds()
+ {
+ var handler = new MockHttpMessageHandler(HttpStatusCode.OK, "{\"data\":[{\"id\":\"vertex_ai/gemini-2.5-flash\"},{\"id\":\"ollama/llama3.2\"}]}");
+ var service = CreateService(handler);
+
+ var models = await service.GetAvailableModelsAsync();
+
+ Assert.Single(models);
+ Assert.Equal("vertex_ai/gemini-2.5-flash", models[0]);
+ }
+
+ [Fact]
+ public async Task GetModelCapabilitiesAsync_EmptyEndpoint_ThrowsInvalidOperationException()
+ {
+ var service = CreateService(customSettings: new AppSettings(AiAssistantEndpoint: ""));
+ await Assert.ThrowsAsync(() => service.GetModelCapabilitiesAsync(endpoint: ""));
+ }
+
+ [Fact]
+ public void BuildExtensionsAiChatMessage_WithImageAttachment_CreatesMultimodalChatMessage()
+ {
+ var item = new AiChatMessageItem
+ {
+ Role = "user",
+ Content = "What is in this diagram?",
+ Attachments = new List
+ {
+ new()
+ {
+ FileName = "diagram.png",
+ ContentType = "image/png",
+ Base64Data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
+ }
+ }
+ };
+
+ var chatMessage = AiAssistantService.ToExtensionsAiChatMessage(item);
+
+ Assert.Equal(Microsoft.Extensions.AI.ChatRole.User, chatMessage.Role);
+ Assert.Equal(2, chatMessage.Contents.Count);
+ Assert.IsType(chatMessage.Contents[0]);
+ Assert.IsType(chatMessage.Contents[1]);
+ }
+
+ [Fact]
+ public void BuildExtensionsAiChatMessage_WithoutAttachments_ReturnsSimpleChatMessage()
+ {
+ var item = new AiChatMessageItem
+ {
+ Role = "assistant",
+ Content = "I can help with that."
+ };
+
+ var chatMessage = AiAssistantService.ToExtensionsAiChatMessage(item);
+
+ Assert.Equal(Microsoft.Extensions.AI.ChatRole.Assistant, chatMessage.Role);
+ Assert.Equal("I can help with that.", chatMessage.Text);
+ }
+
+ [Fact]
+ public void BuildExtensionsAiChatMessage_WithRawBytesAttachment_CreatesImageContentWithBytes()
+ {
+ var raw = new byte[] { 0x89, 0x50, 0x4E, 0x47 };
+ var item = new AiChatMessageItem
+ {
+ Role = "user",
+ Content = "Raw image test",
+ Attachments = new List
+ {
+ new()
+ {
+ FileName = "raw.png",
+ ContentType = "image/png",
+ RawBytes = raw
+ }
+ }
+ };
+
+ var chatMessage = AiAssistantService.ToExtensionsAiChatMessage(item);
+
+ Assert.Equal(2, chatMessage.Contents.Count);
+ var imgContent = Assert.IsType(chatMessage.Contents[1]);
+ Assert.Equal("image/png", imgContent.MediaType);
+ }
+
+ [Fact]
+ public void BuildExtensionsAiChatMessage_WithDataUriPrefix_CreatesImageContentWithUri()
+ {
+ var item = new AiChatMessageItem
+ {
+ Role = "user",
+ Content = "",
+ Attachments = new List
+ {
+ new()
+ {
+ FileName = "datauri.png",
+ ContentType = "image/png",
+ Base64Data = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
+ }
+ }
+ };
+
+ var chatMessage = AiAssistantService.ToExtensionsAiChatMessage(item);
+
+ Assert.Single(chatMessage.Contents);
+ var imgContent = Assert.IsType(chatMessage.Contents[0]);
+ Assert.NotNull(imgContent.Uri);
+ Assert.StartsWith("data:image/png;base64,", imgContent.Uri);
+ }
+
+
+ private class RoutingHttpMessageHandler : HttpMessageHandler
+ {
+ private readonly Func _handler;
+
+ public RoutingHttpMessageHandler(Func handler)
+ {
+ _handler = handler;
+ }
+
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ return Task.FromResult(_handler(request));
+ }
+ }
+}
+
+
diff --git a/LocalLLMServerManager.Tests/AiAssistantTabControlTests.cs b/LocalLLMServerManager.Tests/AiAssistantTabControlTests.cs
new file mode 100644
index 0000000..8e1e118
--- /dev/null
+++ b/LocalLLMServerManager.Tests/AiAssistantTabControlTests.cs
@@ -0,0 +1,121 @@
+using System;
+using System.Globalization;
+using Avalonia.Headless.XUnit;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+using Avalonia.Media.Imaging;
+using LocalLLMServerManager.Shared.Models;
+using LocalLLMServerManager.Shared.ViewModels;
+using LocalLLMServerManager.Shared.Views.Controls;
+using Xunit;
+
+namespace LocalLLMServerManager.Tests;
+
+public class AiAssistantTabControlTests
+{
+ // Minimal valid 1x1 PNG data
+ private const string Base641x1Png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=";
+
+ [AvaloniaFact]
+ public void AiAssistantTabControl_InstantiatesSuccessfully()
+ {
+ var control = new AiAssistantTabControl();
+ Assert.NotNull(control);
+
+ var vm = new AiAssistantViewModel();
+ control.DataContext = vm;
+ Assert.Same(vm, control.DataContext);
+ }
+
+ [AvaloniaFact]
+ public void AttachmentToImageConverter_NullOrInvalid_ReturnsNull()
+ {
+ var converter = AttachmentToImageConverter.Instance;
+
+ Assert.Null(converter.Convert(null, typeof(Bitmap), null, CultureInfo.InvariantCulture));
+ Assert.Null(converter.Convert("not-valid-base64!!!", typeof(Bitmap), null, CultureInfo.InvariantCulture));
+ Assert.Null(converter.Convert(12345, typeof(Bitmap), null, CultureInfo.InvariantCulture));
+
+ var corruptAttachment = new AiChatMessageAttachment
+ {
+ FileName = "bad.png",
+ Base64Data = "not-valid-base64!!!",
+ RawBytes = null
+ };
+ Assert.Null(converter.Convert(corruptAttachment, typeof(Bitmap), null, CultureInfo.InvariantCulture));
+
+ var emptyAttachment = new AiChatMessageAttachment
+ {
+ FileName = "empty.png",
+ Base64Data = "",
+ RawBytes = null
+ };
+ Assert.Null(converter.Convert(emptyAttachment, typeof(Bitmap), null, CultureInfo.InvariantCulture));
+ }
+
+ [AvaloniaFact]
+ public void AttachmentToImageConverter_ValidPngBytes_ReturnsBitmapAndCaches()
+ {
+ var converter = AttachmentToImageConverter.Instance;
+ var bytes = Convert.FromBase64String(Base641x1Png);
+
+ var attachment = new AiChatMessageAttachment
+ {
+ FileName = "test.png",
+ ContentType = "image/png",
+ RawBytes = bytes
+ };
+
+ var result1 = converter.Convert(attachment, typeof(Bitmap), null, CultureInfo.InvariantCulture);
+ Assert.NotNull(result1);
+ Assert.IsAssignableFrom(result1);
+
+ // Second call should return cached instance
+ var result2 = converter.Convert(attachment, typeof(Bitmap), null, CultureInfo.InvariantCulture);
+ Assert.Same(result1, result2);
+ }
+
+ [AvaloniaFact]
+ public void AttachmentToImageConverter_ValidBase64_ReturnsBitmap()
+ {
+ var converter = AttachmentToImageConverter.Instance;
+ var attachment = new AiChatMessageAttachment
+ {
+ FileName = "pixel.png",
+ ContentType = "image/png",
+ Base64Data = Base641x1Png
+ };
+
+ var result = converter.Convert(attachment, typeof(Bitmap), null, CultureInfo.InvariantCulture);
+ Assert.NotNull(result);
+ Assert.IsAssignableFrom(result);
+ }
+
+ [Fact]
+ public void AttachmentToImageConverter_ConvertBack_ThrowsNotSupportedException()
+ {
+ var converter = AttachmentToImageConverter.Instance;
+ Assert.Throws(() =>
+ converter.ConvertBack(null, typeof(object), null, CultureInfo.InvariantCulture));
+ }
+
+ [AvaloniaFact]
+ public void AiAssistantTabControl_EventHandlers_HandleNullTopLevelSafely()
+ {
+ var control = new AiAssistantTabControl();
+ control.DataContext = new AiAssistantViewModel();
+
+ // Calling click without visual tree / storage provider attached must not throw
+ control.OnAttachFileButtonClick(null, new RoutedEventArgs());
+
+ // Calling key down without visual tree / clipboard attached must not throw
+ var e = new KeyEventArgs
+ {
+ Key = Key.V,
+ KeyModifiers = KeyModifiers.Control,
+ RoutedEvent = InputElement.KeyDownEvent
+ };
+ control.OnPromptTextBoxKeyDown(null, e);
+ Assert.False(e.Handled);
+ }
+}
diff --git a/LocalLLMServerManager.Tests/AiAssistantViewModelTests.cs b/LocalLLMServerManager.Tests/AiAssistantViewModelTests.cs
new file mode 100644
index 0000000..10b620b
--- /dev/null
+++ b/LocalLLMServerManager.Tests/AiAssistantViewModelTests.cs
@@ -0,0 +1,384 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using LocalLLMServerManager.Shared.Interfaces;
+using LocalLLMServerManager.Shared.Models;
+using LocalLLMServerManager.Shared.ViewModels;
+using Moq;
+using Xunit;
+
+namespace LocalLLMServerManager.Tests;
+
+public class AiAssistantViewModelTests
+{
+ [Fact]
+ public void InitialState_HasWelcomeMessage_AndDefaultModel()
+ {
+ var vm = new AiAssistantViewModel();
+
+ Assert.Single(vm.Messages);
+ Assert.True(vm.Messages[0].IsAssistant);
+ Assert.Contains("Copilot", vm.Messages[0].Content);
+ Assert.False(string.IsNullOrWhiteSpace(vm.SelectedModel));
+ Assert.NotEmpty(vm.SuggestionChips);
+ Assert.False(vm.IsGenerating);
+ Assert.False(vm.IsSetupCardVisible);
+ }
+
+ [Fact]
+ public void ToggleSetupCard_InvertsVisibility()
+ {
+ var vm = new AiAssistantViewModel();
+ Assert.False(vm.IsSetupCardVisible);
+
+ vm.ToggleSetupCard();
+ Assert.True(vm.IsSetupCardVisible);
+
+ vm.ToggleSetupCard();
+ Assert.False(vm.IsSetupCardVisible);
+ }
+
+ [Fact]
+ public void ClearChat_ResetsMessagesToGreeting()
+ {
+ var vm = new AiAssistantViewModel();
+ vm.Messages.Add(new AiChatMessageItem { Role = "user", Content = "test 1" });
+ vm.Messages.Add(new AiChatMessageItem { Role = "assistant", Content = "test 2" });
+ Assert.Equal(3, vm.Messages.Count);
+
+ vm.ClearChat();
+ Assert.Single(vm.Messages);
+ Assert.True(vm.Messages[0].IsAssistant);
+ }
+
+ [Fact]
+ public void SaveConfiguration_UpdatesSettingsService()
+ {
+ var mockSettings = new Mock();
+ var settings = new AppSettings();
+ mockSettings.Setup(s => s.LoadSettings()).Returns(settings);
+ mockSettings.Setup(s => s.SaveSettings(It.IsAny()));
+
+ var vm = new AiAssistantViewModel(null, mockSettings.Object);
+ vm.Endpoint = "http://my-litellm:4000/v1";
+ vm.ApiKey = "sk-custom-key";
+ vm.SelectedModel = "gemini-2.5-flash";
+ vm.IsEnabled = true;
+
+ vm.SaveConfiguration();
+
+ mockSettings.Verify(s => s.SaveSettings(It.Is(st =>
+ st.AiAssistantEndpoint == "http://my-litellm:4000/v1" &&
+ st.AiAssistantApiKey == "sk-custom-key" &&
+ st.AiAssistantModel == "gemini-2.5-flash" &&
+ st.AiAssistantEnabled == true)), Times.Once);
+
+ Assert.False(vm.IsSetupCardVisible);
+ }
+
+ [Fact]
+ public async Task TestConnectionAsync_WithSuccessfulValidation_UpdatesStatusAndModels()
+ {
+ var mockAssistant = new Mock();
+ mockAssistant.Setup(a => a.ValidateConnectionAsync(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny()))
+ .ReturnsAsync(new AiValidationResult(
+ Success: true,
+ Message: "Connected successfully (Latency: 15 ms, 2 models found)",
+ AvailableModels: new List { "model-a", "model-b" },
+ LatencyMs: 15));
+
+ var vm = new AiAssistantViewModel(mockAssistant.Object);
+ vm.Endpoint = "http://127.0.0.1:4000/v1";
+ vm.SelectedModel = "model-a";
+
+ await vm.TestConnectionAsync();
+
+ Assert.True(vm.IsConnectionSuccess);
+ Assert.Contains("Connected successfully", vm.ConnectionStatusMessage);
+ Assert.Equal(2, vm.AvailableModels.Count);
+ Assert.Contains("model-a", vm.AvailableModels);
+ Assert.Contains("model-b", vm.AvailableModels);
+ }
+
+ [Fact]
+ public async Task TestConnectionAsync_WithFailedValidation_SetsFailureState()
+ {
+ var mockAssistant = new Mock();
+ mockAssistant.Setup(a => a.ValidateConnectionAsync(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny()))
+ .ReturnsAsync(new AiValidationResult(
+ Success: false,
+ Message: "Connection refused on port 4000",
+ AvailableModels: new List()));
+
+ var vm = new AiAssistantViewModel(mockAssistant.Object);
+
+ await vm.TestConnectionAsync();
+
+ Assert.False(vm.IsConnectionSuccess);
+ Assert.Contains("Connection refused", vm.ConnectionStatusMessage);
+ }
+
+ [Fact]
+ public async Task SendMessageAsync_DirectService_StreamsTokensAndRecordsToolCalls()
+ {
+ var mockAssistant = new Mock();
+
+ async IAsyncEnumerable MockStream(AiChatRequest req)
+ {
+ await Task.Yield();
+ yield return new AiChatChunk(ToolCall: new AiToolExecutionItem(ToolName: "GetVramTelemetry", Result: "VRAM: 8GB free", IsSuccess: true));
+ yield return new AiChatChunk(DeltaText: "You have 8GB ");
+ yield return new AiChatChunk(DeltaText: "of VRAM available.");
+ }
+
+ mockAssistant.Setup(a => a.StreamChatAsync(It.IsAny(), It.IsAny()))
+ .Returns((AiChatRequest req, CancellationToken ct) => MockStream(req));
+
+ var vm = new AiAssistantViewModel(mockAssistant.Object);
+ vm.InputText = "How much VRAM is free?";
+
+ await vm.SendMessageAsync();
+
+ // Should have initial greeting, user message, and assistant response
+ Assert.Equal(3, vm.Messages.Count);
+
+ var userMsg = vm.Messages[1];
+ Assert.True(userMsg.IsUser);
+ Assert.Equal("How much VRAM is free?", userMsg.Content);
+
+ var assistantMsg = vm.Messages[2];
+ Assert.True(assistantMsg.IsAssistant);
+ Assert.Equal("You have 8GB of VRAM available.", assistantMsg.Content);
+ Assert.True(assistantMsg.HasToolCalls);
+ Assert.Single(assistantMsg.ToolCalls);
+ Assert.Equal("GetVramTelemetry", assistantMsg.ToolCalls[0].ToolName);
+ Assert.False(assistantMsg.IsLoading);
+ Assert.False(vm.IsGenerating);
+ }
+
+ [Fact]
+ public async Task SelectSuggestionAsync_PopulatesInputAndSends()
+ {
+ var mockAssistant = new Mock();
+
+ async IAsyncEnumerable MockStream(AiChatRequest req)
+ {
+ await Task.Yield();
+ yield return new AiChatChunk(DeltaText: "Telemetry OK");
+ }
+
+ mockAssistant.Setup(a => a.StreamChatAsync(It.IsAny(), It.IsAny()))
+ .Returns((AiChatRequest req, CancellationToken ct) => MockStream(req));
+
+ var vm = new AiAssistantViewModel(mockAssistant.Object);
+
+ await vm.SelectSuggestionAsync("β‘ Check live VRAM and GPU telemetry");
+
+ Assert.Equal(3, vm.Messages.Count);
+ Assert.Equal("β‘ Check live VRAM and GPU telemetry", vm.Messages[1].Content);
+ Assert.Equal("Telemetry OK", vm.Messages[2].Content);
+ }
+
+ [Fact]
+ public void StagedAttachments_CanAddAndRemove()
+ {
+ var vm = new AiAssistantViewModel();
+ Assert.False(vm.HasStagedAttachments);
+
+ var att = new AiChatMessageAttachment
+ {
+ FileName = "screen.png",
+ ContentType = "image/png",
+ Base64Data = "abc"
+ };
+
+ vm.AddStagedAttachment(att);
+ Assert.True(vm.HasStagedAttachments);
+ Assert.Single(vm.StagedAttachments);
+
+ vm.RemoveStagedAttachment(att.Id);
+ Assert.False(vm.HasStagedAttachments);
+ Assert.Empty(vm.StagedAttachments);
+ }
+
+ [Fact]
+ public void ChangingSelectedModelCapability_UpdatesSelectedModelString()
+ {
+ var vm = new AiAssistantViewModel();
+ var cap = new AiModelCapabilityInfo("openai/gpt-4o", "GPT-4o", "openai", SupportsVision: true);
+
+ vm.SelectedModelCapability = cap;
+ Assert.Equal("openai/gpt-4o", vm.SelectedModel);
+ }
+
+ [Fact]
+ public void PasteImageBytes_AddsStagedAttachmentWithBase64()
+ {
+ var vm = new AiAssistantViewModel();
+ var bytes = new byte[] { 0x89, 0x50, 0x4E, 0x47 }; // PNG header bytes
+ vm.PasteImageBytes(bytes, "image/png");
+
+ Assert.True(vm.HasStagedAttachments);
+ var item = Assert.Single(vm.StagedAttachments);
+ Assert.Equal("image/png", item.ContentType);
+ Assert.Equal(Convert.ToBase64String(bytes), item.Base64Data);
+ Assert.Equal(bytes, item.RawBytes);
+ }
+
+ [Fact]
+ public void ClearStagedAttachments_RemovesAllAttachmentsAndUpdatesHasStagedAttachments()
+ {
+ var vm = new AiAssistantViewModel();
+ vm.AddStagedAttachment(new AiChatMessageAttachment { FileName = "a.png", Base64Data = "123" });
+ vm.AddStagedAttachment(new AiChatMessageAttachment { FileName = "b.png", Base64Data = "456" });
+ Assert.Equal(2, vm.StagedAttachments.Count);
+ Assert.True(vm.HasStagedAttachments);
+
+ vm.ClearStagedAttachments();
+ Assert.Empty(vm.StagedAttachments);
+ Assert.False(vm.HasStagedAttachments);
+ }
+
+ [Fact]
+ public void ChangingSelectedModel_UpdatesSelectedModelCapability_WhenPresent()
+ {
+ var vm = new AiAssistantViewModel();
+ var cap = new AiModelCapabilityInfo("openai/gpt-4o", "GPT-4o", "openai", SupportsVision: true);
+ vm.AvailableModelCapabilities.Add(cap);
+
+ vm.SelectedModel = "openai/gpt-4o";
+ Assert.Same(cap, vm.SelectedModelCapability);
+ }
+
+ [Fact]
+ public void ChangingSelectedModel_ResetsSelectedModelCapabilityToNull_WhenNotFound()
+ {
+ var vm = new AiAssistantViewModel();
+ var cap = new AiModelCapabilityInfo("openai/gpt-4o", "GPT-4o", "openai", SupportsVision: true);
+ vm.AvailableModelCapabilities.Add(cap);
+ vm.SelectedModel = "openai/gpt-4o";
+ Assert.Same(cap, vm.SelectedModelCapability);
+
+ vm.SelectedModel = "unknown-model-xyz";
+ Assert.Null(vm.SelectedModelCapability);
+ Assert.Equal("unknown-model-xyz", vm.SelectedModel);
+ }
+
+ [Fact]
+ public void PasteImageBytes_UsesMillisecondPrecisionTimestamp()
+ {
+ var vm = new AiAssistantViewModel();
+ var bytes = new byte[] { 1, 2, 3 };
+
+ vm.PasteImageBytes(bytes, "image/png");
+
+ Assert.Single(vm.StagedAttachments);
+ var fileName = vm.StagedAttachments[0].FileName;
+ Assert.StartsWith("pasted_image_", fileName);
+ Assert.EndsWith(".png", fileName);
+
+ // pasted_image_yyyyMMdd_HHmmss_fff.png -> 13 + 8 + 1 + 6 + 1 + 3 + 4 = 36 chars
+ Assert.Equal(36, fileName.Length);
+ var parts = fileName.Split('_');
+ Assert.True(parts.Length >= 4);
+ var fffPart = parts[^1].Replace(".png", "");
+ Assert.Equal(3, fffPart.Length);
+ }
+
+ [Fact]
+ public async Task SendMessageAsync_TransfersStagedAttachmentsToUserMessage_AndClearsStaged()
+ {
+ var mockAssistant = new Mock();
+ async IAsyncEnumerable MockStream(AiChatRequest req)
+ {
+ await Task.Yield();
+ yield return new AiChatChunk(DeltaText: "Image received");
+ }
+ mockAssistant.Setup(a => a.StreamChatAsync(It.IsAny(), It.IsAny()))
+ .Returns((AiChatRequest req, CancellationToken ct) => MockStream(req));
+
+ var vm = new AiAssistantViewModel(mockAssistant.Object);
+ var att = new AiChatMessageAttachment
+ {
+ FileName = "photo.jpg",
+ ContentType = "image/jpeg",
+ Base64Data = "dGVzdA=="
+ };
+ vm.AddStagedAttachment(att);
+ vm.InputText = "What is this image?";
+
+ await vm.SendMessageAsync();
+
+ Assert.False(vm.HasStagedAttachments);
+ Assert.Empty(vm.StagedAttachments);
+
+ var userMsg = vm.Messages[1];
+ Assert.True(userMsg.IsUser);
+ Assert.True(userMsg.HasAttachments);
+ Assert.Single(userMsg.Attachments);
+ Assert.Equal("photo.jpg", userMsg.Attachments[0].FileName);
+ }
+
+ [Fact]
+ public async Task LoadAvailableModelsAsync_PopulatesCapabilitiesAndSyncsSelectedModel()
+ {
+ var mockAssistant = new Mock();
+ var caps = new List
+ {
+ new("vertex_ai/gemini-2.5-flash", "Gemini 2.5 Flash", "vertex_ai", SupportsVision: true),
+ new("openai/gpt-4o", "GPT-4o", "openai", SupportsVision: true)
+ };
+ mockAssistant.Setup(a => a.GetModelCapabilitiesAsync(It.IsAny(), It.IsAny(), false, It.IsAny()))
+ .ReturnsAsync(caps);
+
+ var vm = new AiAssistantViewModel(mockAssistant.Object);
+ vm.SelectedModel = "openai/gpt-4o";
+
+ await vm.LoadAvailableModelsAsync(refresh: true);
+
+ Assert.Equal(2, vm.AvailableModelCapabilities.Count);
+ Assert.Equal(2, vm.AvailableModels.Count);
+ Assert.NotNull(vm.SelectedModelCapability);
+ Assert.Equal("openai/gpt-4o", vm.SelectedModelCapability!.Id);
+ }
+
+ [Fact]
+ public async Task TestConnectionAsync_WhenSuccessful_LoadsCapabilities()
+ {
+ var mockAssistant = new Mock();
+ mockAssistant.Setup(a => a.ValidateConnectionAsync(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny()))
+ .ReturnsAsync(new AiValidationResult(
+ Success: true,
+ Message: "Connected successfully",
+ AvailableModels: new List { "model-1", "model-2" }));
+
+ var caps = new List
+ {
+ new("model-1", "Model 1", "test", SupportsVision: false),
+ new("model-2", "Model 2", "test", SupportsVision: true)
+ };
+ mockAssistant.Setup(a => a.GetModelCapabilitiesAsync(It.IsAny(), It.IsAny(), false, It.IsAny()))
+ .ReturnsAsync(caps);
+
+ var vm = new AiAssistantViewModel(mockAssistant.Object);
+ await vm.TestConnectionAsync();
+
+ Assert.True(vm.IsConnectionSuccess);
+ Assert.Equal(2, vm.AvailableModelCapabilities.Count);
+ Assert.Equal("model-1", vm.AvailableModelCapabilities[0].Id);
+ }
+}
diff --git a/LocalLLMServerManager.Tests/AppSettingsTests.cs b/LocalLLMServerManager.Tests/AppSettingsTests.cs
index 4e9cdbb..fe67f94 100644
--- a/LocalLLMServerManager.Tests/AppSettingsTests.cs
+++ b/LocalLLMServerManager.Tests/AppSettingsTests.cs
@@ -32,6 +32,12 @@ public void AppSettings_DefaultValues_HaveEmptyDynamicPathsAndSensibleDefaults()
Assert.Equal("af_heart", settings.PreferredAudioVoice);
Assert.Equal("", settings.VideoModelsPath);
Assert.Equal("", settings.VideoOutputPath);
+ Assert.False(settings.AiAssistantEnabled);
+ Assert.Equal("http://127.0.0.1:4000/v1", settings.AiAssistantEndpoint);
+ Assert.Equal("", settings.AiAssistantApiKey);
+ Assert.Equal("google/gemini-2.5-flash", settings.AiAssistantModel);
+ Assert.Equal("", settings.AiAssistantCustomSystemPrompt);
+ Assert.Equal("", settings.AiAssistantPromptsDirectory);
}
[Fact]
diff --git a/LocalLLMServerManager.Tests/AvaloniaHeadlessInteractionTests.cs b/LocalLLMServerManager.Tests/AvaloniaHeadlessInteractionTests.cs
index 5b8adac..939cbfa 100644
--- a/LocalLLMServerManager.Tests/AvaloniaHeadlessInteractionTests.cs
+++ b/LocalLLMServerManager.Tests/AvaloniaHeadlessInteractionTests.cs
@@ -51,7 +51,7 @@ public void MainView_TabNavigation_SwitchesActiveTabsCleanly()
var tabControl = view.GetVisualDescendants().OfType().FirstOrDefault();
Assert.NotNull(tabControl);
- Assert.Equal(5, tabControl.Items.Count);
+ Assert.Equal(6, tabControl.Items.Count);
// Switch to Tab 2 (Workflows)
tabControl.SelectedIndex = 1;
@@ -61,14 +61,18 @@ public void MainView_TabNavigation_SwitchesActiveTabsCleanly()
tabControl.SelectedIndex = 2;
Assert.Equal(2, tabControl.SelectedIndex);
- // Switch to Tab 4 (Documentation)
+ // Switch to Tab 4 (Copilot)
tabControl.SelectedIndex = 3;
Assert.Equal(3, tabControl.SelectedIndex);
- // Switch to Tab 5 (Settings)
+ // Switch to Tab 5 (Documentation)
tabControl.SelectedIndex = 4;
Assert.Equal(4, tabControl.SelectedIndex);
+ // Switch to Tab 6 (Settings)
+ tabControl.SelectedIndex = 5;
+ Assert.Equal(5, tabControl.SelectedIndex);
+
window.Close();
}
@@ -382,7 +386,7 @@ public void SettingsTabControl_ThemeSwitching_DoesNotCrashUI()
// Switch to Settings Tab
var tabControl = view.GetVisualDescendants().OfType().FirstOrDefault();
Assert.NotNull(tabControl);
- tabControl.SelectedIndex = 4;
+ tabControl.SelectedIndex = 5;
Avalonia.Threading.Dispatcher.UIThread.RunJobs();
var settingsControl = view.GetVisualDescendants().OfType().FirstOrDefault();
@@ -433,7 +437,7 @@ public void SettingsTabControl_AllActionsAndPickers_InteractCleanly()
var tabControl = view.GetVisualDescendants().OfType().FirstOrDefault();
Assert.NotNull(tabControl);
- tabControl.SelectedIndex = 4;
+ tabControl.SelectedIndex = 5;
Avalonia.Threading.Dispatcher.UIThread.RunJobs();
var settingsControl = view.GetVisualDescendants().OfType().FirstOrDefault();
@@ -680,5 +684,47 @@ public void CivitaiTabControl_PresetsAndActions_InteractCleanly()
window.Close();
}
+
+ [AvaloniaFact]
+ public void AiAssistantTabControl_VisualTreeRenders_AndBindsViewModel()
+ {
+ var vm = new AiAssistantViewModel();
+ var control = new AiAssistantTabControl { DataContext = vm };
+ var window = new Window { Content = control, Width = 1024, Height = 768 };
+ window.Show();
+ Avalonia.Threading.Dispatcher.UIThread.RunJobs();
+
+ Assert.NotNull(control);
+ Assert.NotNull(control.DataContext);
+
+ // Find header title
+ var textBlocks = control.GetVisualDescendants().OfType().ToList();
+ var titleBlock = textBlocks.FirstOrDefault(t => t.Text != null && t.Text.Contains("AI Assistant & App Copilot"));
+ Assert.NotNull(titleBlock);
+
+ // Find model badge
+ var modelBlock = textBlocks.FirstOrDefault(t => t.Text != null && t.Text == vm.SelectedModel);
+ Assert.NotNull(modelBlock);
+
+ // Find buttons (Setup, Reload, Clear, Send)
+ var buttons = control.GetVisualDescendants().OfType