Improve settings logs agent and renderer
PR and Push Build/Test / build-and-test (push) Successful in 8m13s

This commit is contained in:
2026-06-02 00:09:03 +02:00
parent 11c65ce669
commit e9825f5bf5
11 changed files with 1507 additions and 527 deletions
@@ -1,387 +0,0 @@
#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
@@ -5,6 +5,10 @@ internal enum WorkflowRulesEditorMarkdownInlineStyle
Normal,
Italic,
Bold,
BoldItalic,
Strikethrough,
Link,
Image,
Code
}
@@ -13,6 +17,24 @@ internal abstract record WorkflowRulesEditorMarkdownBlock;
internal sealed record WorkflowRulesEditorMarkdownParagraph(
IReadOnlyList<WorkflowRulesEditorMarkdownInline> Inlines) : WorkflowRulesEditorMarkdownBlock;
internal sealed record WorkflowRulesEditorMarkdownHeading(
int Level,
IReadOnlyList<WorkflowRulesEditorMarkdownInline> Inlines) : WorkflowRulesEditorMarkdownBlock;
internal sealed record WorkflowRulesEditorMarkdownBlockQuote(
IReadOnlyList<WorkflowRulesEditorMarkdownInline> Inlines) : WorkflowRulesEditorMarkdownBlock;
internal sealed record WorkflowRulesEditorMarkdownImage(
string AltText,
string Target) : WorkflowRulesEditorMarkdownBlock;
internal sealed record WorkflowRulesEditorMarkdownTaskList(
IReadOnlyList<WorkflowRulesEditorMarkdownTaskItem> Items) : WorkflowRulesEditorMarkdownBlock;
internal sealed record WorkflowRulesEditorMarkdownTaskItem(
bool IsChecked,
IReadOnlyList<WorkflowRulesEditorMarkdownInline> Inlines);
internal sealed record WorkflowRulesEditorMarkdownCodeBlock(string Text) : WorkflowRulesEditorMarkdownBlock;
internal sealed record WorkflowRulesEditorMarkdownTable(
@@ -21,7 +43,8 @@ internal sealed record WorkflowRulesEditorMarkdownTable(
internal sealed record WorkflowRulesEditorMarkdownInline(
string Text,
WorkflowRulesEditorMarkdownInlineStyle Style);
WorkflowRulesEditorMarkdownInlineStyle Style,
string? LinkTarget = null);
internal static class WorkflowRulesEditorMarkdown
{
@@ -64,6 +87,50 @@ internal static class WorkflowRulesEditorMarkdown
continue;
}
if (TryParseStandaloneImage(line, out var image))
{
FlushParagraph(blocks, paragraph);
blocks.Add(image);
continue;
}
if (TryParseHeading(line, out var heading))
{
FlushParagraph(blocks, paragraph);
blocks.Add(heading);
continue;
}
if (IsBlockQuote(line))
{
FlushParagraph(blocks, paragraph);
var quote = new List<string>();
while (index < lines.Length && IsBlockQuote(lines[index]))
{
quote.Add(lines[index].TrimStart()[1..].TrimStart());
index++;
}
blocks.Add(new WorkflowRulesEditorMarkdownBlockQuote(ParseInline(string.Join('\n', quote))));
index--;
continue;
}
if (TryParseTaskLine(line, out _))
{
FlushParagraph(blocks, paragraph);
var tasks = new List<WorkflowRulesEditorMarkdownTaskItem>();
while (index < lines.Length && TryParseTaskLine(lines[index], out var task))
{
tasks.Add(task);
index++;
}
blocks.Add(new WorkflowRulesEditorMarkdownTaskList(tasks));
index--;
continue;
}
if (IsTableStart(lines, index))
{
FlushParagraph(blocks, paragraph);
@@ -128,6 +195,72 @@ internal static class WorkflowRulesEditorMarkdown
continue;
}
}
else if (text.AsSpan(next).StartsWith("![", StringComparison.Ordinal))
{
if (TryParseImage(text, next, out var altText, out var target, out var end))
{
AddInline(
result,
altText,
WorkflowRulesEditorMarkdownInlineStyle.Image,
target);
index = end + 1;
continue;
}
}
else if (text[next] == '[')
{
var endLabel = text.IndexOf(']', next + 1);
if (endLabel > next &&
endLabel + 1 < text.Length &&
text[endLabel + 1] == '(')
{
var endTarget = text.IndexOf(')', endLabel + 2);
if (endTarget > endLabel + 2)
{
AddInline(
result,
text[(next + 1)..endLabel],
WorkflowRulesEditorMarkdownInlineStyle.Link,
text[(endLabel + 2)..endTarget]);
index = endTarget + 1;
continue;
}
}
}
else if (text[next] == '<')
{
if (TryParseAutolink(text, next, out var label, out var target, out var end))
{
AddInline(
result,
label,
WorkflowRulesEditorMarkdownInlineStyle.Link,
target);
index = end + 1;
continue;
}
}
else if (text.AsSpan(next).StartsWith("***", StringComparison.Ordinal))
{
var end = text.IndexOf("***", next + 3, StringComparison.Ordinal);
if (end > next)
{
AddInline(result, text[(next + 3)..end], WorkflowRulesEditorMarkdownInlineStyle.BoldItalic);
index = end + 3;
continue;
}
}
else if (text.AsSpan(next).StartsWith("~~", StringComparison.Ordinal))
{
var end = text.IndexOf("~~", next + 2, StringComparison.Ordinal);
if (end > next)
{
AddInline(result, text[(next + 2)..end], WorkflowRulesEditorMarkdownInlineStyle.Strikethrough);
index = end + 2;
continue;
}
}
else if (text.AsSpan(next).StartsWith("**", StringComparison.Ordinal))
{
var end = text.IndexOf("**", next + 2, StringComparison.Ordinal);
@@ -169,6 +302,141 @@ internal static class WorkflowRulesEditorMarkdown
paragraph.Clear();
}
private static bool TryParseHeading(string line, out WorkflowRulesEditorMarkdownHeading heading)
{
heading = null!;
var trimmed = line.TrimStart();
var level = trimmed.StartsWith("# ", StringComparison.Ordinal)
? 1
: trimmed.StartsWith("## ", StringComparison.Ordinal)
? 2
: 0;
if (level == 0)
{
return false;
}
heading = new WorkflowRulesEditorMarkdownHeading(level, ParseInline(trimmed));
return true;
}
private static bool IsBlockQuote(string line)
{
return line.TrimStart().StartsWith('>');
}
private static bool TryParseTaskLine(string line, out WorkflowRulesEditorMarkdownTaskItem task)
{
task = null!;
var trimmedStart = line.TrimStart();
if (trimmedStart.StartsWith("- [x] ", StringComparison.OrdinalIgnoreCase))
{
task = new WorkflowRulesEditorMarkdownTaskItem(true, ParseInline(trimmedStart[6..]));
return true;
}
if (trimmedStart.StartsWith("- [ ] ", StringComparison.Ordinal))
{
task = new WorkflowRulesEditorMarkdownTaskItem(false, ParseInline(trimmedStart[6..]));
return true;
}
return false;
}
private static bool TryParseStandaloneImage(
string line,
out WorkflowRulesEditorMarkdownImage image)
{
image = null!;
var trimmed = line.Trim();
if (!TryParseImage(trimmed, 0, out var altText, out var target, out var end) ||
end != trimmed.Length - 1)
{
return false;
}
image = new WorkflowRulesEditorMarkdownImage(altText, target);
return true;
}
private static bool TryParseImage(
string text,
int start,
out string altText,
out string target,
out int end)
{
altText = "";
target = "";
end = -1;
if (!text.AsSpan(start).StartsWith("![", StringComparison.Ordinal))
{
return false;
}
var endLabel = text.IndexOf(']', start + 2);
if (endLabel <= start ||
endLabel + 1 >= text.Length ||
text[endLabel + 1] != '(')
{
return false;
}
var endTarget = text.IndexOf(')', endLabel + 2);
if (endTarget <= endLabel + 2)
{
return false;
}
altText = text[(start + 2)..endLabel];
target = text[(endLabel + 2)..endTarget];
end = endTarget;
return true;
}
private static bool TryParseAutolink(
string text,
int start,
out string label,
out string target,
out int end)
{
label = "";
target = "";
end = text.IndexOf('>', start + 1);
if (end <= start + 1)
{
return false;
}
label = text[(start + 1)..end];
if (label.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
label.StartsWith("https://", StringComparison.OrdinalIgnoreCase) ||
label.StartsWith("mailto:", StringComparison.OrdinalIgnoreCase))
{
target = label;
return true;
}
if (IsEmailAddress(label))
{
target = "mailto:" + label;
return true;
}
return false;
}
private static bool IsEmailAddress(string text)
{
var at = text.IndexOf('@');
return at > 0 &&
at < text.Length - 1 &&
text.IndexOf('.', at + 1) > at + 1 &&
!text.Any(char.IsWhiteSpace);
}
private static bool IsTableStart(IReadOnlyList<string> lines, int index)
{
return index + 1 < lines.Count &&
@@ -215,33 +483,41 @@ internal static class WorkflowRulesEditorMarkdown
private static void AddInline(
List<WorkflowRulesEditorMarkdownInline> result,
string text,
WorkflowRulesEditorMarkdownInlineStyle style)
WorkflowRulesEditorMarkdownInlineStyle style,
string? linkTarget = null)
{
if (text.Length == 0)
{
return;
}
if (result.Count > 0 && result[^1].Style == style)
if (result.Count > 0 && result[^1].Style == style && result[^1].LinkTarget == linkTarget)
{
result[^1] = result[^1] with { Text = result[^1].Text + text };
return;
}
result.Add(new WorkflowRulesEditorMarkdownInline(text, style));
result.Add(new WorkflowRulesEditorMarkdownInline(text, style, linkTarget));
}
private static int FindNextMarker(string text, int start)
{
var code = text.IndexOf('`', start);
var image = text.IndexOf("![", start, StringComparison.Ordinal);
var link = text.IndexOf('[', start);
var autolink = text.IndexOf('<', start);
var boldItalic = text.IndexOf("***", start, StringComparison.Ordinal);
var strikethrough = text.IndexOf("~~", start, StringComparison.Ordinal);
var bold = text.IndexOf("**", start, StringComparison.Ordinal);
var italic = text.IndexOf('*', start);
if (italic >= 0 && italic + 1 < text.Length && text[italic + 1] == '*')
while (italic >= 0 &&
italic + 1 < text.Length &&
text[italic + 1] == '*')
{
italic = text.IndexOf('*', italic + 2);
}
return new[] { code, bold, italic }
return new[] { code, image, link, autolink, boldItalic, strikethrough, bold, italic }
.Where(index => index >= 0)
.DefaultIfEmpty(-1)
.Min();
@@ -0,0 +1,180 @@
namespace MeetingAssistant.Workflow;
internal sealed class WorkflowRulesEditorMarkdownLinkResolver
{
private static readonly string[] ImageUriSchemes = ["http", "https", "file"];
private readonly IReadOnlyList<string> rootPaths;
private readonly IReadOnlyList<string> recursiveRootPaths;
private readonly Dictionary<string, string> resolvedFiles = new(StringComparer.OrdinalIgnoreCase);
public WorkflowRulesEditorMarkdownLinkResolver(
IEnumerable<string> rootPaths,
IEnumerable<string>? recursiveRootPaths = null)
{
this.rootPaths = rootPaths
.Where(path => !string.IsNullOrWhiteSpace(path))
.Select(path => Path.GetFullPath(path))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
this.recursiveRootPaths = (recursiveRootPaths ?? [])
.Where(path => !string.IsNullOrWhiteSpace(path))
.Select(path => Path.GetFullPath(path))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
}
public static WorkflowRulesEditorMarkdownLinkResolver Empty { get; } = new([]);
public static WorkflowRulesEditorMarkdownLinkResolver FromOptions(MeetingAssistantOptions options)
{
var artifactRoots = new[]
{
VaultPath.Resolve(options.Vault, options.Vault.AssistantContextFolder),
VaultPath.Resolve(options.Vault, options.Vault.MeetingNotesFolder),
VaultPath.Resolve(options.Vault, options.Vault.SummariesFolder),
VaultPath.Resolve(options.Vault, options.Vault.TranscriptsFolder),
VaultPath.Resolve(options.Vault, options.Vault.ProjectsFolder)
};
return new WorkflowRulesEditorMarkdownLinkResolver([
..artifactRoots,
Environment.CurrentDirectory,
AppContext.BaseDirectory
], artifactRoots);
}
public bool TryResolveImageUri(string? target, out Uri uri)
{
uri = null!;
if (string.IsNullOrWhiteSpace(target))
{
return false;
}
if (Uri.TryCreate(target, UriKind.Absolute, out uri!) &&
ImageUriSchemes.Contains(uri.Scheme, StringComparer.OrdinalIgnoreCase))
{
return true;
}
if (TryResolveExistingFilePath(target, out var path))
{
uri = new Uri(path);
return true;
}
return false;
}
public string ResolveOpenTarget(string? target)
{
if (string.IsNullOrWhiteSpace(target))
{
return "";
}
if (Uri.TryCreate(target, UriKind.Absolute, out var uri))
{
return uri.Scheme.Equals("file", StringComparison.OrdinalIgnoreCase)
? uri.LocalPath
: uri.ToString();
}
return TryResolveExistingFilePath(target, out var path)
? path
: target;
}
private bool TryResolveExistingFilePath(string target, out string path)
{
path = "";
if (Path.IsPathRooted(target) && File.Exists(target))
{
path = Path.GetFullPath(target);
return true;
}
if (resolvedFiles.TryGetValue(target, out var cachedPath))
{
path = cachedPath;
return true;
}
if (TryResolveDirectRelativePath(target, out path) ||
TryFindRelativePathInRoots(target, out path))
{
resolvedFiles[target] = path;
return true;
}
return false;
}
private bool TryResolveDirectRelativePath(string target, out string path)
{
foreach (var root in rootPaths)
{
try
{
var candidate = Path.GetFullPath(target, root);
if (File.Exists(candidate))
{
path = candidate;
return true;
}
}
catch
{
}
}
path = "";
return false;
}
private bool TryFindRelativePathInRoots(string target, out string path)
{
path = "";
var normalizedSuffix = NormalizeRelativePath(target);
if (string.IsNullOrWhiteSpace(normalizedSuffix))
{
return false;
}
var fileName = Path.GetFileName(normalizedSuffix);
if (string.IsNullOrWhiteSpace(fileName))
{
return false;
}
foreach (var root in recursiveRootPaths.Where(Directory.Exists))
{
try
{
var match = Directory
.EnumerateFiles(root, fileName, SearchOption.AllDirectories)
.Where(candidate => NormalizeRelativePath(Path.GetRelativePath(root, candidate))
.EndsWith(normalizedSuffix, StringComparison.OrdinalIgnoreCase))
.OrderByDescending(File.GetLastWriteTimeUtc)
.FirstOrDefault();
if (match is not null)
{
path = match;
return true;
}
}
catch
{
}
}
return false;
}
private static string NormalizeRelativePath(string path)
{
return path
.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar)
.TrimStart(Path.DirectorySeparatorChar);
}
}
@@ -1,128 +1,150 @@
#if WINDOWS
using Aprillz.MewUI;
using Aprillz.MewUI.Controls;
using System.Diagnostics;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Documents;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using static MeetingAssistant.Workflow.WorkflowRulesEditorWpfTheme;
namespace MeetingAssistant.Workflow;
internal static class WorkflowRulesEditorMarkdownRenderer
{
public static UIElement CreateContent(string content)
public static UIElement CreateContent(
string content,
WorkflowRulesEditorMarkdownLinkResolver? linkResolver = null)
{
var panel = new StackPanel()
.Vertical()
.Spacing(7);
foreach (var block in WorkflowRulesEditorMarkdown.Parse(content))
linkResolver ??= WorkflowRulesEditorMarkdownLinkResolver.Empty;
var panel = new StackPanel
{
panel.Add(block switch
Orientation = Orientation.Vertical
};
var blocks = WorkflowRulesEditorMarkdown.Parse(content);
for (var index = 0; index < blocks.Count; index++)
{
var element = blocks[index] switch
{
WorkflowRulesEditorMarkdownHeading heading => CreateHeading(heading, linkResolver),
WorkflowRulesEditorMarkdownBlockQuote quote => CreateBlockQuote(quote, linkResolver),
WorkflowRulesEditorMarkdownImage image => CreateImageBlock(image, linkResolver),
WorkflowRulesEditorMarkdownTaskList tasks => CreateTaskList(tasks, linkResolver),
WorkflowRulesEditorMarkdownCodeBlock codeBlock => CreateCodeBlock(codeBlock),
WorkflowRulesEditorMarkdownTable table => CreateTable(table),
WorkflowRulesEditorMarkdownParagraph paragraph => CreateParagraph(paragraph),
WorkflowRulesEditorMarkdownParagraph paragraph => CreateParagraph(paragraph, linkResolver),
_ => new TextBlock()
});
};
if (index < blocks.Count - 1)
{
element.SetValue(FrameworkElement.MarginProperty, new Thickness(0, 0, 0, 7));
}
panel.Children.Add(element);
}
return panel;
}
private static UIElement CreateParagraph(WorkflowRulesEditorMarkdownParagraph paragraph)
private static UIElement CreateHeading(
WorkflowRulesEditorMarkdownHeading heading,
WorkflowRulesEditorMarkdownLinkResolver linkResolver)
{
var lines = SplitInlineLines(paragraph.Inlines);
if (lines.Count == 1)
{
return CreateParagraphLine(lines[0]);
}
var paragraph = CreateInlineParagraph(heading.Inlines, linkResolver, heading.Level == 1 ? 22 : 20);
paragraph.FontWeight = FontWeights.SemiBold;
var panel = new StackPanel()
.Vertical()
.Spacing(3);
foreach (var line in lines)
{
panel.Add(CreateParagraphLine(line));
}
return panel;
return CreateSelectableDocument([paragraph], fontSize: heading.Level == 1 ? 16.5 : 15.5);
}
private static UIElement CreateParagraphLine(IReadOnlyList<WorkflowRulesEditorMarkdownInline> inlines)
private static UIElement CreateBlockQuote(
WorkflowRulesEditorMarkdownBlockQuote quote,
WorkflowRulesEditorMarkdownLinkResolver linkResolver)
{
var linePanel = new WrapPanel()
.Spacing(1);
foreach (var inline in inlines)
var paragraph = CreateInlineParagraph(quote.Inlines, linkResolver);
return new Border
{
foreach (var element in CreateInlineElements(inline))
Padding = new Thickness(10, 7, 10, 7),
BorderThickness = new Thickness(3, 0, 0, 0),
BorderBrush = QuoteBorder,
Background = QuoteBackground,
Child = CreateSelectableDocument([paragraph])
};
}
private static UIElement CreateParagraph(
WorkflowRulesEditorMarkdownParagraph paragraph,
WorkflowRulesEditorMarkdownLinkResolver linkResolver)
{
var flowParagraph = CreateInlineParagraph(paragraph.Inlines, linkResolver);
return CreateSelectableDocument([flowParagraph]);
}
private static UIElement CreateImageBlock(
WorkflowRulesEditorMarkdownImage image,
WorkflowRulesEditorMarkdownLinkResolver linkResolver)
{
return CreateImagePreview(image.AltText, image.Target, linkResolver);
}
private static UIElement CreateTaskList(
WorkflowRulesEditorMarkdownTaskList tasks,
WorkflowRulesEditorMarkdownLinkResolver linkResolver)
{
var grid = new Grid();
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(24) });
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
for (var row = 0; row < tasks.Items.Count; row++)
{
var item = tasks.Items[row];
grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
var glyph = new TextBlock
{
linePanel.Add(element);
}
Text = item.IsChecked ? "☑" : "☐",
FontFamily = new FontFamily("Segoe UI Symbol"),
FontSize = 17,
Foreground = PrimaryText,
LineHeight = 19,
Margin = new Thickness(0, 0, 6, 3),
VerticalAlignment = VerticalAlignment.Top
};
Grid.SetRow(glyph, row);
Grid.SetColumn(glyph, 0);
grid.Children.Add(glyph);
var paragraph = CreateInlineParagraph(item.Inlines, linkResolver);
var text = CreateSelectableDocument([paragraph]);
text.Margin = new Thickness(0, 0, 0, 3);
Grid.SetRow(text, row);
Grid.SetColumn(text, 1);
grid.Children.Add(text);
}
return linePanel;
return grid;
}
private static IEnumerable<UIElement> CreateInlineElements(WorkflowRulesEditorMarkdownInline inline)
private static UIElement CreateCodeBlock(WorkflowRulesEditorMarkdownCodeBlock codeBlock)
{
if (inline.Style == WorkflowRulesEditorMarkdownInlineStyle.Code)
var run = new Run(codeBlock.Text)
{
yield return CreateInlineCode(inline.Text);
yield break;
}
var text = new TextBlock()
.Text(inline.Text)
.TextWrapping(TextWrapping.Wrap)
.Foreground(Color.FromRgb(230, 235, 243));
if (inline.Style == WorkflowRulesEditorMarkdownInlineStyle.Bold)
FontFamily = new FontFamily("Consolas")
};
var paragraph = CreateParagraphBlock();
paragraph.Inlines.Add(run);
var document = CreateSelectableDocument([paragraph], CodeText);
return new Border
{
text.FontWeight(FontWeight.Bold);
}
else if (inline.Style == WorkflowRulesEditorMarkdownInlineStyle.Italic)
{
text.FontFamily("Segoe UI Italic");
}
yield return text;
}
private static IReadOnlyList<IReadOnlyList<WorkflowRulesEditorMarkdownInline>> SplitInlineLines(
IReadOnlyList<WorkflowRulesEditorMarkdownInline> inlines)
{
var lines = new List<IReadOnlyList<WorkflowRulesEditorMarkdownInline>>();
var current = new List<WorkflowRulesEditorMarkdownInline>();
foreach (var inline in inlines)
{
var parts = inline.Text.Split('\n');
for (var index = 0; index < parts.Length; index++)
{
if (parts[index].Length > 0)
{
current.Add(inline with { Text = parts[index] });
}
if (index < parts.Length - 1)
{
lines.Add(current);
current = [];
}
}
}
lines.Add(current);
return lines;
}
private static UIElement CreateInlineCode(string text)
{
return new Border()
.Padding(4, 1, 4, 2)
.Margin(1, 0, 1, 0)
.CornerRadius(4)
.BorderThickness(1)
.BorderBrush(Color.FromRgb(86, 94, 108))
.Background(Color.FromRgb(24, 28, 35))
.Child(new TextBlock()
.Text(text)
.FontFamily("Consolas")
.Foreground(Color.FromRgb(237, 241, 247)));
Padding = new Thickness(9, 7, 9, 7),
CornerRadius = new CornerRadius(6),
BorderThickness = new Thickness(1),
BorderBrush = CodeBorder,
Background = CodeBackground,
Child = document
};
}
private static UIElement CreateTable(WorkflowRulesEditorMarkdownTable table)
@@ -130,46 +152,330 @@ internal static class WorkflowRulesEditorMarkdownRenderer
var rows = table.Rows
.Select(row => new MarkdownTableRow(PadCells(row, table.Header.Count)))
.ToArray();
var gridView = new GridView()
.HeaderHeight(30)
.RowHeight(30)
.CellPadding(new Thickness(8, 4, 8, 4))
.ShowGridLines(true)
.ZebraStriping(true);
for (var index = 0; index < table.Header.Count; index++)
var grid = new Grid();
grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
for (var column = 0; column < table.Header.Count; column++)
{
var columnIndex = index;
gridView.AddColumn<MarkdownTableRow>(
table.Header[index],
GetMarkdownTableColumnWidth(table, index),
_ => new TextBlock()
.TextWrapping(TextWrapping.NoWrap)
.Foreground(Color.FromRgb(230, 235, 243)),
(element, row, _, _) => ((TextBlock)element).Text(row.GetCell(columnIndex)),
minWidth: 64,
resizable: true);
grid.ColumnDefinitions.Add(new ColumnDefinition
{
Width = new GridLength(GetMarkdownTableColumnWidth(table, column))
});
AddTableCell(grid, 0, column, table.Header[column], TableHeaderBackground, FontWeights.SemiBold);
}
return gridView
.ItemsSource(rows)
.MinWidth(Math.Min(900, table.Header.Count * 120))
.Height(Math.Min(420, 30 + Math.Max(1, rows.Length) * 30));
for (var row = 0; row < rows.Length; row++)
{
grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
for (var column = 0; column < table.Header.Count; column++)
{
AddTableCell(grid, row + 1, column, rows[row].GetCell(column), TableCellBackground, FontWeights.Normal);
}
}
return new ScrollViewer
{
Content = grid,
HorizontalScrollBarVisibility = ScrollBarVisibility.Auto,
VerticalScrollBarVisibility = ScrollBarVisibility.Disabled,
MaxHeight = 420
};
}
private static UIElement CreateCodeBlock(WorkflowRulesEditorMarkdownCodeBlock codeBlock)
private static RichTextBox CreateSelectableDocument(
IEnumerable<Block> blocks,
Brush? foreground = null,
double fontSize = 14)
{
return new Border()
.Padding(9)
.CornerRadius(6)
.BorderThickness(1)
.BorderBrush(Color.FromRgb(86, 94, 108))
.Background(Color.FromRgb(20, 24, 31))
.Child(new TextBlock()
.Text(codeBlock.Text)
.TextWrapping(TextWrapping.Wrap)
.FontFamily("Consolas")
.Foreground(Color.FromRgb(237, 241, 247)));
var document = new FlowDocument
{
PagePadding = new Thickness(0),
FontFamily = new FontFamily("Segoe UI"),
FontSize = fontSize,
Foreground = foreground ?? PrimaryText
};
foreach (var block in blocks)
{
document.Blocks.Add(block);
}
return new RichTextBox
{
Document = document,
IsReadOnly = true,
IsDocumentEnabled = true,
BorderThickness = new Thickness(0),
Background = Brushes.Transparent,
Foreground = foreground ?? PrimaryText,
Padding = new Thickness(0),
Margin = new Thickness(0),
MinHeight = 0,
HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled,
VerticalScrollBarVisibility = ScrollBarVisibility.Disabled
};
}
private static Paragraph CreateParagraphBlock(double lineHeight = 19)
{
return new Paragraph
{
Margin = new Thickness(0),
LineHeight = lineHeight
};
}
private static Paragraph CreateInlineParagraph(
IReadOnlyList<WorkflowRulesEditorMarkdownInline> inlines,
WorkflowRulesEditorMarkdownLinkResolver linkResolver,
double lineHeight = 19)
{
var paragraph = CreateParagraphBlock(lineHeight);
foreach (var inline in inlines)
{
paragraph.Inlines.Add(CreateInline(inline, linkResolver));
}
return paragraph;
}
private static Inline CreateInline(
WorkflowRulesEditorMarkdownInline inline,
WorkflowRulesEditorMarkdownLinkResolver linkResolver)
{
if (inline.Style == WorkflowRulesEditorMarkdownInlineStyle.Link)
{
return CreateLink(inline, linkResolver);
}
if (inline.Style == WorkflowRulesEditorMarkdownInlineStyle.Image)
{
return CreateImage(inline, linkResolver);
}
if (inline.Style == WorkflowRulesEditorMarkdownInlineStyle.Code)
{
return CreateInlineCode(inline.Text);
}
var run = new Run(inline.Text);
switch (inline.Style)
{
case WorkflowRulesEditorMarkdownInlineStyle.Bold:
run.FontWeight = FontWeights.SemiBold;
break;
case WorkflowRulesEditorMarkdownInlineStyle.BoldItalic:
run.FontWeight = FontWeights.SemiBold;
run.FontStyle = FontStyles.Italic;
break;
case WorkflowRulesEditorMarkdownInlineStyle.Italic:
run.FontStyle = FontStyles.Italic;
break;
case WorkflowRulesEditorMarkdownInlineStyle.Strikethrough:
run.TextDecorations = TextDecorations.Strikethrough;
break;
}
return run;
}
private static Inline CreateLink(
WorkflowRulesEditorMarkdownInline inline,
WorkflowRulesEditorMarkdownLinkResolver linkResolver)
{
var hyperlink = new Hyperlink(new Run(inline.Text))
{
Foreground = LinkText,
TextDecorations = TextDecorations.Underline,
ToolTip = inline.LinkTarget,
Cursor = Cursors.Hand
};
if (!string.IsNullOrWhiteSpace(inline.LinkTarget))
{
hyperlink.Click += (_, args) =>
{
OpenTarget(inline.LinkTarget, linkResolver);
args.Handled = true;
};
}
return hyperlink;
}
private static Inline CreateInlineCode(string text)
{
var code = new TextBlock
{
Text = text,
FontFamily = new FontFamily("Consolas"),
FontSize = 13,
Foreground = CodeText,
Background = CodeBackground,
Padding = new Thickness(0),
Margin = new Thickness(0),
LineHeight = 16,
VerticalAlignment = VerticalAlignment.Center
};
return new InlineUIContainer(new Border
{
BorderThickness = new Thickness(1),
BorderBrush = CodeBorder,
CornerRadius = new CornerRadius(4),
Background = CodeBackground,
Padding = new Thickness(4, 1, 4, 1),
Margin = new Thickness(1, 0, 1, 0),
Child = code
})
{
BaselineAlignment = BaselineAlignment.Center
};
}
private static Inline CreateImage(
WorkflowRulesEditorMarkdownInline inline,
WorkflowRulesEditorMarkdownLinkResolver linkResolver)
{
var preview = CreateImagePreview(inline.Text, inline.LinkTarget, linkResolver);
return new InlineUIContainer(preview)
{
BaselineAlignment = BaselineAlignment.Center
};
}
private static UIElement CreateImagePreview(
string altText,
string? target,
WorkflowRulesEditorMarkdownLinkResolver linkResolver)
{
var border = new Border
{
Width = 180,
Height = 112,
Margin = new Thickness(0, 4, 0, 4),
Padding = new Thickness(6),
BorderThickness = new Thickness(1),
BorderBrush = CodeBorder,
CornerRadius = new CornerRadius(6),
Background = ImagePlaceholderBackground,
ToolTip = target,
Cursor = Cursors.Hand
};
border.AddHandler(UIElement.MouseLeftButtonUpEvent, new MouseButtonEventHandler((_, args) =>
{
OpenTarget(target, linkResolver);
args.Handled = true;
}), handledEventsToo: true);
if (TryCreateBitmap(target, linkResolver, out var bitmap))
{
var image = new Image
{
Source = bitmap,
Stretch = Stretch.Uniform,
ToolTip = target
};
image.ImageFailed += (_, _) => border.Child = CreateImagePlaceholder(altText);
border.Child = image;
}
else
{
border.Child = CreateImagePlaceholder(altText);
}
return border;
}
private static UIElement CreateImagePlaceholder(string altText)
{
return new TextBlock
{
Text = string.IsNullOrWhiteSpace(altText) ? "Image" : altText,
Foreground = PrimaryText,
TextAlignment = TextAlignment.Center,
TextWrapping = TextWrapping.Wrap,
VerticalAlignment = VerticalAlignment.Center,
HorizontalAlignment = HorizontalAlignment.Center
};
}
private static bool TryCreateBitmap(
string? target,
WorkflowRulesEditorMarkdownLinkResolver linkResolver,
out BitmapImage bitmap)
{
bitmap = null!;
if (string.IsNullOrWhiteSpace(target) || !linkResolver.TryResolveImageUri(target, out var uri))
{
return false;
}
try
{
bitmap = new BitmapImage();
bitmap.BeginInit();
bitmap.CacheOption = BitmapCacheOption.OnLoad;
bitmap.UriSource = uri;
bitmap.DecodePixelWidth = 360;
bitmap.EndInit();
bitmap.Freeze();
return true;
}
catch
{
return false;
}
}
private static void OpenTarget(
string? target,
WorkflowRulesEditorMarkdownLinkResolver linkResolver)
{
if (string.IsNullOrWhiteSpace(target))
{
return;
}
try
{
var openTarget = linkResolver.ResolveOpenTarget(target);
if (string.IsNullOrWhiteSpace(openTarget))
{
return;
}
Process.Start(new ProcessStartInfo(openTarget)
{
UseShellExecute = true
});
}
catch
{
}
}
private static void AddTableCell(
Grid grid,
int row,
int column,
string text,
Brush background,
FontWeight fontWeight)
{
var paragraph = CreateParagraphBlock();
paragraph.Inlines.Add(new Run(text) { FontWeight = fontWeight });
var document = CreateSelectableDocument([paragraph], PrimaryText);
document.HorizontalScrollBarVisibility = ScrollBarVisibility.Hidden;
var border = new Border
{
Padding = new Thickness(8, 4, 8, 4),
BorderThickness = new Thickness(0, 0, 1, 1),
BorderBrush = CodeBorder,
Background = background,
Child = document
};
Grid.SetRow(border, row);
Grid.SetColumn(border, column);
grid.Children.Add(border);
}
private static IReadOnlyList<string> PadCells(IReadOnlyList<string> cells, int count)
@@ -0,0 +1,41 @@
#if WINDOWS
using System.Windows.Media;
namespace MeetingAssistant.Workflow;
internal static class WorkflowRulesEditorWpfTheme
{
public static readonly Brush WindowBackground = Brush(29, 31, 36);
public static readonly Brush UserCardBackground = Brush(26, 48, 78);
public static readonly Brush AgentCardBackground = Brush(35, 39, 46);
public static readonly Brush UserCardBorder = Brush(68, 95, 132);
public static readonly Brush AgentCardBorder = Brush(69, 75, 86);
public static readonly Brush PrimaryText = Brush(230, 235, 243);
public static readonly Brush MutedText = Brush(156, 166, 181);
public static readonly Brush InputBackground = Brush(20, 22, 26);
public static readonly Brush InputBorder = Brush(68, 95, 132);
public static readonly Brush InputFocusedBorder = Brush(84, 135, 214);
public static readonly Brush ButtonBackground = Brush(42, 46, 54);
public static readonly Brush ButtonHoverBackground = Brush(50, 56, 68);
public static readonly Brush ButtonPressedBackground = Brush(31, 36, 45);
public static readonly Brush DisabledButtonBackground = Brush(32, 35, 41);
public static readonly Brush DisabledButtonBorder = Brush(52, 57, 66);
public static readonly Brush DisabledText = Brush(115, 124, 139);
public static readonly Brush CodeText = Brush(237, 241, 247);
public static readonly Brush CodeBackground = Brush(20, 24, 31);
public static readonly Brush CodeBorder = Brush(86, 94, 108);
public static readonly Brush TableHeaderBackground = Brush(42, 46, 54);
public static readonly Brush TableCellBackground = Brush(31, 35, 42);
public static readonly Brush LinkText = Brush(119, 166, 255);
public static readonly Brush QuoteBorder = Brush(92, 111, 140);
public static readonly Brush QuoteBackground = Brush(31, 35, 43);
public static readonly Brush ImagePlaceholderBackground = Brush(24, 28, 35);
private static SolidColorBrush Brush(byte red, byte green, byte blue)
{
var brush = new SolidColorBrush(Color.FromRgb(red, green, blue));
brush.Freeze();
return brush;
}
}
#endif
@@ -0,0 +1,404 @@
#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 = "Settings and logs";
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 message in viewModel.Messages)
{
conversationPanel.Children.Add(CreateMessageCard(message));
}
if (viewModel.IsThinking)
{
conversationPanel.Children.Add(new TextBlock
{
Text = "Thinking...",
Foreground = MutedText,
Margin = new Thickness(4, 2, 4, 2)
});
}
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 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