diff --git a/PolyPilot.Tests/TestStubs.cs b/PolyPilot.Tests/TestStubs.cs index 6fa14ce63..3122d75f1 100644 --- a/PolyPilot.Tests/TestStubs.cs +++ b/PolyPilot.Tests/TestStubs.cs @@ -108,7 +108,7 @@ public Task RequestHistoryAsync(string sessionName, int? limit = null, Cancellat SessionHistories[sessionName] = new List(existing); return Task.CompletedTask; } - public Task SendMessageAsync(string sessionName, string message, string? agentMode = null, CancellationToken ct = default) + public Task SendMessageAsync(string sessionName, string message, string? agentMode = null, List? imageAttachments = null, CancellationToken ct = default) { if (ThrowOnSend) throw new InvalidOperationException("Not connected to server"); diff --git a/PolyPilot/Components/Pages/Dashboard.razor b/PolyPilot/Components/Pages/Dashboard.razor index cbc50e151..45eb9a3e2 100644 --- a/PolyPilot/Components/Pages/Dashboard.razor +++ b/PolyPilot/Components/Pages/Dashboard.razor @@ -3058,8 +3058,36 @@ private async Task TriggerAttach(string sessionName) { - var fileId = $"file-{sessionName.Replace(" ", "-")}"; - await JS.InvokeVoidAsync("clickElement", fileId); + if (PlatformHelper.IsMobile) + { + // On mobile, use MAUI MediaPicker for the native photo picker experience. + // Falls back to FilePicker if MediaPicker is unavailable. + try + { + var photo = await MediaPicker.PickPhotoAsync(new MediaPickerOptions + { + Title = "Select an image" + }); + if (photo == null) return; + + using var stream = await photo.OpenReadAsync(); + using var ms = new MemoryStream(); + await stream.CopyToAsync(ms); + var base64 = Convert.ToBase64String(ms.ToArray()); + var ext = Path.GetExtension(photo.FileName)?.TrimStart('.') ?? "png"; + var inputId = $"input-{sessionName.Replace(" ", "-")}"; + await JsImagePasted(base64, photo.FileName, ext, inputId); + } + catch (Exception ex) + { + Console.WriteLine($"MediaPicker error: {ex.Message}"); + } + } + else + { + var fileId = $"file-{sessionName.Replace(" ", "-")}"; + await JS.InvokeVoidAsync("clickElement", fileId); + } } private void TouchMru(string sessionName) diff --git a/PolyPilot/Models/BridgeMessages.cs b/PolyPilot/Models/BridgeMessages.cs index e13f10b4b..617cb9f9c 100644 --- a/PolyPilot/Models/BridgeMessages.cs +++ b/PolyPilot/Models/BridgeMessages.cs @@ -259,6 +259,15 @@ public class SendMessagePayload public string Message { get; set; } = ""; /// SDK agent mode: "interactive", "plan", "autopilot", "shell". Null = default (interactive). public string? AgentMode { get; set; } + /// Image attachments encoded as base64 for transmission over the bridge. + public List? ImageAttachments { get; set; } +} + +/// Base64-encoded image for bridge transmission. +public class ImageAttachment +{ + public string Base64Data { get; set; } = ""; + public string FileName { get; set; } = "image.png"; } public class CreateSessionPayload diff --git a/PolyPilot/Services/CopilotService.cs b/PolyPilot/Services/CopilotService.cs index 32a17ed26..fbbeade9a 100644 --- a/PolyPilot/Services/CopilotService.cs +++ b/PolyPilot/Services/CopilotService.cs @@ -3348,7 +3348,28 @@ public async Task SendPromptAsync(string sessionName, string prompt, Lis OnStateChanged?.Invoke(); try { - await _bridgeClient.SendMessageAsync(sessionName, prompt, agentMode, cancellationToken); + // Encode images as base64 for bridge transmission + List? imageAttachments = null; + if (imagePaths != null && imagePaths.Count > 0) + { + imageAttachments = new(); + foreach (var path in imagePaths) + { + if (!File.Exists(path)) continue; + try + { + var bytes = await File.ReadAllBytesAsync(path, cancellationToken); + imageAttachments.Add(new ImageAttachment + { + Base64Data = Convert.ToBase64String(bytes), + FileName = Path.GetFileName(path) + }); + } + catch (Exception ex) { Debug($"Failed to encode image '{path}': {ex.Message}"); } + } + if (imageAttachments.Count == 0) imageAttachments = null; + } + await _bridgeClient.SendMessageAsync(sessionName, prompt, agentMode, imageAttachments, cancellationToken); } catch { diff --git a/PolyPilot/Services/IWsBridgeClient.cs b/PolyPilot/Services/IWsBridgeClient.cs index 0e8013f49..7affab398 100644 --- a/PolyPilot/Services/IWsBridgeClient.cs +++ b/PolyPilot/Services/IWsBridgeClient.cs @@ -44,7 +44,7 @@ public interface IWsBridgeClient void AbortForReconnect(); Task RequestSessionsAsync(CancellationToken ct = default); Task RequestHistoryAsync(string sessionName, int? limit = null, CancellationToken ct = default); - Task SendMessageAsync(string sessionName, string message, string? agentMode = null, CancellationToken ct = default); + Task SendMessageAsync(string sessionName, string message, string? agentMode = null, List? imageAttachments = null, CancellationToken ct = default); Task CreateSessionAsync(string name, string? model = null, string? workingDirectory = null, CancellationToken ct = default); Task SwitchSessionAsync(string name, CancellationToken ct = default); Task QueueMessageAsync(string sessionName, string message, string? agentMode = null, CancellationToken ct = default); diff --git a/PolyPilot/Services/WsBridgeClient.cs b/PolyPilot/Services/WsBridgeClient.cs index 8042a6af0..80f08f3f6 100644 --- a/PolyPilot/Services/WsBridgeClient.cs +++ b/PolyPilot/Services/WsBridgeClient.cs @@ -350,9 +350,9 @@ public async Task RequestHistoryAsync(string sessionName, int? limit = null, Can await SendAsync(BridgeMessage.Create(BridgeMessageTypes.GetHistory, new GetHistoryPayload { SessionName = sessionName, Limit = limit }), ct); - public async Task SendMessageAsync(string sessionName, string message, string? agentMode = null, CancellationToken ct = default) => + public async Task SendMessageAsync(string sessionName, string message, string? agentMode = null, List? imageAttachments = null, CancellationToken ct = default) => await SendAsync(BridgeMessage.Create(BridgeMessageTypes.SendMessage, - new SendMessagePayload { SessionName = sessionName, Message = message, AgentMode = agentMode }), ct); + new SendMessagePayload { SessionName = sessionName, Message = message, AgentMode = agentMode, ImageAttachments = imageAttachments }), ct); public async Task CreateSessionAsync(string name, string? model = null, string? workingDirectory = null, CancellationToken ct = default) => await SendAsync(BridgeMessage.Create(BridgeMessageTypes.CreateSession, diff --git a/PolyPilot/Services/WsBridgeServer.cs b/PolyPilot/Services/WsBridgeServer.cs index 01abbf302..09dd92946 100644 --- a/PolyPilot/Services/WsBridgeServer.cs +++ b/PolyPilot/Services/WsBridgeServer.cs @@ -337,7 +337,7 @@ public async Task DrainPendingPromptsAsync() /// Dispatch a bridge prompt with orchestrator routing on the UI thread. /// Shared by both the live send_message handler and the drain replay loop. /// - private async Task DispatchBridgePromptAsync(string sessionName, string message, string? agentMode, CancellationToken ct = default) + private async Task DispatchBridgePromptAsync(string sessionName, string message, string? agentMode, List? imagePaths = null, CancellationToken ct = default) { try { @@ -354,7 +354,7 @@ private async Task DispatchBridgePromptAsync(string sessionName, string message, } else { - await _copilot.SendPromptAsync(sessionName, message, cancellationToken: ct, agentMode: agentMode); + await _copilot.SendPromptAsync(sessionName, message, imagePaths, cancellationToken: ct, agentMode: agentMode); } }); } @@ -871,9 +871,9 @@ await SendToClientAsync(clientId, ws, messageBuffer.Append(Encoding.UTF8.GetString(buffer, 0, result.Count)); - if (messageBuffer.Length > 256 * 1024) + if (messageBuffer.Length > 16 * 1024 * 1024) { - try { await ws.CloseAsync(WebSocketCloseStatus.MessageTooBig, "Message exceeds 256KB limit", CancellationToken.None); } catch { } + try { await ws.CloseAsync(WebSocketCloseStatus.MessageTooBig, "Message exceeds 16MB limit", CancellationToken.None); } catch { } break; // guard against unbounded frames } @@ -927,12 +927,33 @@ await SendToClientAsync(clientId, ws, case BridgeMessageTypes.SendMessage: var sendReq = msg.GetPayload(); - if (sendReq != null && !string.IsNullOrWhiteSpace(sendReq.SessionName) && !string.IsNullOrWhiteSpace(sendReq.Message)) + if (sendReq != null && !string.IsNullOrWhiteSpace(sendReq.SessionName) && (!string.IsNullOrWhiteSpace(sendReq.Message) || sendReq.ImageAttachments is { Count: > 0 })) { BridgeLog($"[BRIDGE] Client sending message to '{sendReq.SessionName}'"); - // Fire-and-forget: don't block the client message loop waiting for the full response. - // SendPromptAsync awaits ResponseCompletion (minutes). Responses stream back via events. - // Blocking here prevents the client from sending abort, switch, or other commands. + + // Decode any image attachments from base64 to temp files + List? sendImagePaths = null; + if (sendReq.ImageAttachments is { Count: > 0 }) + { + var tempDir = Path.Combine(Path.GetTempPath(), "PolyPilot-images"); + Directory.CreateDirectory(tempDir); + sendImagePaths = new(); + foreach (var att in sendReq.ImageAttachments) + { + try + { + var ext = Path.GetExtension(att.FileName); + if (string.IsNullOrEmpty(ext)) ext = ".png"; + var tempPath = Path.Combine(tempDir, $"{Guid.NewGuid()}{ext}"); + await File.WriteAllBytesAsync(tempPath, Convert.FromBase64String(att.Base64Data), ct); + sendImagePaths.Add(tempPath); + } + catch (Exception ex) { BridgeLog($"[BRIDGE] Failed to decode image '{att.FileName}': {ex.Message}"); } + } + if (sendImagePaths.Count == 0) sendImagePaths = null; + else BridgeLog($"[BRIDGE] Decoded {sendImagePaths.Count} image attachment(s) for '{sendReq.SessionName}'"); + } + var sendSession = sendReq.SessionName; var sendMessage = sendReq.Message; var sendAgentMode = sendReq.AgentMode; @@ -948,8 +969,15 @@ await SendToClientAsync(clientId, ws, // Dispatch with orchestrator routing on the UI thread (fire-and-forget). _ = Task.Run(async () => { - try { await DispatchBridgePromptAsync(sendSession, sendMessage, sendAgentMode, ct); } + try { await DispatchBridgePromptAsync(sendSession, sendMessage, sendAgentMode, sendImagePaths, ct); } catch (Exception ex) { BridgeLog($"[BRIDGE] SendPromptAsync error for '{sendSession}': {ex.Message}"); } + finally + { + // Clean up temp image files after send completes + if (sendImagePaths != null) + foreach (var p in sendImagePaths) + try { File.Delete(p); } catch { } + } }); } break; diff --git a/PolyPilot/wwwroot/index.html b/PolyPilot/wwwroot/index.html index 9d1d2a86e..6f5b6be7c 100644 --- a/PolyPilot/wwwroot/index.html +++ b/PolyPilot/wwwroot/index.html @@ -120,6 +120,33 @@ } }, true); + // File picker change handler — reads selected images and sends to Blazor. + // Covers the attach button on all platforms (especially mobile where + // paste/drag-drop aren't available). + document.addEventListener('change', function(e) { + if (e.target.type !== 'file' || !e.target.id.startsWith('file-')) return; + if (!window.__dashRef) return; + var files = e.target.files; + if (!files) return; + // Derive the input ID from the file input ID (file-X → input-X) + var inputId = 'input-' + e.target.id.substring(5); + for (var i = 0; i < files.length; i++) { + if (!files[i].type.startsWith('image/')) continue; + (function(f) { + var reader = new FileReader(); + reader.onload = function() { + var base64 = reader.result.split(',')[1]; + var ext = (f.name && f.name.includes('.')) ? f.name.split('.').pop() : 'png'; + var name = f.name || ('attached-image.' + ext); + window.__dashRef.invokeMethodAsync('JsImagePasted', base64, name, ext, inputId); + }; + reader.readAsDataURL(f); + })(files[i]); + } + // Reset so the same file can be re-selected + e.target.value = ''; + }); + // Global image drop handler for chat input areas document.addEventListener('dragover', function(e) { var zone = e.target.closest && (e.target.closest('.input-area') || e.target.closest('.card-input'));