diff --git a/PolyPilot.Tests/PolyPilot.Tests.csproj b/PolyPilot.Tests/PolyPilot.Tests.csproj index d94efb19..d385dc27 100644 --- a/PolyPilot.Tests/PolyPilot.Tests.csproj +++ b/PolyPilot.Tests/PolyPilot.Tests.csproj @@ -13,6 +13,8 @@ + + @@ -76,6 +78,8 @@ + + diff --git a/PolyPilot/Components/AppChatPopover.razor b/PolyPilot/Components/AppChatPopover.razor new file mode 100644 index 00000000..9753fafb --- /dev/null +++ b/PolyPilot/Components/AppChatPopover.razor @@ -0,0 +1,164 @@ +@using PolyPilot.Models +@using PolyPilot.Services +@using PolyPilot.Services.AI +@inject DirectLocalChatService LocalChat +@inject IJSRuntime JS + +@if (IsVisible) +{ +
+
+ ✨ App Chat +
+ @if (messages.Count > 0) + { + + } +
+
+ +
+ +
+ +
+ + +
+
+} + +@code { + [Parameter] public bool IsVisible { get; set; } + + private List messages = new(); + private List toolActivities = new(); + private string streamingContent = ""; + private string currentToolName = ""; + private bool isProcessing; + private string sessionName = "appchat-" + Guid.NewGuid().ToString("N")[..8]; + private CancellationTokenSource? _cts; + + private void ClearChat() + { + _cts?.Cancel(); + messages.Clear(); + toolActivities.Clear(); + streamingContent = ""; + currentToolName = ""; + isProcessing = false; + LocalChat.RemoveSession(sessionName); + sessionName = "appchat-" + Guid.NewGuid().ToString("N")[..8]; + } + + private async Task HandleKeyDown(KeyboardEventArgs e) + { + if (e.Key == "Enter" && !isProcessing) + await SendMessage(); + } + + private async Task SendMessage() + { + var prompt = await JS.InvokeAsync("eval", "document.getElementById('appchat-input')?.value || ''"); + if (string.IsNullOrWhiteSpace(prompt)) return; + + await JS.InvokeVoidAsync("eval", "document.getElementById('appchat-input').value = ''"); + + messages.Add(ChatMessage.UserMessage(prompt)); + isProcessing = true; + streamingContent = ""; + StateHasChanged(); + + _cts?.Cancel(); + _cts = new CancellationTokenSource(); + + await LocalChat.SendPromptStreamingAsync( + sessionName, + prompt, + onDelta: delta => + { + // Clear tool state when text starts streaming + if (currentToolName != "") + { + currentToolName = ""; + } + streamingContent += delta; + InvokeAsync(StateHasChanged); + }, + onComplete: full => + { + messages.Add(ChatMessage.AssistantMessage(full)); + streamingContent = ""; + currentToolName = ""; + toolActivities.Clear(); + isProcessing = false; + InvokeAsync(StateHasChanged); + }, + onError: error => + { + messages.Add(ChatMessage.ErrorMessage(error)); + streamingContent = ""; + currentToolName = ""; + toolActivities.Clear(); + isProcessing = false; + InvokeAsync(StateHasChanged); + }, + onToolStart: (name, callId, argsJson) => + { + currentToolName = name; + messages.Add(ChatMessage.ToolCallMessage(name, callId, argsJson)); + toolActivities.Add(new ToolActivity + { + Name = name, + CallId = callId, + Input = argsJson, + StartedAt = DateTime.Now + }); + InvokeAsync(StateHasChanged); + }, + onToolEnd: (callId, result) => + { + var activity = toolActivities.FirstOrDefault(a => a.CallId == callId); + if (activity != null) + { + activity.IsComplete = true; + activity.IsSuccess = true; + activity.CompletedAt = DateTime.Now; + activity.Result = result; + } + var toolMsg = messages.LastOrDefault(m => m.ToolCallId == callId); + if (toolMsg != null) + { + toolMsg.IsComplete = true; + toolMsg.IsSuccess = true; + toolMsg.Content = result ?? ""; + toolMsg.IsCollapsed = true; + } + currentToolName = ""; + InvokeAsync(StateHasChanged); + }, + cancellationToken: _cts.Token); + } + + public void Dispose() + { + _cts?.Cancel(); + _cts?.Dispose(); + LocalChat.RemoveSession(sessionName); + } +} diff --git a/PolyPilot/Components/AppChatPopover.razor.css b/PolyPilot/Components/AppChatPopover.razor.css new file mode 100644 index 00000000..c65eec7b --- /dev/null +++ b/PolyPilot/Components/AppChatPopover.razor.css @@ -0,0 +1,137 @@ +.appchat-popover { + position: fixed; + top: 50px; + left: 16px; + width: 520px; + max-height: 72vh; + background: var(--bg-primary, #1e1e2e); + border: 1px solid var(--border-color, #444); + border-radius: 12px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); + z-index: 200; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.appchat-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 14px; + background: var(--bg-secondary, #2a2a3e); + border-bottom: 1px solid var(--border-color, #444); + flex-shrink: 0; +} + +.appchat-title { + font-size: 13px; + font-weight: 600; + color: var(--text-primary, #e0e0e0); +} + +.appchat-header-actions { + display: flex; + gap: 4px; +} + +.appchat-clear-btn { + background: none; + border: none; + color: var(--text-secondary, #999); + cursor: pointer; + font-size: 13px; + padding: 2px 6px; + border-radius: 4px; + line-height: 1; +} + +.appchat-clear-btn:hover { + background: var(--bg-hover, rgba(255, 255, 255, 0.1)); + color: var(--text-primary, #e0e0e0); +} + +.appchat-body { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + min-height: 200px; + max-height: calc(72vh - 100px); + padding: 8px; +} + +/* Force text and code blocks to wrap within the popover */ +.appchat-body pre, +.appchat-body code { + white-space: pre-wrap; + word-break: break-word; + overflow-x: hidden; +} + +.appchat-body .chat-msg-text { + overflow-x: hidden; + word-break: break-word; +} + +.appchat-input-area { + display: flex; + gap: 6px; + padding: 8px 10px; + border-top: 1px solid var(--border-color, #444); + background: var(--bg-secondary, #2a2a3e); + flex-shrink: 0; +} + +.appchat-input-area input { + flex: 1; + background: var(--bg-primary, #1e1e2e); + border: 1px solid var(--border-color, #555); + border-radius: 6px; + padding: 6px 10px; + color: var(--text-primary, #e0e0e0); + font-size: 13px; + outline: none; +} + +.appchat-input-area input:focus { + border-color: var(--accent-color, #7c3aed); +} + +.appchat-input-area input:disabled { + opacity: 0.5; +} + +.appchat-send-btn { + background: var(--accent-color, #7c3aed); + border: none; + border-radius: 6px; + color: white; + padding: 6px 12px; + cursor: pointer; + font-size: 14px; + line-height: 1; +} + +.appchat-send-btn:hover:not(:disabled) { + opacity: 0.85; +} + +.appchat-send-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +/* Mobile adjustments */ +@media (max-width: 768px) { + .appchat-popover { + top: 50px; + left: 8px; + right: 8px; + width: auto; + max-height: 50vh; + } + + .appchat-body { + max-height: 35vh; + } +} diff --git a/PolyPilot/Components/Layout/MainLayout.razor b/PolyPilot/Components/Layout/MainLayout.razor index 347be655..94726c68 100644 --- a/PolyPilot/Components/Layout/MainLayout.razor +++ b/PolyPilot/Components/Layout/MainLayout.razor @@ -5,12 +5,12 @@
- +
@@ -21,15 +21,18 @@
- +
+ +
@code { private bool flyoutOpen; + private bool appChatOpen; private bool showStatistics; private int fontSize = 20; private Timer? _holidayTimer; @@ -201,6 +204,11 @@ flyoutOpen = false; } + private void ToggleAppChat() + { + appChatOpen = !appChatOpen; + } + private void ToggleStatistics() { showStatistics = !showStatistics; diff --git a/PolyPilot/Components/Layout/SessionSidebar.razor b/PolyPilot/Components/Layout/SessionSidebar.razor index 08565e6e..5d2e716d 100644 --- a/PolyPilot/Components/Layout/SessionSidebar.razor +++ b/PolyPilot/Components/Layout/SessionSidebar.razor @@ -56,6 +56,7 @@ else +
@@ -193,6 +194,7 @@ else [Parameter] public bool IsFlyoutPanel { get; set; } [Parameter] public EventCallback OnToggleFlyout { get; set; } [Parameter] public EventCallback OnSessionSelected { get; set; } + [Parameter] public EventCallback OnToggleAppChat { get; set; } [Parameter] public int FontSize { get; set; } = 20; [Parameter] public EventCallback OnFontSizeChange { get; set; } [Parameter] public EventCallback OnToggleStatistics { get; set; } diff --git a/PolyPilot/Components/Layout/SessionSidebar.razor.css b/PolyPilot/Components/Layout/SessionSidebar.razor.css index 4aa6a183..9af1fe7f 100644 --- a/PolyPilot/Components/Layout/SessionSidebar.razor.css +++ b/PolyPilot/Components/Layout/SessionSidebar.razor.css @@ -1104,6 +1104,13 @@ color: var(--accent-primary); } +.appchat-toggle { + font-size: 12px; + border: none; + background: none; + cursor: pointer; +} + .info-popover { position: relative; display: flex; align-items: center; } .info-trigger { cursor: pointer; diff --git a/PolyPilot/Components/Pages/Dashboard.razor b/PolyPilot/Components/Pages/Dashboard.razor index 58a8e283..1298ff27 100644 --- a/PolyPilot/Components/Pages/Dashboard.razor +++ b/PolyPilot/Components/Pages/Dashboard.razor @@ -953,13 +953,15 @@ _focusedInputId = $"input-{active!.Replace(" ", "-")}"; _needsScrollToBottom = true; CopilotService.SaveUiState("/dashboard", activeSession: active, expandedSession: active, expandedGrid: !isCompactGrid); + _ = InvokeAsync(() => JS.InvokeVoidAsync("switchKeepAliveSlot", $"slot-{active.Replace(" ", "-")}", active)); } - // Ensure expandedSession stays in sync with active session (e.g., sidebar flyout selection on mobile) - else if (active != null && expandedSession != null && expandedSession != active && sessions.Any(s => s.Name == active)) + // Ensure expandedSession stays in sync with active session (e.g., sidebar flyout selection on mobile, tool switch) + else if (active != null && expandedSession != active && sessions.Any(s => s.Name == active)) { _lastActiveSession = active; expandedSession = active; _needsScrollToBottom = true; + _ = InvokeAsync(() => JS.InvokeVoidAsync("switchKeepAliveSlot", $"slot-{active.Replace(" ", "-")}", active)); } else if (sessionSwitched && active == null) { diff --git a/PolyPilot/MauiProgram.cs b/PolyPilot/MauiProgram.cs index 10851132..32c655d4 100644 --- a/PolyPilot/MauiProgram.cs +++ b/PolyPilot/MauiProgram.cs @@ -1,8 +1,13 @@ using PolyPilot.Services; +using PolyPilot.Services.AI; +using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; using ZXing.Net.Maui.Controls; using MauiDevFlow.Agent; using MauiDevFlow.Blazor; +#if MACCATALYST || IOS +using Microsoft.Maui.Essentials.AI; +#endif using CommunityToolkit.Maui; using CommunityToolkit.Maui.Media; #if MACCATALYST @@ -111,6 +116,9 @@ public static MauiApp CreateMauiApp() builder.Services.AddSingleton(SpeechToText.Default); builder.Services.AddSingleton(); + // Register local AI services (AppChat powered by on-device SLM) + RegisterLocalAI(builder); + #if DEBUG builder.Services.AddBlazorWebViewDeveloperTools(); builder.Logging.AddDebug(); @@ -133,4 +141,39 @@ private static void LogException(string source, Exception? ex) } catch { /* Don't throw in exception handler */ } } + + /// + /// Register the local AI IChatClient pipeline and DirectLocalChatService. + /// On Apple platforms, uses AppleIntelligenceChatClient + NLEmbeddingGenerator. + /// On other platforms, AppChat is unavailable (no SLM provider yet). + /// + private static void RegisterLocalAI(MauiAppBuilder builder) + { +#if MACCATALYST || IOS + // Apple Intelligence provides on-device IChatClient + IEmbeddingGenerator + #pragma warning disable MAUIAI0001 // Apple Intelligence is experimental + builder.Services.AddSingleton(); + + #pragma warning disable MEAI001 // EmbeddingToolReductionStrategy is experimental + builder.Services.AddKeyedSingleton("local", (sp, _) => + { + var appleClient = sp.GetRequiredService(); + #pragma warning restore MAUIAI0001 + var loggerFactory = sp.GetRequiredService(); + + return appleClient + .AsBuilder() + .UseLogging(loggerFactory) + .ConfigureOptions(o => + { + o.MaxOutputTokens = 350; + o.AllowMultipleToolCalls = false; + }) + .Build(); + }); + #pragma warning restore MEAI001 + + builder.Services.AddSingleton(); +#endif + } } diff --git a/PolyPilot/PolyPilot.csproj b/PolyPilot/PolyPilot.csproj index 0f69a45f..ae4f3861 100644 --- a/PolyPilot/PolyPilot.csproj +++ b/PolyPilot/PolyPilot.csproj @@ -74,7 +74,9 @@ - + + + diff --git a/PolyPilot/Services/AI/AppChatTools.cs b/PolyPilot/Services/AI/AppChatTools.cs new file mode 100644 index 00000000..806271e8 --- /dev/null +++ b/PolyPilot/Services/AI/AppChatTools.cs @@ -0,0 +1,105 @@ +using System.Text.Json; +using Microsoft.Extensions.AI; + +namespace PolyPilot.Services.AI; + +/// +/// Tools that the local AppChat agent can invoke to query and navigate app state. +/// Each method returns a JSON string summary for the SLM. +/// +public static class AppChatTools +{ + /// + /// Creates the set of AIFunction tools for AppChat sessions. + /// The CopilotService instance is captured by closure. + /// + public static IList Create(CopilotService service) + { + return + [ + AIFunctionFactory.Create( + () => GetSessionList(service), + nameof(GetSessionList), + "Lists all active Copilot sessions with their name, model, message count, processing status, working directory, and git branch."), + + AIFunctionFactory.Create( + () => GetRecentErrors(service), + nameof(GetRecentErrors), + "Returns the most recent error messages from all sessions, if any."), + + AIFunctionFactory.Create( + (string sessionName) => SwitchToSession(service, sessionName), + nameof(SwitchToSession), + "Switches the UI to show the specified session. Use the exact session name from GetSessionList."), + + AIFunctionFactory.Create( + (string query) => SearchSessionHistory(service, query), + nameof(SearchSessionHistory), + "Searches all session chat histories for messages containing the query text. Returns matching messages with session name, role, content snippet, and timestamp."), + ]; + } + + private static string GetSessionList(CopilotService service) + { + var sessions = service.GetAllSessions().Select(s => new + { + s.Name, + s.Model, + s.MessageCount, + s.IsProcessing, + HasQueue = s.MessageQueue.Count > 0, + s.WorkingDirectory, + s.GitBranch + }); + return JsonSerializer.Serialize(sessions); + } + + private static string GetRecentErrors(CopilotService service) + { + var errors = service.GetAllSessions() + .SelectMany(s => s.History + .Where(m => m?.MessageType == Models.ChatMessageType.Error) + .TakeLast(3) + .Select(m => new { Session = s.Name, m.Content, m.Timestamp })) + .OrderByDescending(e => e.Timestamp) + .Take(10); + return JsonSerializer.Serialize(errors); + } + + private static string SwitchToSession(CopilotService service, string sessionName) + { + var session = service.GetAllSessions().FirstOrDefault(s => + s.Name.Equals(sessionName, StringComparison.OrdinalIgnoreCase)); + if (session == null) + return JsonSerializer.Serialize(new { Error = $"Session '{sessionName}' not found." }); + + service.SwitchSession(session.Name); + session.LastReadMessageCount = session.History.Count; + service.SaveUiState("/", session.Name, expandedSession: session.Name); + return JsonSerializer.Serialize(new { Success = true, SwitchedTo = session.Name }); + } + + private static string SearchSessionHistory(CopilotService service, string query) + { + if (string.IsNullOrWhiteSpace(query)) + return JsonSerializer.Serialize(new { Error = "Query cannot be empty." }); + + var results = service.GetAllSessions() + .SelectMany(s => s.History + .Where(m => m?.Content != null && + m.Content.Contains(query, StringComparison.OrdinalIgnoreCase)) + .TakeLast(5) + .Select(m => new + { + Session = s.Name, + m.Role, + Content = m.Content.Length > 200 + ? m.Content[..200] + "..." + : m.Content, + m.Timestamp + })) + .OrderByDescending(r => r.Timestamp) + .Take(15); + return JsonSerializer.Serialize(results); + } +} diff --git a/PolyPilot/Services/AI/DirectLocalChatService.cs b/PolyPilot/Services/AI/DirectLocalChatService.cs new file mode 100644 index 00000000..d7699f07 --- /dev/null +++ b/PolyPilot/Services/AI/DirectLocalChatService.cs @@ -0,0 +1,131 @@ +using System.Collections.Concurrent; +using System.Text; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace PolyPilot.Services.AI; + +/// +/// Thin adapter over ChatClientAgent for local AppChat sessions. +/// Each session gets its own AgentSession for conversation history. +/// +public class DirectLocalChatService +{ + private readonly IChatClient _chatClient; + private readonly CopilotService _copilotService; + private readonly ILogger _logger; + private readonly ConcurrentDictionary _sessions = new(); + private ChatClientAgent? _agent; + + private const string SystemPrompt = """ + You are PolyPilot Assistant, a helpful on-device AI that answers questions about the user's + active Copilot sessions, queued messages, and session organization. You have access to tools + that let you read app state. Keep responses concise — you are running on a local model with + limited context. If you don't know something or a tool doesn't return useful data, say so + briefly rather than guessing. + """; + + public DirectLocalChatService( + [FromKeyedServices("local")] IChatClient chatClient, + CopilotService copilotService, + ILogger logger) + { + _chatClient = chatClient; + _copilotService = copilotService; + _logger = logger; + } + + private ChatClientAgent GetOrCreateAgent() + { + if (_agent is not null) return _agent; + + var tools = AppChatTools.Create(_copilotService); + _agent = new ChatClientAgent( + _chatClient, + instructions: SystemPrompt, + name: "PolyPilotAssistant", + tools: tools.Cast().ToList()); + return _agent; + } + + private async Task GetOrCreateSessionAsync(string sessionName) + { + if (_sessions.TryGetValue(sessionName, out var existing)) + return existing; + + var agent = GetOrCreateAgent(); + var session = await agent.CreateSessionAsync(); + _sessions[sessionName] = session; + return session; + } + + /// + /// Send a prompt to the local model and stream the response back via callbacks. + /// + public async Task SendPromptStreamingAsync( + string sessionName, + string prompt, + Action onDelta, + Action onComplete, + Action? onError = null, + Action? onToolStart = null, + Action? onToolEnd = null, + CancellationToken cancellationToken = default) + { + var agent = GetOrCreateAgent(); + var session = await GetOrCreateSessionAsync(sessionName); + + var fullResponse = new StringBuilder(); + + try + { + await foreach (var update in agent.RunStreamingAsync( + prompt, + session, + cancellationToken: cancellationToken)) + { + // Surface tool calls to the UI + foreach (var content in update.Contents) + { + if (content is FunctionCallContent fcc) + { + var argsJson = fcc.Arguments is { Count: > 0 } + ? System.Text.Json.JsonSerializer.Serialize(fcc.Arguments) + : null; + onToolStart?.Invoke(fcc.Name, fcc.CallId, argsJson); + } + else if (content is FunctionResultContent frc) + onToolEnd?.Invoke(frc.CallId, frc.Result?.ToString()); + } + + var text = update.Text; + if (!string.IsNullOrEmpty(text)) + { + fullResponse.Append(text); + onDelta(text); + } + } + + onComplete(fullResponse.ToString()); + } + catch (OperationCanceledException) + { + onComplete(fullResponse.ToString()); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error in AppChat session '{SessionName}'", sessionName); + onError?.Invoke(ex.Message); + } + } + + /// + /// Remove the session for a closed AppChat. + /// + public void RemoveSession(string sessionName) + { + _sessions.TryRemove(sessionName, out _); + } +}