forked from Manuel/meeting-assistant
ci: consolidate platform pipelines and test prerequisites on macOS support
This commit is contained in:
1 parent
1b19b08f2e
commit
728e66dd72
76 files changed
+7477
-239
No files matched your search
@@ -0,0 +1,43 @@
|
||||
using MeetingAssistant.Notifications;
|
||||
|
||||
namespace MeetingAssistant.MacOs;
|
||||
|
||||
public static class DesktopCommunicationEndpoints
|
||||
{
|
||||
public static void MapDesktopCommunication(this WebApplication app)
|
||||
{
|
||||
app.MapGet("/desktop/requests", (DesktopUserRequestStore requests) => Results.Ok(requests.List()));
|
||||
app.MapPost("/desktop/requests/{id}/respond", async (
|
||||
string id, DesktopUserRequestResponse response, DesktopUserRequestStore requests) =>
|
||||
{
|
||||
if (string.IsNullOrEmpty(response.Action)) return Results.BadRequest();
|
||||
return await requests.RespondAsync(id, response.Action) switch
|
||||
{
|
||||
DesktopUserRequestRespondResult.Accepted => Results.Ok(new { status = "accepted" }),
|
||||
DesktopUserRequestRespondResult.InvalidAction => Results.BadRequest(new { status = "invalid-action" }),
|
||||
DesktopUserRequestRespondResult.NoLongerPending => Results.StatusCode(StatusCodes.Status410Gone),
|
||||
_ => Results.Problem("The user action could not be completed.")
|
||||
};
|
||||
});
|
||||
app.MapGet("/desktop/agent-windows", (MacOsWorkflowRulesEditorSessions sessions) =>
|
||||
Results.Ok(sessions.GetAgentWindows()));
|
||||
app.MapGet("/diagnostics/settings-and-logs", () =>
|
||||
Results.Content(MacOsWorkflowRulesEditorPage.Html, "text/html"));
|
||||
app.MapGet("/diagnostics/settings-and-logs/chat", IResult (
|
||||
string? session, MacOsWorkflowRulesEditorSessions sessions) =>
|
||||
{
|
||||
return sessions.TryGetSession(session, out var chat)
|
||||
? Results.Ok(chat.GetSnapshot())
|
||||
: Results.NotFound();
|
||||
});
|
||||
app.MapPost("/diagnostics/settings-and-logs/chat", async Task<IResult> (
|
||||
string? session, MacOsWorkflowRulesEditorChatRequest request,
|
||||
MacOsWorkflowRulesEditorSessions sessions, CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!sessions.TryGetSession(session, out var chat)) return Results.NotFound();
|
||||
return Results.Ok(await chat.SendAsync(request.Message, cancellationToken));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record DesktopUserRequestResponse(string? Action);
|
||||
@@ -0,0 +1,23 @@
|
||||
using MeetingAssistant.Notifications;
|
||||
|
||||
namespace MeetingAssistant.MacOs;
|
||||
|
||||
public sealed class MacOsAgentApprovalPromptService(DesktopUserRequestStore requests) : IAgentApprovalPromptService
|
||||
{
|
||||
public async Task<bool> ConfirmAsync(AgentApprovalPrompt prompt, CancellationToken cancellationToken)
|
||||
{
|
||||
var request = requests.Add(new DesktopUserRequestSpec(
|
||||
"agent-approval", prompt.Title, prompt.Message, prompt.Timeout,
|
||||
[new("yes", "Yes"), new("no", "No")]), cancellationToken);
|
||||
try
|
||||
{
|
||||
var response = await request.Decision.WaitAsync(cancellationToken);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return response == "yes";
|
||||
}
|
||||
finally
|
||||
{
|
||||
requests.Dismiss(request.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,13 @@ public sealed class MacOsDesktopControlService : IHostedService, IDisposable
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
var helperPath = Path.Combine(AppContext.BaseDirectory, "Native", "macos-desktop-controls");
|
||||
var helperPath = Path.Combine(
|
||||
AppContext.BaseDirectory,
|
||||
"Native",
|
||||
"MeetingAssistantDesktopControls.app",
|
||||
"Contents",
|
||||
"MacOS",
|
||||
"macos-desktop-controls");
|
||||
if (!File.Exists(helperPath))
|
||||
{
|
||||
logger.LogWarning("macOS desktop controls helper was not found at {HelperPath}", helperPath);
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
using MeetingAssistant.Notifications;
|
||||
using MeetingAssistant.Recording;
|
||||
|
||||
namespace MeetingAssistant.MacOs;
|
||||
|
||||
public sealed class MacOsMeetingInactivityPromptService(DesktopUserRequestStore requests) : IMeetingInactivityPromptService
|
||||
{
|
||||
private const string Kind = "meeting-inactivity";
|
||||
|
||||
public Task ShowStopPromptAsync(MeetingInactivityPromptRequest request,
|
||||
Func<MeetingInactivityPromptResponse, CancellationToken, Task> handleResponseAsync,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
requests.Add(new DesktopUserRequestSpec(
|
||||
Kind, "Stop meeting?",
|
||||
$"No transcript text has arrived for {request.InactivityDuration.TotalMinutes:0.#} minutes.",
|
||||
TimeSpan.FromMinutes(1),
|
||||
[new("stop", "Stop"), new("continue", "Continue"), new("pause", "Pause transcription")]),
|
||||
cancellationToken,
|
||||
(action, token) => handleResponseAsync(MeetingInactivityPromptActions.ParseResponse(action), token));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task DismissAllAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
requests.DismissKind(Kind);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Text.Json;
|
||||
using MeetingAssistant.Calendar;
|
||||
using MeetingAssistant.Notifications;
|
||||
using MeetingAssistant.MeetingNotes;
|
||||
using MeetingAssistant.Screenshots;
|
||||
using MeetingAssistant.Transcription;
|
||||
@@ -362,33 +363,30 @@ internal sealed class MacOsCalendarMeetingProvider : ICalendarMeetingProvider
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class MacOsMeetingStartPromptService : IMeetingStartPromptService
|
||||
internal sealed class MacOsMeetingStartPromptService(
|
||||
DesktopUserRequestStore requests, TimeProvider timeProvider) : IMeetingStartPromptService
|
||||
{
|
||||
private readonly IMacOsMeetingPromptClient client;
|
||||
private readonly ILogger<MacOsMeetingStartPromptService> logger;
|
||||
|
||||
public MacOsMeetingStartPromptService(
|
||||
IMacOsMeetingPromptClient client,
|
||||
ILogger<MacOsMeetingStartPromptService> logger)
|
||||
{
|
||||
this.client = client;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
public async Task ShowPromptAsync(
|
||||
MeetingStartPromptRequest request,
|
||||
public Task ShowPromptAsync(MeetingStartPromptRequest request,
|
||||
Func<MeetingStartPromptResponse, CancellationToken, Task> handleResponseAsync,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await client.ShowMeetingStartPromptAsync(request, cancellationToken);
|
||||
await handleResponseAsync(response, cancellationToken);
|
||||
}
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
{
|
||||
logger.LogError(exception, "Failed to display native macOS meeting-start prompt");
|
||||
}
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var timeout = request.Meeting.End - timeProvider.GetUtcNow();
|
||||
if (timeout <= TimeSpan.Zero) return Task.CompletedTask;
|
||||
if (timeout > TimeSpan.FromMinutes(5)) timeout = TimeSpan.FromMinutes(5);
|
||||
var actions = new List<DesktopRequestAction> { new("record", "Record"), new("skip", "Skip") };
|
||||
if (request.CanAttachToCurrentMeeting) actions.Add(new("attach-metadata", "Add metadata to current meeting"));
|
||||
requests.Add(new DesktopUserRequestSpec(
|
||||
"meeting-start", "Record meeting?",
|
||||
$"{request.Meeting.Subject} starts at {request.Meeting.Start.LocalDateTime:t}.",
|
||||
timeout, actions), cancellationToken,
|
||||
(action, token) => handleResponseAsync(action switch
|
||||
{
|
||||
"record" => MeetingStartPromptResponse.Record,
|
||||
"attach-metadata" => MeetingStartPromptResponse.AttachMetadataToCurrentMeeting,
|
||||
_ => MeetingStartPromptResponse.Skip
|
||||
}, token));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
using System.Collections.Concurrent;
|
||||
using MeetingAssistant.Workflow;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace MeetingAssistant.MacOs;
|
||||
|
||||
public sealed class MacOsWorkflowRulesEditorSessions
|
||||
{
|
||||
private readonly MacOsWorkflowRulesEditorSession defaultSession;
|
||||
private readonly ConcurrentDictionary<string, MacOsWorkflowRulesEditorSession> sessions = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<string, MacOsAgentWindowRequest> windows = new(StringComparer.Ordinal);
|
||||
private readonly IWorkflowRulesEditorChatPipeline pipeline;
|
||||
private readonly string agentUrl;
|
||||
private readonly CancellationToken applicationStopping;
|
||||
private readonly ILogger<MacOsWorkflowRulesEditorSessions> logger;
|
||||
|
||||
public MacOsWorkflowRulesEditorSessions(
|
||||
IWorkflowRulesEditorChatPipeline pipeline,
|
||||
IOptions<MeetingAssistantOptions> options,
|
||||
IHostApplicationLifetime applicationLifetime,
|
||||
ILogger<MacOsWorkflowRulesEditorSessions> logger)
|
||||
{
|
||||
this.pipeline = pipeline;
|
||||
this.logger = logger;
|
||||
applicationStopping = applicationLifetime.ApplicationStopping;
|
||||
agentUrl = $"{options.Value.Api.PublicBaseUrl.TrimEnd('/')}/diagnostics/settings-and-logs";
|
||||
defaultSession = CreateSession();
|
||||
}
|
||||
|
||||
public bool TryGetSession(string? id, out MacOsWorkflowRulesEditorSession session)
|
||||
{
|
||||
if (string.IsNullOrEmpty(id))
|
||||
{
|
||||
session = defaultSession;
|
||||
return true;
|
||||
}
|
||||
return sessions.TryGetValue(id, out session!);
|
||||
}
|
||||
|
||||
public IReadOnlyList<MacOsAgentWindowRequest> GetAgentWindows() =>
|
||||
applicationStopping.IsCancellationRequested ? [] : windows.Values.ToArray();
|
||||
|
||||
public void OpenWithPrompt(string initialPrompt)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(initialPrompt);
|
||||
applicationStopping.ThrowIfCancellationRequested();
|
||||
var id = Guid.NewGuid().ToString("N");
|
||||
var session = CreateSession();
|
||||
sessions[id] = session;
|
||||
var task = session.SendAsync(initialPrompt, applicationStopping);
|
||||
windows[id] = new MacOsAgentWindowRequest(id, $"{agentUrl}?session={id}");
|
||||
_ = ObservePromptAsync(task, id);
|
||||
}
|
||||
|
||||
private MacOsWorkflowRulesEditorSession CreateSession() =>
|
||||
new(new WorkflowRulesEditorChatViewModel(pipeline), applicationStopping);
|
||||
|
||||
private async Task ObservePromptAsync(Task task, string id)
|
||||
{
|
||||
try
|
||||
{
|
||||
await task;
|
||||
}
|
||||
catch (OperationCanceledException) when (applicationStopping.IsCancellationRequested)
|
||||
{
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(exception, "Could not start approved workflow conversation {SessionId}", id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record MacOsAgentWindowRequest(string Id, string Url);
|
||||
@@ -7,10 +7,14 @@ namespace MeetingAssistant.MacOs;
|
||||
public sealed class MacOsWorkflowRulesEditorWindowService : IWorkflowRulesEditorWindowService
|
||||
{
|
||||
private readonly IOptions<MeetingAssistantOptions> options;
|
||||
private readonly Func<MacOsWorkflowRulesEditorSessions> getSessions;
|
||||
|
||||
public MacOsWorkflowRulesEditorWindowService(IOptions<MeetingAssistantOptions> options)
|
||||
public MacOsWorkflowRulesEditorWindowService(
|
||||
IOptions<MeetingAssistantOptions> options,
|
||||
Func<MacOsWorkflowRulesEditorSessions> getSessions)
|
||||
{
|
||||
this.options = options;
|
||||
this.getSessions = getSessions;
|
||||
}
|
||||
|
||||
public void Show()
|
||||
@@ -31,59 +35,82 @@ public sealed class MacOsWorkflowRulesEditorWindowService : IWorkflowRulesEditor
|
||||
|
||||
public void ShowWithPrompt(string initialPrompt)
|
||||
{
|
||||
throw new PlatformNotSupportedException(
|
||||
"Automatic workflow-change conversations require the Windows approval UI. Use the interactive macOS agent instead.");
|
||||
getSessions().OpenWithPrompt(initialPrompt);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class MacOsWorkflowRulesEditorSession
|
||||
{
|
||||
private readonly WorkflowRulesEditorChatViewModel viewModel;
|
||||
private readonly CancellationToken applicationStopping;
|
||||
private readonly SemaphoreSlim sendLock = new(1, 1);
|
||||
private MacOsWorkflowRulesEditorChatResponse snapshot = new([], false);
|
||||
|
||||
public MacOsWorkflowRulesEditorSession(WorkflowRulesEditorChatViewModel viewModel)
|
||||
public MacOsWorkflowRulesEditorSession(
|
||||
WorkflowRulesEditorChatViewModel viewModel,
|
||||
CancellationToken applicationStopping = default)
|
||||
{
|
||||
this.viewModel = viewModel;
|
||||
this.applicationStopping = applicationStopping;
|
||||
}
|
||||
|
||||
public async Task<MacOsWorkflowRulesEditorChatResponse> SendAsync(
|
||||
public Task<MacOsWorkflowRulesEditorChatResponse> SendAsync(
|
||||
string? message,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (string.IsNullOrWhiteSpace(message))
|
||||
{
|
||||
return Snapshot();
|
||||
return Task.FromResult(GetSnapshot());
|
||||
}
|
||||
|
||||
await sendLock.WaitAsync(cancellationToken);
|
||||
// The response connection controls only its wait; accepted agent work belongs to the application.
|
||||
return SendCoreAsync(message.Trim()).WaitAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public MacOsWorkflowRulesEditorChatResponse GetSnapshot() => Volatile.Read(ref snapshot);
|
||||
|
||||
private async Task<MacOsWorkflowRulesEditorChatResponse> SendCoreAsync(string message)
|
||||
{
|
||||
await sendLock.WaitAsync(applicationStopping);
|
||||
try
|
||||
{
|
||||
viewModel.Draft = message;
|
||||
await viewModel.SendAsync(cancellationToken);
|
||||
return Snapshot();
|
||||
applicationStopping.ThrowIfCancellationRequested();
|
||||
var messages = GetSnapshot().Messages
|
||||
.Append(new MacOsWorkflowRulesEditorMessage("user", message))
|
||||
.ToArray();
|
||||
Volatile.Write(ref snapshot, new MacOsWorkflowRulesEditorChatResponse(
|
||||
Array.AsReadOnly(messages), true));
|
||||
try
|
||||
{
|
||||
// Dispatch the agent away from the caller so opening a window or processing a decision stays responsive.
|
||||
await Task.Run(() => viewModel.SendPromptAsync(message, applicationStopping), applicationStopping);
|
||||
}
|
||||
finally
|
||||
{
|
||||
var completedMessages = viewModel.Messages
|
||||
.Where(item => item.Kind == WorkflowRulesEditorConversationItemKind.Message)
|
||||
.Select(item => new MacOsWorkflowRulesEditorMessage(
|
||||
item.Message!.Role.ToString().ToLowerInvariant(),
|
||||
item.Message.Content))
|
||||
.ToArray();
|
||||
Volatile.Write(ref snapshot, new MacOsWorkflowRulesEditorChatResponse(
|
||||
Array.AsReadOnly(completedMessages), false));
|
||||
}
|
||||
return GetSnapshot();
|
||||
}
|
||||
finally
|
||||
{
|
||||
sendLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private MacOsWorkflowRulesEditorChatResponse Snapshot()
|
||||
{
|
||||
return new MacOsWorkflowRulesEditorChatResponse(
|
||||
viewModel.Messages
|
||||
.Where(item => item.Kind == WorkflowRulesEditorConversationItemKind.Message)
|
||||
.Select(item => new MacOsWorkflowRulesEditorMessage(
|
||||
item.Message!.Role.ToString().ToLowerInvariant(),
|
||||
item.Message.Content))
|
||||
.ToArray());
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record MacOsWorkflowRulesEditorChatRequest(string? Message);
|
||||
|
||||
public sealed record MacOsWorkflowRulesEditorChatResponse(
|
||||
IReadOnlyList<MacOsWorkflowRulesEditorMessage> Messages);
|
||||
IReadOnlyList<MacOsWorkflowRulesEditorMessage> Messages,
|
||||
bool IsThinking = false);
|
||||
|
||||
public sealed record MacOsWorkflowRulesEditorMessage(string Role, string Content);
|
||||
|
||||
@@ -126,6 +153,13 @@ public static class MacOsWorkflowRulesEditorPage
|
||||
const prompt = document.querySelector('#prompt');
|
||||
const send = document.querySelector('#send');
|
||||
const messages = document.querySelector('#messages');
|
||||
const session = new URLSearchParams(location.search).get('session');
|
||||
const chatUrl = '/diagnostics/settings-and-logs/chat' + (session ? '?session=' + encodeURIComponent(session) : '');
|
||||
let submitting = false;
|
||||
let thinking = false;
|
||||
let refreshing = false;
|
||||
let available = true;
|
||||
let refreshTimer;
|
||||
const render = items => {
|
||||
messages.replaceChildren(...items.map(item => {
|
||||
const card = document.createElement('div');
|
||||
@@ -135,22 +169,57 @@ public static class MacOsWorkflowRulesEditorPage
|
||||
}));
|
||||
messages.scrollTop = messages.scrollHeight;
|
||||
};
|
||||
const update = body => {
|
||||
render(body.messages);
|
||||
thinking = !!body.isThinking;
|
||||
send.disabled = submitting || thinking;
|
||||
};
|
||||
const showError = text => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'message agent';
|
||||
card.textContent = text;
|
||||
messages.append(card);
|
||||
};
|
||||
const refresh = async () => {
|
||||
if (refreshing || !available) return;
|
||||
clearTimeout(refreshTimer);
|
||||
refreshing = true;
|
||||
try {
|
||||
const response = await fetch(chatUrl, {cache: 'no-store'});
|
||||
if (!response.ok) throw new Error('This conversation is no longer available.');
|
||||
update(await response.json());
|
||||
} catch (error) {
|
||||
available = false;
|
||||
send.disabled = true;
|
||||
showError(error.message);
|
||||
} finally {
|
||||
refreshing = false;
|
||||
if (available && (thinking || submitting)) refreshTimer = setTimeout(refresh, 300);
|
||||
}
|
||||
};
|
||||
form.addEventListener('submit', async event => {
|
||||
event.preventDefault();
|
||||
const message = prompt.value.trim();
|
||||
if (!message) return;
|
||||
if (!message || submitting || thinking || !available) return;
|
||||
prompt.value = '';
|
||||
submitting = true;
|
||||
send.disabled = true;
|
||||
try {
|
||||
const response = await fetch('/diagnostics/settings-and-logs/chat', {
|
||||
const responseTask = fetch(chatUrl, {
|
||||
method: 'POST',
|
||||
headers: {'content-type': 'application/json'},
|
||||
body: JSON.stringify({message})
|
||||
});
|
||||
const body = await response.json();
|
||||
render(body.messages);
|
||||
void refresh();
|
||||
const response = await responseTask;
|
||||
if (!response.ok) throw new Error('The message could not be sent.');
|
||||
update(await response.json());
|
||||
} catch (error) {
|
||||
showError(error.message);
|
||||
} finally {
|
||||
send.disabled = false;
|
||||
submitting = false;
|
||||
send.disabled = !available || thinking;
|
||||
void refresh();
|
||||
prompt.focus();
|
||||
}
|
||||
});
|
||||
@@ -160,6 +229,7 @@ public static class MacOsWorkflowRulesEditorPage
|
||||
form.requestSubmit();
|
||||
}
|
||||
});
|
||||
void refresh();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in new issue
Block a user