Public Access
Improve settings logs agent and renderer
PR and Push Build/Test / build-and-test (push) Successful in 8m13s
PR and Push Build/Test / build-and-test (push) Successful in 8m13s
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user