You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
585 lines
18 KiB
585 lines
18 KiB
using System; |
|
using System.Collections.Generic; |
|
using System.ComponentModel; |
|
using System.Diagnostics; |
|
using System.Linq; |
|
using System.Runtime.CompilerServices; |
|
using System.Text; |
|
using System.Text.Json; |
|
using Avalonia; |
|
using Avalonia.Controls; |
|
using Avalonia.Data; |
|
using Avalonia.LogicalTree; |
|
using Avalonia.Media; |
|
using Avalonia.Threading; |
|
using AvaloniaEdit; |
|
using AvaloniaEdit.TextMate; |
|
using CommunityToolkit.Mvvm.Input; |
|
using FluentAvalonia.UI.Controls; |
|
using Markdown.Avalonia; |
|
using NLog; |
|
using Refit; |
|
using StabilityMatrix.Avalonia.Controls; |
|
using StabilityMatrix.Avalonia.Helpers; |
|
using StabilityMatrix.Core.Exceptions; |
|
using StabilityMatrix.Core.Extensions; |
|
using StabilityMatrix.Core.Models; |
|
using StabilityMatrix.Core.Services; |
|
using TextMateSharp.Grammars; |
|
using Process = FuzzySharp.Process; |
|
using StabilityMatrix.Avalonia.Languages; |
|
using StabilityMatrix.Avalonia.Models; |
|
using StabilityMatrix.Core.Helper; |
|
|
|
namespace StabilityMatrix.Avalonia; |
|
|
|
public static class DialogHelper |
|
{ |
|
private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); |
|
|
|
/// <summary> |
|
/// Create a generic textbox entry content dialog. |
|
/// </summary> |
|
public static BetterContentDialog CreateTextEntryDialog( |
|
string title, |
|
string description, |
|
IReadOnlyList<TextBoxField> textFields |
|
) |
|
{ |
|
Dispatcher.UIThread.VerifyAccess(); |
|
|
|
var stackPanel = new StackPanel(); |
|
var grid = new Grid |
|
{ |
|
RowDefinitions = |
|
{ |
|
new RowDefinition(GridLength.Auto), |
|
new RowDefinition(GridLength.Star) |
|
}, |
|
Children = |
|
{ |
|
new TextBlock { Text = description }, |
|
stackPanel |
|
} |
|
}; |
|
grid.Loaded += (_, _) => |
|
{ |
|
// Focus first textbox |
|
var firstTextBox = stackPanel.Children.OfType<TextBox>().First(); |
|
firstTextBox.Focus(); |
|
firstTextBox.CaretIndex = firstTextBox.Text?.LastIndexOf('.') ?? 0; |
|
}; |
|
|
|
// Disable primary button if any textboxes are invalid |
|
var primaryCommand = new RelayCommand( |
|
delegate { }, |
|
() => |
|
{ |
|
var invalidCount = textFields.Count(field => !field.IsValid); |
|
Debug.WriteLine($"Checking can execute: {invalidCount} invalid fields"); |
|
return invalidCount == 0; |
|
} |
|
); |
|
|
|
// Create textboxes |
|
foreach (var field in textFields) |
|
{ |
|
var label = new TextBlock { Text = field.Label }; |
|
stackPanel.Children.Add(label); |
|
|
|
var textBox = new TextBox |
|
{ |
|
[!TextBox.TextProperty] = new Binding("TextProperty"), |
|
Watermark = field.Watermark, |
|
DataContext = field, |
|
}; |
|
stackPanel.Children.Add(textBox); |
|
|
|
// When IsValid property changes, update invalid count and primary button |
|
field.PropertyChanged += (_, args) => |
|
{ |
|
if (args.PropertyName == nameof(TextBoxField.IsValid)) |
|
{ |
|
primaryCommand.NotifyCanExecuteChanged(); |
|
} |
|
}; |
|
|
|
// Set initial value |
|
textBox.Text = field.Text; |
|
|
|
// See if initial value is valid |
|
try |
|
{ |
|
field.Validator?.Invoke(field.Text); |
|
} |
|
catch (Exception) |
|
{ |
|
field.IsValid = false; |
|
} |
|
} |
|
|
|
return new BetterContentDialog |
|
{ |
|
Title = title, |
|
Content = grid, |
|
PrimaryButtonText = Resources.Action_OK, |
|
CloseButtonText = Resources.Action_Cancel, |
|
IsPrimaryButtonEnabled = true, |
|
PrimaryButtonCommand = primaryCommand, |
|
DefaultButton = ContentDialogButton.Primary |
|
}; |
|
} |
|
|
|
/// <summary> |
|
/// Create a generic dialog for showing a markdown document |
|
/// </summary> |
|
public static BetterContentDialog CreateMarkdownDialog( |
|
string markdown, |
|
string? title = null, |
|
TextEditorPreset editorPreset = default |
|
) |
|
{ |
|
Dispatcher.UIThread.VerifyAccess(); |
|
|
|
var viewer = new MarkdownScrollViewer { Markdown = markdown }; |
|
|
|
// Apply syntax highlighting to code blocks if preset is provided |
|
if (editorPreset != default) |
|
{ |
|
using var _ = CodeTimer.StartDebug(); |
|
|
|
var appliedCount = 0; |
|
|
|
if ( |
|
viewer.GetLogicalDescendants().FirstOrDefault()?.GetLogicalDescendants() is |
|
{ } stackDescendants |
|
) |
|
{ |
|
foreach (var editor in stackDescendants.OfType<TextEditor>()) |
|
{ |
|
TextEditorConfigs.Configure(editor, editorPreset); |
|
|
|
editor.FontFamily = "Cascadia Code,Consolas,Menlo,Monospace"; |
|
editor.Margin = new Thickness(0); |
|
editor.Padding = new Thickness(4); |
|
editor.IsEnabled = false; |
|
|
|
if (editor.GetLogicalParent() is Border border) |
|
{ |
|
border.BorderThickness = new Thickness(0); |
|
border.CornerRadius = new CornerRadius(4); |
|
} |
|
|
|
appliedCount++; |
|
} |
|
} |
|
|
|
Logger.Log( |
|
appliedCount > 0 ? LogLevel.Trace : LogLevel.Warn, |
|
$"Applied syntax highlighting to {appliedCount} code blocks" |
|
); |
|
} |
|
|
|
return new BetterContentDialog |
|
{ |
|
Title = title, |
|
Content = viewer, |
|
CloseButtonText = Resources.Action_Close, |
|
IsPrimaryButtonEnabled = false, |
|
}; |
|
} |
|
|
|
/// <summary> |
|
/// Create a dialog for displaying an ApiException |
|
/// </summary> |
|
public static BetterContentDialog CreateApiExceptionDialog( |
|
ApiException exception, |
|
string? title = null |
|
) |
|
{ |
|
Dispatcher.UIThread.VerifyAccess(); |
|
|
|
// Setup text editor |
|
var textEditor = new TextEditor |
|
{ |
|
IsReadOnly = true, |
|
WordWrap = true, |
|
Options = { ShowColumnRulers = false, AllowScrollBelowDocument = false } |
|
}; |
|
var registryOptions = new RegistryOptions(ThemeName.DarkPlus); |
|
textEditor |
|
.InstallTextMate(registryOptions) |
|
.SetGrammar(registryOptions.GetScopeByLanguageId("json")); |
|
|
|
var mainGrid = new StackPanel |
|
{ |
|
Spacing = 8, |
|
Margin = new Thickness(16), |
|
Children = |
|
{ |
|
new TextBlock |
|
{ |
|
Text = $"{(int)exception.StatusCode} - {exception.ReasonPhrase}", |
|
FontSize = 18, |
|
FontWeight = FontWeight.Medium, |
|
Margin = new Thickness(0, 8), |
|
}, |
|
textEditor |
|
} |
|
}; |
|
|
|
var dialog = new BetterContentDialog |
|
{ |
|
Title = title, |
|
Content = mainGrid, |
|
CloseButtonText = "Close", |
|
IsPrimaryButtonEnabled = false, |
|
}; |
|
|
|
// Try to deserialize to json element |
|
if (exception.Content != null) |
|
{ |
|
try |
|
{ |
|
// Deserialize to json element then re-serialize to ensure indentation |
|
var jsonElement = JsonSerializer.Deserialize<JsonElement>( |
|
exception.Content, |
|
new JsonSerializerOptions |
|
{ |
|
AllowTrailingCommas = true, |
|
ReadCommentHandling = JsonCommentHandling.Skip |
|
} |
|
); |
|
var formatted = JsonSerializer.Serialize( |
|
jsonElement, |
|
new JsonSerializerOptions() { WriteIndented = true } |
|
); |
|
|
|
textEditor.Document.Text = formatted; |
|
} |
|
catch (JsonException) |
|
{ |
|
// Otherwise just add the content as a code block |
|
textEditor.Document.Text = exception.Content; |
|
} |
|
} |
|
|
|
return dialog; |
|
} |
|
|
|
/// <summary> |
|
/// Create a dialog for displaying json |
|
/// </summary> |
|
public static BetterContentDialog CreateJsonDialog( |
|
string json, |
|
string? title = null, |
|
string? subTitle = null |
|
) |
|
{ |
|
Dispatcher.UIThread.VerifyAccess(); |
|
|
|
// Setup text editor |
|
var textEditor = new TextEditor |
|
{ |
|
IsReadOnly = true, |
|
WordWrap = true, |
|
Options = { ShowColumnRulers = false, AllowScrollBelowDocument = false } |
|
}; |
|
var registryOptions = new RegistryOptions(ThemeName.DarkPlus); |
|
textEditor |
|
.InstallTextMate(registryOptions) |
|
.SetGrammar(registryOptions.GetScopeByLanguageId("json")); |
|
|
|
var mainGrid = new StackPanel |
|
{ |
|
Spacing = 8, |
|
Margin = new Thickness(16), |
|
Children = { textEditor } |
|
}; |
|
|
|
if (subTitle is not null) |
|
{ |
|
mainGrid.Children.Insert( |
|
0, |
|
new TextBlock |
|
{ |
|
Text = subTitle, |
|
FontSize = 18, |
|
FontWeight = FontWeight.Medium, |
|
Margin = new Thickness(0, 8), |
|
} |
|
); |
|
} |
|
|
|
var dialog = new BetterContentDialog |
|
{ |
|
Title = title, |
|
Content = mainGrid, |
|
CloseButtonText = "Close", |
|
PrimaryButtonText = "Copy", |
|
IsPrimaryButtonEnabled = false, |
|
}; |
|
|
|
// Try to deserialize to json element |
|
try |
|
{ |
|
// Deserialize to json element then re-serialize to ensure indentation |
|
var jsonElement = JsonSerializer.Deserialize<JsonElement>( |
|
json, |
|
new JsonSerializerOptions |
|
{ |
|
AllowTrailingCommas = true, |
|
ReadCommentHandling = JsonCommentHandling.Skip |
|
} |
|
); |
|
var formatted = JsonSerializer.Serialize( |
|
jsonElement, |
|
new JsonSerializerOptions { WriteIndented = true } |
|
); |
|
|
|
textEditor.Document.Text = formatted; |
|
} |
|
catch (JsonException) |
|
{ |
|
// Otherwise just add the content as a code block |
|
textEditor.Document.Text = json; |
|
} |
|
|
|
dialog.PrimaryButtonCommand = new AsyncRelayCommand(async () => |
|
{ |
|
// Copy the json to clipboard |
|
var clipboard = App.Clipboard; |
|
await clipboard.SetTextAsync(textEditor.Document.Text); |
|
}); |
|
|
|
return dialog; |
|
} |
|
|
|
/// <summary> |
|
/// Create a dialog for displaying a prompt error |
|
/// </summary> |
|
/// <param name="exception">Target exception to display</param> |
|
/// <param name="sourceText">Full text of the target Document</param> |
|
/// <param name="modelIndexService">Optional model index service to look for similar names</param> |
|
public static BetterContentDialog CreatePromptErrorDialog( |
|
PromptError exception, |
|
string sourceText, |
|
IModelIndexService? modelIndexService = null |
|
) |
|
{ |
|
Dispatcher.UIThread.VerifyAccess(); |
|
|
|
var title = |
|
exception is PromptSyntaxError ? "Prompt Syntax Error" : "Prompt Validation Error"; |
|
|
|
// Get the index of the error |
|
var errorIndex = exception.TextOffset; |
|
|
|
// Get the line of error |
|
var total = 0; |
|
var errorLine = string.Empty; |
|
var errorLineNum = 0; |
|
var errorLineOffset = -1; |
|
var errorLineEndOffset = -1; |
|
foreach (var (i, line) in sourceText.Split(Environment.NewLine).Enumerate()) |
|
{ |
|
var lineLength = line.Length + Environment.NewLine.Length; |
|
if (total + lineLength > errorIndex) |
|
{ |
|
// Found, set the line text and number |
|
errorLine = line; |
|
errorLineNum = i + 1; |
|
// Calculate line offset of the error |
|
errorLineOffset = exception.TextOffset - total; |
|
// Calculate line offset of the end of the error |
|
errorLineEndOffset = exception.TextEndOffset - total; |
|
break; |
|
} |
|
total += lineLength; |
|
} |
|
|
|
// Format the error line |
|
var errorLineFormattedBuilder = new StringBuilder(); |
|
// Add line number |
|
var errorLinePrefix = $"[{errorLineNum}] "; |
|
errorLineFormattedBuilder.AppendLine(errorLinePrefix + errorLine); |
|
// Add error indicator at line offset |
|
errorLineFormattedBuilder.Append(' ', errorLinePrefix.Length + errorLineOffset); |
|
errorLineFormattedBuilder.Append('^', errorLineEndOffset - errorLineOffset); |
|
var errorLineFormatted = errorLineFormattedBuilder.ToString(); |
|
|
|
// Setup text editor |
|
var textEditor = new TextEditor |
|
{ |
|
IsReadOnly = true, |
|
WordWrap = false, |
|
IsEnabled = false, |
|
ShowLineNumbers = false, |
|
FontFamily = "Cascadia Code,Consolas,Menlo,Monospace", |
|
FontSize = 15, |
|
Options = |
|
{ |
|
HighlightCurrentLine = true, |
|
ShowColumnRulers = false, |
|
AllowScrollBelowDocument = false |
|
} |
|
}; |
|
TextEditorConfigs.ConfigForPrompt(textEditor); |
|
|
|
textEditor.Document.Text = errorLineFormatted; |
|
textEditor.TextArea.Caret.Offset = textEditor.Document.Lines[0].EndOffset; |
|
|
|
var mainGrid = new StackPanel |
|
{ |
|
Spacing = 8, |
|
Margin = new Thickness(16), |
|
Children = |
|
{ |
|
new TextBlock |
|
{ |
|
Text = |
|
$"{exception.Message} - at line {errorLineNum} [{errorLineOffset}:{errorLineEndOffset}]", |
|
FontSize = 18, |
|
FontWeight = FontWeight.Medium, |
|
Margin = new Thickness(0, 8), |
|
}, |
|
textEditor |
|
} |
|
}; |
|
|
|
// Check model typos |
|
if (modelIndexService is not null && exception is PromptUnknownModelError unknownModelError) |
|
{ |
|
var sharedFolderType = unknownModelError.ModelType.ConvertTo<SharedFolderType>(); |
|
if (modelIndexService.ModelIndex.TryGetValue(sharedFolderType, out var models)) |
|
{ |
|
var result = Process.ExtractOne( |
|
unknownModelError.ModelName, |
|
models.Select(m => m.FileNameWithoutExtension) |
|
); |
|
|
|
if (result.Score > 40) |
|
{ |
|
mainGrid.Children.Add( |
|
new InfoBar |
|
{ |
|
Title = $"Did you mean: {result.Value}?", |
|
IsClosable = false, |
|
IsOpen = true |
|
} |
|
); |
|
} |
|
} |
|
} |
|
|
|
textEditor.ScrollToHorizontalOffset(errorLineEndOffset - errorLineOffset); |
|
|
|
var dialog = new BetterContentDialog |
|
{ |
|
Title = title, |
|
Content = mainGrid, |
|
CloseButtonText = "Close", |
|
IsPrimaryButtonEnabled = false, |
|
}; |
|
|
|
return dialog; |
|
} |
|
|
|
/// <summary> |
|
/// Create a simple title and description task dialog. |
|
/// Sets the XamlRoot to the current top level window. |
|
/// </summary> |
|
public static TaskDialog CreateTaskDialog(string title, string description) |
|
{ |
|
Dispatcher.UIThread.VerifyAccess(); |
|
|
|
var content = new StackPanel |
|
{ |
|
Children = |
|
{ |
|
new TextBlock |
|
{ |
|
Margin = new Thickness(0, 0, 0, 8), |
|
FontSize = 16, |
|
Text = title, |
|
TextWrapping = TextWrapping.WrapWithOverflow, |
|
}, |
|
new TextBlock { Text = description, TextWrapping = TextWrapping.WrapWithOverflow, } |
|
} |
|
}; |
|
|
|
return new TaskDialog |
|
{ |
|
Title = title, |
|
Content = content, |
|
XamlRoot = App.VisualRoot |
|
}; |
|
} |
|
|
|
/// <summary> |
|
/// Creates a connection help dialog. |
|
/// </summary> |
|
public static BetterContentDialog CreateConnectionHelpDialog() |
|
{ |
|
// TODO |
|
return new BetterContentDialog(); |
|
} |
|
} |
|
|
|
// Text fields |
|
public sealed class TextBoxField : INotifyPropertyChanged |
|
{ |
|
// Label above the textbox |
|
public string Label { get; init; } = string.Empty; |
|
|
|
// Actual text value |
|
public string Text { get; set; } = string.Empty; |
|
|
|
// Watermark text |
|
public string Watermark { get; init; } = string.Empty; |
|
|
|
/// <summary> |
|
/// Validation action on text changes. Throw exception if invalid. |
|
/// </summary> |
|
public Action<string>? Validator { get; init; } |
|
|
|
public string TextProperty |
|
{ |
|
get => Text; |
|
[DebuggerStepThrough] |
|
set |
|
{ |
|
try |
|
{ |
|
Validator?.Invoke(value); |
|
} |
|
catch (Exception e) |
|
{ |
|
IsValid = false; |
|
throw new DataValidationException(e.Message); |
|
} |
|
Text = value; |
|
IsValid = true; |
|
OnPropertyChanged(); |
|
} |
|
} |
|
|
|
// Default to true if no validator is provided |
|
private bool isValid; |
|
public bool IsValid |
|
{ |
|
get => Validator == null || isValid; |
|
set |
|
{ |
|
isValid = value; |
|
OnPropertyChanged(); |
|
} |
|
} |
|
|
|
public event PropertyChangedEventHandler? PropertyChanged; |
|
|
|
private void OnPropertyChanged([CallerMemberName] string? propertyName = null) |
|
{ |
|
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); |
|
} |
|
}
|
|
|