Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion PolyPilot.Tests/TestStubs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ public Task RequestHistoryAsync(string sessionName, int? limit = null, Cancellat
SessionHistories[sessionName] = new List<ChatMessage>(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<ImageAttachment>? imageAttachments = null, CancellationToken ct = default)
{
if (ThrowOnSend)
throw new InvalidOperationException("Not connected to server");
Expand Down
32 changes: 30 additions & 2 deletions PolyPilot/Components/Pages/Dashboard.razor
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
9 changes: 9 additions & 0 deletions PolyPilot/Models/BridgeMessages.cs
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,15 @@ public class SendMessagePayload
public string Message { get; set; } = "";
/// <summary>SDK agent mode: "interactive", "plan", "autopilot", "shell". Null = default (interactive).</summary>
public string? AgentMode { get; set; }
/// <summary>Image attachments encoded as base64 for transmission over the bridge.</summary>
public List<ImageAttachment>? ImageAttachments { get; set; }
}

/// <summary>Base64-encoded image for bridge transmission.</summary>
public class ImageAttachment
{
public string Base64Data { get; set; } = "";
public string FileName { get; set; } = "image.png";
}

public class CreateSessionPayload
Expand Down
23 changes: 22 additions & 1 deletion PolyPilot/Services/CopilotService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3348,7 +3348,28 @@ public async Task<string> SendPromptAsync(string sessionName, string prompt, Lis
OnStateChanged?.Invoke();
try
{
await _bridgeClient.SendMessageAsync(sessionName, prompt, agentMode, cancellationToken);
// Encode images as base64 for bridge transmission
List<ImageAttachment>? 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
{
Expand Down
2 changes: 1 addition & 1 deletion PolyPilot/Services/IWsBridgeClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ImageAttachment>? 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);
Expand Down
4 changes: 2 additions & 2 deletions PolyPilot/Services/WsBridgeClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ImageAttachment>? 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,
Expand Down
46 changes: 37 additions & 9 deletions PolyPilot/Services/WsBridgeServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
/// </summary>
private async Task DispatchBridgePromptAsync(string sessionName, string message, string? agentMode, CancellationToken ct = default)
private async Task DispatchBridgePromptAsync(string sessionName, string message, string? agentMode, List<string>? imagePaths = null, CancellationToken ct = default)
{
try
{
Expand All @@ -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);
}
});
}
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -927,12 +927,33 @@ await SendToClientAsync(clientId, ws,

case BridgeMessageTypes.SendMessage:
var sendReq = msg.GetPayload<SendMessagePayload>();
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<string>? 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;
Expand All @@ -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;
Expand Down
27 changes: 27 additions & 0 deletions PolyPilot/wwwroot/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
Expand Down