Files
meeting-assistant/MeetingAssistant/Workflow/WpfWorkflowRulesEditorWindowService.Windows.cs
T
2026-07-01 10:30:28 +02:00

444 lines
15 KiB
C#

#if WINDOWS
using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Threading;
using static MeetingAssistant.Workflow.WorkflowRulesEditorWpfTheme;
namespace MeetingAssistant.Workflow;
internal sealed class WpfWorkflowRulesEditorWindowService : IWorkflowRulesEditorWindowService
{
internal const string WindowTitle = "Meeting Summary Agent";
private readonly IServiceProvider services;
private readonly WorkflowRulesEditorMarkdownLinkResolver linkResolver;
private readonly ILogger<WpfWorkflowRulesEditorWindowService> logger;
private readonly object sync = new();
private Dispatcher? dispatcher;
private WorkflowRulesEditorWpfWindow? window;
private bool isStarted;
public WpfWorkflowRulesEditorWindowService(
IServiceProvider services,
WorkflowRulesEditorMarkdownLinkResolver linkResolver,
ILogger<WpfWorkflowRulesEditorWindowService> logger)
{
this.services = services;
this.linkResolver = linkResolver;
this.logger = logger;
}
public void Show()
{
EnsureUiThread();
dispatcher?.BeginInvoke(ShowOnUiThread);
}
private void EnsureUiThread()
{
lock (sync)
{
if (isStarted)
{
return;
}
isStarted = true;
var ready = new ManualResetEventSlim();
var thread = new Thread(() => RunApplication(ready))
{
IsBackground = true,
Name = "Meeting Assistant Settings and Logs"
};
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
if (!ready.Wait(TimeSpan.FromSeconds(10)))
{
isStarted = false;
throw new TimeoutException("Settings and logs UI thread did not start.");
}
}
}
private void RunApplication(ManualResetEventSlim ready)
{
try
{
logger.LogInformation("Settings and logs WPF thread started");
var application = new Application
{
ShutdownMode = ShutdownMode.OnExplicitShutdown
};
dispatcher = application.Dispatcher;
ready.Set();
application.Run();
}
catch (Exception exception)
{
logger.LogError(exception, "Settings and logs WPF thread failed");
ready.Set();
}
finally
{
lock (sync)
{
dispatcher = null;
window = null;
isStarted = false;
}
}
}
private void ShowOnUiThread()
{
try
{
window ??= new WorkflowRulesEditorWpfWindow(
services.GetRequiredService<WorkflowRulesEditorChatViewModel>(),
linkResolver,
() => window = null);
window.Show();
if (window.WindowState == WindowState.Minimized)
{
window.WindowState = WindowState.Normal;
}
window.Activate();
}
catch (Exception exception)
{
logger.LogError(exception, "Settings and logs WPF window failed");
}
}
}
internal sealed class WorkflowRulesEditorWpfWindow : Window
{
private readonly WorkflowRulesEditorChatViewModel viewModel;
private readonly WorkflowRulesEditorMarkdownLinkResolver linkResolver;
private readonly Action closed;
private readonly StackPanel conversationPanel = new() { Orientation = Orientation.Vertical };
private readonly ScrollViewer conversationScroll = new();
private readonly Border inputFrame = new();
private readonly TextBox input = new();
private readonly TextBlock inputPlaceholder = new();
private readonly Button sendButton = new();
private readonly EventHandler changedHandler;
public WorkflowRulesEditorWpfWindow(
WorkflowRulesEditorChatViewModel viewModel,
WorkflowRulesEditorMarkdownLinkResolver linkResolver,
Action closed)
{
this.viewModel = viewModel;
this.linkResolver = linkResolver;
this.closed = closed;
changedHandler = (_, _) => RequestRender();
this.viewModel.Changed += changedHandler;
Title = WpfWorkflowRulesEditorWindowService.WindowTitle;
Width = 840;
Height = 760;
MinWidth = 520;
MinHeight = 460;
Background = WindowBackground;
Content = CreateLayout();
var iconPath = Path.Combine(AppContext.BaseDirectory, "Assets", "meeting-assistant.ico");
if (File.Exists(iconPath))
{
Icon = System.Windows.Media.Imaging.BitmapFrame.Create(new Uri(iconPath));
}
Closed += (_, _) =>
{
viewModel.Changed -= changedHandler;
closed();
};
ConfigureInput();
Render();
}
private UIElement CreateLayout()
{
var root = new Grid
{
Margin = new Thickness(12)
};
root.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) });
root.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
conversationScroll.Content = conversationPanel;
conversationScroll.VerticalScrollBarVisibility = ScrollBarVisibility.Auto;
conversationScroll.HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled;
conversationScroll.Padding = new Thickness(0, 0, 4, 0);
Grid.SetRow(conversationScroll, 0);
root.Children.Add(conversationScroll);
var inputRow = new Grid { Margin = new Thickness(0, 10, 0, 0) };
inputRow.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
inputRow.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
Grid.SetRow(inputRow, 1);
Grid.SetColumn(inputFrame, 0);
inputRow.Children.Add(inputFrame);
Grid.SetColumn(sendButton, 1);
inputRow.Children.Add(sendButton);
root.Children.Add(inputRow);
return root;
}
private void ConfigureInput()
{
input.AcceptsReturn = true;
input.TextWrapping = TextWrapping.Wrap;
input.MinHeight = 86;
input.MaxHeight = 124;
input.Padding = new Thickness(12, 9, 12, 9);
input.Background = Brushes.Transparent;
input.Foreground = PrimaryText;
input.BorderThickness = new Thickness(0);
input.CaretBrush = PrimaryText;
input.FontSize = 14;
input.VerticalScrollBarVisibility = ScrollBarVisibility.Auto;
input.TextChanged += (_, _) =>
{
viewModel.Draft = input.Text;
inputPlaceholder.Visibility = string.IsNullOrEmpty(input.Text)
? Visibility.Visible
: Visibility.Collapsed;
};
input.PreviewKeyDown += OnInputPreviewKeyDown;
input.GotKeyboardFocus += (_, _) => inputFrame.BorderBrush = InputFocusedBorder;
input.LostKeyboardFocus += (_, _) => inputFrame.BorderBrush = InputBorder;
inputPlaceholder.Text = "ask me what I can do for you";
inputPlaceholder.Foreground = MutedText;
inputPlaceholder.Margin = new Thickness(13, 9, 13, 9);
inputPlaceholder.IsHitTestVisible = false;
var inputGrid = new Grid();
inputGrid.Children.Add(input);
inputGrid.Children.Add(inputPlaceholder);
inputFrame.Child = inputGrid;
inputFrame.Margin = new Thickness(0, 0, 10, 0);
inputFrame.Background = InputBackground;
inputFrame.BorderBrush = InputBorder;
inputFrame.BorderThickness = new Thickness(1);
inputFrame.CornerRadius = new CornerRadius(7);
sendButton.Content = "Send";
sendButton.Width = 104;
sendButton.MinHeight = 86;
sendButton.Background = ButtonBackground;
sendButton.Foreground = PrimaryText;
sendButton.BorderBrush = InputBorder;
sendButton.BorderThickness = new Thickness(1);
sendButton.FontWeight = FontWeights.SemiBold;
sendButton.Cursor = Cursors.Hand;
sendButton.Style = CreateSendButtonStyle();
sendButton.Click += async (_, _) => await SendAsync();
}
private async void OnInputPreviewKeyDown(object sender, KeyEventArgs args)
{
if (IsEnterKey(args) && Keyboard.Modifiers.HasFlag(ModifierKeys.Shift) is false)
{
args.Handled = true;
await SendAsync();
}
}
private static bool IsEnterKey(KeyEventArgs args)
{
return args.Key is Key.Enter or Key.Return ||
args.SystemKey is Key.Enter or Key.Return ||
args.ImeProcessedKey is Key.Enter or Key.Return;
}
private async Task SendAsync()
{
var sendTask = viewModel.SendAsync();
input.Text = viewModel.Draft;
await sendTask;
}
private void RequestRender()
{
if (Dispatcher.CheckAccess())
{
Render();
return;
}
Dispatcher.BeginInvoke(Render);
}
private void Render()
{
var shouldAutoScroll = WorkflowRulesEditorScrollPolicy.ShouldAutoScroll(
conversationScroll.VerticalOffset,
conversationScroll.ViewportHeight,
conversationPanel.ActualHeight);
conversationPanel.Children.Clear();
foreach (var item in viewModel.Messages)
{
conversationPanel.Children.Add(item.Kind == WorkflowRulesEditorConversationItemKind.Activity
? CreateActivityExpander(item)
: CreateMessageCard(item.Message!));
}
if (viewModel.IsThinking)
{
foreach (var activity in viewModel.ActivityMessages)
{
conversationPanel.Children.Add(CreateActivityLine(activity));
}
conversationPanel.Children.Add(CreateActivityLine(viewModel.ActivityMessage));
}
sendButton.IsEnabled = !viewModel.IsThinking;
sendButton.Cursor = sendButton.IsEnabled ? Cursors.Hand : Cursors.Arrow;
if (shouldAutoScroll)
{
Dispatcher.BeginInvoke(DispatcherPriority.ContextIdle, ScrollConversationToBottom);
Dispatcher.BeginInvoke(DispatcherPriority.ApplicationIdle, ScrollConversationToBottom);
}
}
private void ScrollConversationToBottom()
{
conversationScroll.ScrollToVerticalOffset(WorkflowRulesEditorScrollPolicy.GetBottomOffset(
conversationScroll.ViewportHeight,
conversationPanel.ActualHeight));
}
private UIElement CreateMessageCard(WorkflowRulesEditorChatMessage message)
{
var isUser = message.Role == WorkflowRulesEditorChatRole.User;
var cardContent = new StackPanel { Orientation = Orientation.Vertical };
cardContent.Children.Add(WorkflowRulesEditorMarkdownRenderer.CreateContent(message.Content, linkResolver));
var card = new Border
{
Padding = new Thickness(12, 9, 12, 9),
Margin = isUser ? new Thickness(42, 0, 4, 8) : new Thickness(0, 0, 42, 8),
CornerRadius = new CornerRadius(8),
BorderThickness = new Thickness(1),
BorderBrush = isUser ? UserCardBorder : AgentCardBorder,
Background = isUser ? UserCardBackground : AgentCardBackground,
Child = cardContent
};
return card;
}
private static Expander CreateActivityExpander(WorkflowRulesEditorConversationItem item)
{
var details = new StackPanel
{
Orientation = Orientation.Vertical,
Margin = new Thickness(16, 2, 4, 6)
};
foreach (var line in item.ActivityLines ?? [])
{
details.Children.Add(CreateActivityLine(line));
}
var expander = new Expander
{
Header = item.Content,
Content = details,
IsExpanded = item.IsExpanded,
Foreground = MutedText,
Background = Brushes.Transparent,
Margin = new Thickness(4, 0, 4, 8)
};
expander.Expanded += (_, _) => item.IsExpanded = true;
expander.Collapsed += (_, _) => item.IsExpanded = false;
return expander;
}
private static TextBlock CreateActivityLine(string text)
{
return new TextBlock
{
Text = text,
Foreground = MutedText,
Margin = new Thickness(4, 2, 4, 2)
};
}
private static Style CreateSendButtonStyle()
{
var style = new Style(typeof(Button));
style.Setters.Add(new Setter(Control.PaddingProperty, new Thickness(12, 0, 12, 0)));
style.Setters.Add(new Setter(Control.TemplateProperty, CreateSendButtonTemplate()));
style.Triggers.Add(new Trigger
{
Property = UIElement.IsMouseOverProperty,
Value = true,
Setters =
{
new Setter(Control.BackgroundProperty, ButtonHoverBackground)
}
});
style.Triggers.Add(new Trigger
{
Property = ButtonBase.IsPressedProperty,
Value = true,
Setters =
{
new Setter(Control.BackgroundProperty, ButtonPressedBackground)
}
});
style.Triggers.Add(new Trigger
{
Property = UIElement.IsEnabledProperty,
Value = false,
Setters =
{
new Setter(Control.BackgroundProperty, DisabledButtonBackground),
new Setter(Control.BorderBrushProperty, DisabledButtonBorder),
new Setter(Control.ForegroundProperty, DisabledText),
new Setter(UIElement.OpacityProperty, 1.0)
}
});
return style;
}
private static ControlTemplate CreateSendButtonTemplate()
{
var border = new FrameworkElementFactory(typeof(Border));
border.SetValue(Border.CornerRadiusProperty, new CornerRadius(7));
border.SetValue(Border.BackgroundProperty, new TemplateBindingExtension(Control.BackgroundProperty));
border.SetValue(Border.BorderBrushProperty, new TemplateBindingExtension(Control.BorderBrushProperty));
border.SetValue(Border.BorderThicknessProperty, new TemplateBindingExtension(Control.BorderThicknessProperty));
var content = new FrameworkElementFactory(typeof(TextBlock));
content.SetValue(FrameworkElement.HorizontalAlignmentProperty, HorizontalAlignment.Center);
content.SetValue(FrameworkElement.VerticalAlignmentProperty, VerticalAlignment.Center);
content.SetValue(TextBlock.TextProperty, new TemplateBindingExtension(ContentControl.ContentProperty));
content.SetValue(TextBlock.ForegroundProperty, new TemplateBindingExtension(Control.ForegroundProperty));
content.SetValue(TextBlock.FontWeightProperty, new TemplateBindingExtension(Control.FontWeightProperty));
border.AppendChild(content);
return new ControlTemplate(typeof(Button))
{
VisualTree = border
};
}
}
#endif