Public Access
388 lines
11 KiB
C#
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
|