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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +