forked from Manuel/meeting-assistant
238 lines
9.3 KiB
C#
238 lines
9.3 KiB
C#
using System.Diagnostics;
|
|
using MeetingAssistant.Workflow;
|
|
using Microsoft.Extensions.Options;
|
|
|
|
namespace MeetingAssistant.MacOs;
|
|
|
|
public sealed class MacOsWorkflowRulesEditorWindowService : IWorkflowRulesEditorWindowService
|
|
{
|
|
private readonly IOptions<MeetingAssistantOptions> options;
|
|
private readonly Func<MacOsWorkflowRulesEditorSessions> getSessions;
|
|
|
|
public MacOsWorkflowRulesEditorWindowService(
|
|
IOptions<MeetingAssistantOptions> options,
|
|
Func<MacOsWorkflowRulesEditorSessions> getSessions)
|
|
{
|
|
this.options = options;
|
|
this.getSessions = getSessions;
|
|
}
|
|
|
|
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 void ShowWithPrompt(string initialPrompt)
|
|
{
|
|
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,
|
|
CancellationToken applicationStopping = default)
|
|
{
|
|
this.viewModel = viewModel;
|
|
this.applicationStopping = applicationStopping;
|
|
}
|
|
|
|
public Task<MacOsWorkflowRulesEditorChatResponse> SendAsync(
|
|
string? message,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
if (string.IsNullOrWhiteSpace(message))
|
|
{
|
|
return Task.FromResult(GetSnapshot());
|
|
}
|
|
|
|
// 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
|
|
{
|
|
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();
|
|
}
|
|
}
|
|
}
|
|
|
|
public sealed record MacOsWorkflowRulesEditorChatRequest(string? Message);
|
|
|
|
public sealed record MacOsWorkflowRulesEditorChatResponse(
|
|
IReadOnlyList<MacOsWorkflowRulesEditorMessage> Messages,
|
|
bool IsThinking = false);
|
|
|
|
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 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');
|
|
card.className = `message ${item.role}`;
|
|
card.textContent = item.content;
|
|
return card;
|
|
}));
|
|
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 || submitting || thinking || !available) return;
|
|
prompt.value = '';
|
|
submitting = true;
|
|
send.disabled = true;
|
|
try {
|
|
const responseTask = fetch(chatUrl, {
|
|
method: 'POST',
|
|
headers: {'content-type': 'application/json'},
|
|
body: JSON.stringify({message})
|
|
});
|
|
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 {
|
|
submitting = false;
|
|
send.disabled = !available || thinking;
|
|
void refresh();
|
|
prompt.focus();
|
|
}
|
|
});
|
|
prompt.addEventListener('keydown', event => {
|
|
if (event.key === 'Enter' && !event.shiftKey) {
|
|
event.preventDefault();
|
|
form.requestSubmit();
|
|
}
|
|
});
|
|
void refresh();
|
|
</script>
|
|
</body>
|
|
</html>
|
|
""";
|
|
}
|