Files
meeting-assistant/MeetingAssistant/Workflow/MewUiWorkflowRulesEditorWindowService.Windows.cs
T
codex 11c65ce669
PR and Push Build/Test / build-and-test (push) Successful in 9m36s
Expand settings and logs agent tools
2026-06-01 21:07:08 +02:00

388 lines
11 KiB
C#

#if WINDOWS
using System.Runtime.InteropServices;
using System.Text;
using Aprillz.MewUI;
using Aprillz.MewUI.Controls;
namespace MeetingAssistant.Workflow;
public sealed class MewUiWorkflowRulesEditorWindowService : IWorkflowRulesEditorWindowService
{
internal const string WindowTitle = "Settings and logs";
private readonly IServiceProvider services;
private readonly ILogger<MewUiWorkflowRulesEditorWindowService> logger;
private readonly object sync = new();
private bool isRunning;
public MewUiWorkflowRulesEditorWindowService(
IServiceProvider services,
ILogger<MewUiWorkflowRulesEditorWindowService> logger)
{
this.services = services;
this.logger = logger;
}
public void Show()
{
lock (sync)
{
if (isRunning)
{
logger.LogInformation("Workflow rules editor UI is already running");
if (WorkflowRulesEditorWindowActivation.TryActivate(WindowTitle))
{
return;
}
logger.LogWarning("Workflow rules editor UI was marked running, but no window could be activated; starting a new UI thread");
isRunning = false;
}
isRunning = true;
}
logger.LogInformation("Starting workflow rules editor UI thread");
var thread = new Thread(RunWindow)
{
IsBackground = true,
Name = "Meeting Assistant Rules Editor"
};
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
}
private void RunWindow()
{
try
{
logger.LogInformation("Workflow rules editor UI thread started");
var viewModel = services.GetRequiredService<WorkflowRulesEditorChatViewModel>();
var window = new WorkflowRulesEditorMewWindow(viewModel);
window.Run();
logger.LogInformation("Workflow rules editor UI window closed");
}
catch (Exception exception)
{
logger.LogError(exception, "Workflow rules editor UI failed");
}
finally
{
lock (sync)
{
isRunning = false;
}
}
}
}
internal sealed class WorkflowRulesEditorMewWindow
{
private readonly WorkflowRulesEditorChatViewModel viewModel;
private readonly StackPanel conversationPanel = new();
private readonly ScrollViewer conversationScroll = new();
private readonly MultiLineTextBox input = new();
private readonly Button sendButton = new();
private readonly EventHandler changedHandler;
public WorkflowRulesEditorMewWindow(WorkflowRulesEditorChatViewModel viewModel)
{
this.viewModel = viewModel;
changedHandler = (_, _) => RequestRender();
this.viewModel.Changed += changedHandler;
}
public void Run()
{
ConfigureTheme();
conversationPanel
.Vertical()
.Spacing(8);
input
.Height(92)
.Wrap(true)
.Placeholder("ask me what I can do for you")
.OnTextChanged(text => viewModel.Draft = text)
.OnKeyDown(args =>
{
if (args.Key == Key.Enter && !args.ShiftKey)
{
args.Handled = true;
_ = SendAsync();
}
});
sendButton
.Content("Send")
.Width(84)
.OnClick(() => _ = SendAsync());
var window = new Window()
.Title(MewUiWorkflowRulesEditorWindowService.WindowTitle)
.Resizable(680, 760, 520, 460)
.Padding(0)
.OnLoaded(Render)
.OnClosed(() => viewModel.Changed -= changedHandler)
.Content(
new DockPanel()
.LastChildFill()
.Padding(12)
.Spacing(10)
.Children(
new DockPanel()
.LastChildFill()
.Spacing(8)
.DockBottom()
.Children(
sendButton.DockRight(),
input),
conversationScroll
.AutoVerticalScroll()
.NoHorizontalScroll()
.Content(conversationPanel)));
var icon = LoadWindowIcon();
if (icon is not null)
{
window.Icon = icon;
}
Application.Create()
.UseTheme(ThemeVariant.Dark)
.UseWin32()
.UseDirect2D()
.Run(window);
}
private static IconSource? LoadWindowIcon()
{
var iconPath = Path.Combine(AppContext.BaseDirectory, "Assets", "meeting-assistant.ico");
return File.Exists(iconPath)
? IconSource.FromFile(iconPath)
: null;
}
private static void ConfigureTheme()
{
ThemeManager.DefaultLightSeed = ThemeSeed.DefaultLight with
{
ButtonFace = Color.FromRgb(245, 245, 245)
};
ThemeManager.DefaultDarkSeed = ThemeSeed.DefaultDark with
{
ButtonFace = Color.FromRgb(42, 46, 54)
};
}
private async Task SendAsync()
{
var sendTask = viewModel.SendAsync();
input.Text = viewModel.Draft;
await sendTask;
}
private void RequestRender()
{
var dispatcher = Application.Current?.Dispatcher;
if (dispatcher is null)
{
return;
}
dispatcher.BeginInvoke(Render);
}
private void Render()
{
var shouldAutoScroll = WorkflowRulesEditorScrollPolicy.ShouldAutoScroll(
conversationScroll.VerticalOffset,
conversationScroll.ViewportHeight,
conversationPanel.ActualHeight);
conversationPanel.Clear();
foreach (var message in viewModel.Messages)
{
conversationPanel.Add(CreateMessageCard(message));
}
if (viewModel.IsThinking)
{
conversationPanel.Add(new TextBlock()
.Text("Thinking...")
.Foreground(Color.FromRgb(156, 166, 181))
.Margin(4, 2, 4, 2));
}
sendButton.IsEnabled = !viewModel.IsThinking;
if (shouldAutoScroll)
{
QueueScrollConversationToBottom();
}
}
private void QueueScrollConversationToBottom()
{
var dispatcher = Application.Current?.Dispatcher;
if (dispatcher is null)
{
return;
}
dispatcher.BeginInvoke(DispatcherPriority.Idle, () =>
{
ScrollConversationToBottom();
dispatcher.BeginInvoke(DispatcherPriority.Idle, ScrollConversationToBottom);
});
}
private void ScrollConversationToBottom()
{
conversationScroll.SetScrollOffsets(
conversationScroll.HorizontalOffset,
WorkflowRulesEditorScrollPolicy.GetBottomOffset(
conversationScroll.ViewportHeight,
conversationPanel.ActualHeight));
}
private static Element CreateMessageCard(WorkflowRulesEditorChatMessage message)
{
var isUser = message.Role == WorkflowRulesEditorChatRole.User;
var copyButton = new Button()
.Content("⧉")
.Width(28)
.Height(24)
.ToolTip("Copy message")
.OnClick(() => WorkflowRulesEditorClipboard.SetText(message.Content));
copyButton.IsVisible = false;
var copyRow = new DockPanel()
.LastChildFill(false)
.Children(copyButton.DockRight());
var cardContent = new StackPanel()
.Vertical()
.Spacing(4)
.Children(
copyRow,
WorkflowRulesEditorMarkdownRenderer.CreateContent(message.Content));
var card = new Border()
.Padding(10)
.Margin(isUser ? new Thickness(42, 0, 4, 0) : new Thickness(0, 0, 42, 0))
.CornerRadius(8)
.BorderThickness(1)
.BorderBrush(isUser ? Color.FromRgb(68, 95, 132) : Color.FromRgb(69, 75, 86))
.Background(isUser ? Color.FromRgb(26, 48, 78) : Color.FromRgb(35, 39, 46))
.OnMouseEnter(() => copyButton.IsVisible = true)
.OnMouseLeave(() => copyButton.IsVisible = false)
.Child(cardContent);
return card;
}
}
internal static class WorkflowRulesEditorWindowActivation
{
private const int SwRestore = 9;
public static bool TryActivate(string title)
{
if (string.IsNullOrWhiteSpace(title))
{
return false;
}
var handle = FindWindow(null, title);
if (handle == IntPtr.Zero)
{
return false;
}
ShowWindow(handle, SwRestore);
return SetForegroundWindow(handle);
}
[DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
private static extern IntPtr FindWindow(string? lpClassName, string lpWindowName);
[DllImport("user32.dll", SetLastError = true)]
private static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
[DllImport("user32.dll", SetLastError = true)]
private static extern bool SetForegroundWindow(IntPtr hWnd);
}
internal static class WorkflowRulesEditorClipboard
{
private const uint CfUnicodeText = 13;
private const uint GmemMoveable = 0x0002;
private const uint GmemZeroInit = 0x0040;
public static void SetText(string text)
{
var bytes = Encoding.Unicode.GetBytes((text ?? "") + '\0');
var handle = GlobalAlloc(GmemMoveable | GmemZeroInit, (UIntPtr)bytes.Length);
if (handle == IntPtr.Zero)
{
return;
}
var locked = GlobalLock(handle);
if (locked == IntPtr.Zero)
{
GlobalFree(handle);
return;
}
Marshal.Copy(bytes, 0, locked, bytes.Length);
GlobalUnlock(handle);
if (!OpenClipboard(IntPtr.Zero))
{
GlobalFree(handle);
return;
}
try
{
EmptyClipboard();
if (SetClipboardData(CfUnicodeText, handle) != IntPtr.Zero)
{
handle = IntPtr.Zero;
}
}
finally
{
CloseClipboard();
if (handle != IntPtr.Zero)
{
GlobalFree(handle);
}
}
}
[DllImport("user32.dll", SetLastError = true)]
private static extern bool OpenClipboard(IntPtr hWndNewOwner);
[DllImport("user32.dll", SetLastError = true)]
private static extern bool EmptyClipboard();
[DllImport("user32.dll", SetLastError = true)]
private static extern IntPtr SetClipboardData(uint uFormat, IntPtr hMem);
[DllImport("user32.dll", SetLastError = true)]
private static extern bool CloseClipboard();
[DllImport("kernel32.dll", SetLastError = true)]
private static extern IntPtr GlobalAlloc(uint uFlags, UIntPtr dwBytes);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern IntPtr GlobalLock(IntPtr hMem);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool GlobalUnlock(IntPtr hMem);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern IntPtr GlobalFree(IntPtr hMem);
}
#endif