forked from Manuel/meeting-assistant
feat(macos): add native desktop controls and hotkey integration
- Introduced MacOsDesktopControlManifest to manage desktop control configurations. - Implemented MacOsDesktopControlService to handle the lifecycle of the macOS helper process. - Created MacOsWorkflowRulesEditorWindowService for managing workflow rules through a native window. - Developed a Swift helper for macOS that integrates with AppKit and Carbon for menu-bar controls and global hotkeys. - Added support for recording lifecycle actions via HTTP endpoints, maintaining consistency with existing Windows functionality. - Enhanced user experience by allowing macOS users to start/stop recordings and access settings through a menu-bar interface. - Documented design decisions, implementation evidence, and verification tasks for the new macOS features.
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
using System.Diagnostics;
|
||||
using MeetingAssistant.Workflow;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace MeetingAssistant.MacOs;
|
||||
|
||||
public sealed class MacOsWorkflowRulesEditorWindowService : IWorkflowRulesEditorWindowService
|
||||
{
|
||||
private readonly IOptions<MeetingAssistantOptions> options;
|
||||
|
||||
public MacOsWorkflowRulesEditorWindowService(IOptions<MeetingAssistantOptions> options)
|
||||
{
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
public void Show()
|
||||
{
|
||||
if (!OperatingSystem.IsMacOS())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var url = $"{options.Value.Api.PublicBaseUrl.TrimEnd('/')}/diagnostics/settings-and-logs";
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = "/usr/bin/open",
|
||||
ArgumentList = { url },
|
||||
UseShellExecute = false
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class MacOsWorkflowRulesEditorSession
|
||||
{
|
||||
private readonly WorkflowRulesEditorChatViewModel viewModel;
|
||||
private readonly SemaphoreSlim sendLock = new(1, 1);
|
||||
|
||||
public MacOsWorkflowRulesEditorSession(WorkflowRulesEditorChatViewModel viewModel)
|
||||
{
|
||||
this.viewModel = viewModel;
|
||||
}
|
||||
|
||||
public async Task<MacOsWorkflowRulesEditorChatResponse> SendAsync(
|
||||
string? message,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(message))
|
||||
{
|
||||
return Snapshot();
|
||||
}
|
||||
|
||||
await sendLock.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
viewModel.Draft = message;
|
||||
await viewModel.SendAsync(cancellationToken);
|
||||
return Snapshot();
|
||||
}
|
||||
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);
|
||||
|
||||
public sealed record MacOsWorkflowRulesEditorMessage(string Role, string Content);
|
||||
|
||||
public static class MacOsWorkflowRulesEditorPage
|
||||
{
|
||||
public const string Html = """
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Meeting Summary Agent</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; font: 15px -apple-system, BlinkMacSystemFont, sans-serif; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; background: #171719; color: #f5f5f7; }
|
||||
main { height: 100vh; display: grid; grid-template-rows: auto 1fr auto; padding: 18px; gap: 14px; }
|
||||
h1 { margin: 0; font-size: 18px; }
|
||||
#messages { overflow: auto; display: flex; flex-direction: column; gap: 10px; }
|
||||
.message { max-width: 82%; padding: 10px 12px; border: 1px solid #414147; border-radius: 10px; white-space: pre-wrap; }
|
||||
.user { align-self: flex-end; background: #293650; }
|
||||
.agent { align-self: flex-start; background: #232326; }
|
||||
form { display: grid; grid-template-columns: 1fr auto; gap: 10px; }
|
||||
textarea { min-height: 78px; resize: vertical; border-radius: 9px; border: 1px solid #55555d; background: #222225; color: inherit; padding: 10px; font: inherit; }
|
||||
button { min-width: 96px; border: 0; border-radius: 9px; background: #0a84ff; color: white; font-weight: 600; }
|
||||
button:disabled { opacity: .55; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Meeting Summary Agent</h1>
|
||||
<section id="messages"></section>
|
||||
<form id="chat">
|
||||
<textarea id="prompt" placeholder="ask me what I can do for you"></textarea>
|
||||
<button id="send">Send</button>
|
||||
</form>
|
||||
</main>
|
||||
<script>
|
||||
const form = document.querySelector('#chat');
|
||||
const prompt = document.querySelector('#prompt');
|
||||
const send = document.querySelector('#send');
|
||||
const messages = document.querySelector('#messages');
|
||||
const render = items => {
|
||||
messages.replaceChildren(...items.map(item => {
|
||||
const card = document.createElement('div');
|
||||
card.className = `message ${item.role}`;
|
||||
card.textContent = item.content;
|
||||
return card;
|
||||
}));
|
||||
messages.scrollTop = messages.scrollHeight;
|
||||
};
|
||||
form.addEventListener('submit', async event => {
|
||||
event.preventDefault();
|
||||
const message = prompt.value.trim();
|
||||
if (!message) return;
|
||||
prompt.value = '';
|
||||
send.disabled = true;
|
||||
try {
|
||||
const response = await fetch('/diagnostics/settings-and-logs/chat', {
|
||||
method: 'POST',
|
||||
headers: {'content-type': 'application/json'},
|
||||
body: JSON.stringify({message})
|
||||
});
|
||||
const body = await response.json();
|
||||
render(body.messages);
|
||||
} finally {
|
||||
send.disabled = false;
|
||||
prompt.focus();
|
||||
}
|
||||
});
|
||||
prompt.addEventListener('keydown', event => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
form.requestSubmit();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
""";
|
||||
}
|
||||
Reference in New Issue
Block a user