From 5f035a5050bc9feeec212648572922619ef72017 Mon Sep 17 00:00:00 2001 From: corecompiled <285886213+corecompiled@users.noreply.github.com> Date: Mon, 3 Aug 2026 06:21:17 +0800 Subject: [PATCH 1/5] feat: Avalonia GUI over the same engine (Phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A console window is intimidating to exactly the audience the product names — someone who double-clicks an exe off a USB stick. This adds a windowed host without touching the engine underneath. The layering paid off here. OpenKey.Gui contains view models and views and nothing else: no chat logic, no rotation, no persistence, no provider code. Composition in Program.cs is line-for-line the console's, because the GUI is a second host over ChatEngine rather than a second implementation of it. One refactor was forced and is the right one anyway. DPAPI key storage, app paths, the OAuth flow and the tokenizer all lived inside the console exe, and the GUI needs every one of them. They move to a new OpenKey.Windows project that both hosts reference. OpenKey.Core still has zero package references and still targets net10.0 rather than net10.0-windows, so the platform-neutral layer stays platform-neutral. The GUI keeps the console's rules rather than inventing its own: - Streaming renders at block granularity, so a code fence becomes a panel once it closes while prose keeps flowing. - IsAttemptRestart clears the in-progress reply, so a mid-reply model switch does not show the answer twice. - Errors say what happened and what to do next; no ChatErrorKind names and no raw exception text reach the window. - Erase-everything states exactly what is lost, defaults to Cancel, and puts focus on Cancel so a stray Enter cannot destroy a key. - One accent, one neutral, three signals — colour carries meaning. Enter sends and Shift+Enter adds a line, which is the way round people expect. Auto-scroll follows a streaming reply only when the view is already at the bottom, so it cannot yank the page while someone reads back. MarkdownBlock is a plain record with no Avalonia types in its parsing path, which is what makes 16 tests possible without starting a window. Three binding defects were caught that way and by reading the compiled XAML: the model picker rendered a record's ToString instead of its name, a collection count was bound to a bool, and a bare number bound to Margin would have indented list items on all four sides. Publishes AOT like the console. Not wired into the release workflow yet — it wants a human to look at it first. 103 tests. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01WGcarVYpt1mfjk6iFkcDpZ --- OpenKey.slnx | 3 + src/OpenKey.Gui/App.axaml | 25 ++ src/OpenKey.Gui/App.axaml.cs | 29 ++ .../Converters/StatusBrushConverter.cs | 30 ++ src/OpenKey.Gui/OpenKey.Gui.csproj | 36 ++ src/OpenKey.Gui/Program.cs | 67 +++ .../ViewModels/MainWindowViewModel.cs | 391 ++++++++++++++++++ src/OpenKey.Gui/ViewModels/MarkdownBlock.cs | 183 ++++++++ .../ViewModels/MessageViewModel.cs | 115 ++++++ .../ViewModels/ObservableObject.cs | 28 ++ src/OpenKey.Gui/Views/ConfirmWindow.axaml | 27 ++ src/OpenKey.Gui/Views/ConfirmWindow.axaml.cs | 26 ++ src/OpenKey.Gui/Views/MainWindow.axaml | 217 ++++++++++ src/OpenKey.Gui/Views/MainWindow.axaml.cs | 76 ++++ src/OpenKey.Gui/app.manifest | 13 + src/{OpenKey => OpenKey.Windows}/AppPaths.cs | 2 +- .../DpapiKeyStore.cs | 2 +- .../OAuth/OAuthPortInUseException.cs | 2 +- .../OAuth/OAuthWire.cs | 2 +- .../OAuth/OpenRouterOAuth.cs | 2 +- .../OAuth/PkceCodes.cs | 2 +- src/OpenKey.Windows/OpenKey.Windows.csproj | 32 ++ .../TiktokenCounter.cs | 4 +- src/OpenKey/ConsoleHost.cs | 3 +- src/OpenKey/OpenKey.csproj | 1 + src/OpenKey/Program.cs | 1 + tests/OpenKey.Gui.Tests/MarkdownBlockTests.cs | 175 ++++++++ .../OpenKey.Gui.Tests.csproj | 19 + tests/OpenKey.Tests/OpenKey.Tests.csproj | 1 + tests/OpenKey.Tests/PkceCodesTests.cs | 2 +- 30 files changed, 1506 insertions(+), 10 deletions(-) create mode 100644 src/OpenKey.Gui/App.axaml create mode 100644 src/OpenKey.Gui/App.axaml.cs create mode 100644 src/OpenKey.Gui/Converters/StatusBrushConverter.cs create mode 100644 src/OpenKey.Gui/OpenKey.Gui.csproj create mode 100644 src/OpenKey.Gui/Program.cs create mode 100644 src/OpenKey.Gui/ViewModels/MainWindowViewModel.cs create mode 100644 src/OpenKey.Gui/ViewModels/MarkdownBlock.cs create mode 100644 src/OpenKey.Gui/ViewModels/MessageViewModel.cs create mode 100644 src/OpenKey.Gui/ViewModels/ObservableObject.cs create mode 100644 src/OpenKey.Gui/Views/ConfirmWindow.axaml create mode 100644 src/OpenKey.Gui/Views/ConfirmWindow.axaml.cs create mode 100644 src/OpenKey.Gui/Views/MainWindow.axaml create mode 100644 src/OpenKey.Gui/Views/MainWindow.axaml.cs create mode 100644 src/OpenKey.Gui/app.manifest rename src/{OpenKey => OpenKey.Windows}/AppPaths.cs (89%) rename src/{OpenKey => OpenKey.Windows}/DpapiKeyStore.cs (98%) rename src/{OpenKey => OpenKey.Windows}/OAuth/OAuthPortInUseException.cs (94%) rename src/{OpenKey => OpenKey.Windows}/OAuth/OAuthWire.cs (95%) rename src/{OpenKey => OpenKey.Windows}/OAuth/OpenRouterOAuth.cs (99%) rename src/{OpenKey => OpenKey.Windows}/OAuth/PkceCodes.cs (96%) create mode 100644 src/OpenKey.Windows/OpenKey.Windows.csproj rename src/{OpenKey => OpenKey.Windows}/TiktokenCounter.cs (95%) create mode 100644 tests/OpenKey.Gui.Tests/MarkdownBlockTests.cs create mode 100644 tests/OpenKey.Gui.Tests/OpenKey.Gui.Tests.csproj diff --git a/OpenKey.slnx b/OpenKey.slnx index 5d0abba..fb2104b 100644 --- a/OpenKey.slnx +++ b/OpenKey.slnx @@ -1,12 +1,15 @@ + + + diff --git a/src/OpenKey.Gui/App.axaml b/src/OpenKey.Gui/App.axaml new file mode 100644 index 0000000..68fb11a --- /dev/null +++ b/src/OpenKey.Gui/App.axaml @@ -0,0 +1,25 @@ + + + + + + + + + #22D3EE + + + + + + + + + + + + diff --git a/src/OpenKey.Gui/App.axaml.cs b/src/OpenKey.Gui/App.axaml.cs new file mode 100644 index 0000000..0ddbc02 --- /dev/null +++ b/src/OpenKey.Gui/App.axaml.cs @@ -0,0 +1,29 @@ +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Markup.Xaml; +using Microsoft.Extensions.DependencyInjection; +using OpenKey.Gui.ViewModels; +using OpenKey.Gui.Views; + +namespace OpenKey.Gui; + +public partial class App : Application +{ + public override void Initialize() => AvaloniaXamlLoader.Load(this); + + public override void OnFrameworkInitializationCompleted() + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + var vm = Program.Services.GetRequiredService(); + desktop.MainWindow = new MainWindow { DataContext = vm }; + + // Start-up work (loading the key, restoring the conversation, fetching models) happens + // after the window is up, so the user sees the app immediately rather than a delay + // followed by a window. + desktop.MainWindow.Opened += async (_, _) => await vm.InitializeAsync(); + } + + base.OnFrameworkInitializationCompleted(); + } +} diff --git a/src/OpenKey.Gui/Converters/StatusBrushConverter.cs b/src/OpenKey.Gui/Converters/StatusBrushConverter.cs new file mode 100644 index 0000000..3dacfe9 --- /dev/null +++ b/src/OpenKey.Gui/Converters/StatusBrushConverter.cs @@ -0,0 +1,30 @@ +using System.Globalization; +using Avalonia.Data.Converters; +using Avalonia.Media; +using OpenKey.Gui.ViewModels; + +namespace OpenKey.Gui.Converters; + +/// +/// Maps a status severity to its accent colour. Kept in one place for the same reason the console +/// keeps every colour in Theme: a brush chosen at a call site is how a palette drifts. +/// +public sealed class StatusBrushConverter : IValueConverter +{ + private static readonly SolidColorBrush Info = new(Color.Parse("#22D3EE")); + private static readonly SolidColorBrush Ok = new(Color.Parse("#4ADE80")); + private static readonly SolidColorBrush Warn = new(Color.Parse("#FBBF24")); + private static readonly SolidColorBrush Danger = new(Color.Parse("#F87171")); + + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) => + (value as StatusKind?) switch + { + StatusKind.Ok => Ok, + StatusKind.Warn => Warn, + StatusKind.Danger => Danger, + _ => Info, + }; + + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) => + throw new NotSupportedException(); +} diff --git a/src/OpenKey.Gui/OpenKey.Gui.csproj b/src/OpenKey.Gui/OpenKey.Gui.csproj new file mode 100644 index 0000000..a99f14c --- /dev/null +++ b/src/OpenKey.Gui/OpenKey.Gui.csproj @@ -0,0 +1,36 @@ + + + WinExe + net10.0-windows + OpenKey.Gui + OpenKeyApp + win-x64;win-arm64 + false + app.manifest + true + + + true + true + + + true + enable + + + + + + + + + + + + + + + + + diff --git a/src/OpenKey.Gui/Program.cs b/src/OpenKey.Gui/Program.cs new file mode 100644 index 0000000..e024b44 --- /dev/null +++ b/src/OpenKey.Gui/Program.cs @@ -0,0 +1,67 @@ +using Avalonia; +using Microsoft.Extensions.DependencyInjection; +using OpenKey.Core.AppPaths; +using OpenKey.Core.Engine; +using OpenKey.Core.Providers; +using OpenKey.Core.Storage; +using OpenKey.Gui.ViewModels; +using OpenKey.Providers.OpenRouter; +using OpenKey.Windows; + +namespace OpenKey.Gui; + +internal static class Program +{ + /// + /// Composition is identical to the console host's, and deliberately so: the GUI is a second + /// host over the same engine, not a second implementation. Everything below the view models + /// is shared, which is what makes this project small. + /// + public static IServiceProvider Services { get; private set; } = default!; + + [STAThread] + public static int Main(string[] args) + { + var services = new ServiceCollection(); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + // Infinite on purpose — see the console host's Program.cs. HttpClient.Timeout bounds the + // whole response including the body, which silently aborts long streamed replies; the + // provider applies per-read deadlines instead. + services.AddSingleton(_ => new HttpClient { Timeout = Timeout.InfiniteTimeSpan }); + + services.AddSingleton(sp => + new OpenRouterProvider( + sp.GetRequiredService(), + sp.GetRequiredService().Load)); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + var provider = services.BuildServiceProvider(); + Services = provider; + + try + { + return BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); + } + finally + { + provider.Dispose(); + } + } + + // Referenced by name by the Avalonia designer tooling; keep the signature. + public static AppBuilder BuildAvaloniaApp() => + AppBuilder.Configure() + .UsePlatformDetect() + .WithInterFont() + .LogToTrace(); +} diff --git a/src/OpenKey.Gui/ViewModels/MainWindowViewModel.cs b/src/OpenKey.Gui/ViewModels/MainWindowViewModel.cs new file mode 100644 index 0000000..9c45d63 --- /dev/null +++ b/src/OpenKey.Gui/ViewModels/MainWindowViewModel.cs @@ -0,0 +1,391 @@ +using System.Collections.ObjectModel; +using System.Diagnostics; +using System.Reflection; +using Avalonia.Threading; +using OpenKey.Core.AppPaths; +using OpenKey.Core.Engine; +using OpenKey.Core.Providers; +using OpenKey.Core.Storage; +using OpenKey.Providers.OpenRouter; +using OpenKey.Windows.OAuth; + +namespace OpenKey.Gui.ViewModels; + +public sealed class MainWindowViewModel : ObservableObject +{ + private readonly ChatEngine _engine; + private readonly IKeyStore _keys; + private readonly IModelCatalog _catalog; + private readonly IRotationPolicy _rotation; + private readonly IConfigStore _config; + private readonly IAppPaths _paths; + private readonly HttpClient _http; + + private CancellationTokenSource? _turnCts; + private string _draft = string.Empty; + private bool _isBusy; + private bool _needsKey; + private string? _status; + private StatusKind _statusKind; + private string _keyInput = string.Empty; + private bool _isSigningIn; + + public MainWindowViewModel( + ChatEngine engine, + IKeyStore keys, + IModelCatalog catalog, + IRotationPolicy rotation, + IConfigStore config, + IAppPaths paths, + HttpClient http) + { + _engine = engine; + _keys = keys; + _catalog = catalog; + _rotation = rotation; + _config = config; + _paths = paths; + _http = http; + + Messages.CollectionChanged += (_, _) => Raise(nameof(IsConversationEmpty)); + } + + public ObservableCollection Messages { get; } = new(); + + /// Drives the empty-state prompt. A bare Count cannot bind to IsVisible. + public bool IsConversationEmpty => Messages.Count == 0; + + public ObservableCollection Models { get; } = new(); + + public string Version { get; } = + (typeof(MainWindowViewModel).Assembly + .GetCustomAttribute()?.InformationalVersion + ?? "dev") is var v && v.IndexOf('+', StringComparison.Ordinal) > 0 + ? v[..v.IndexOf('+', StringComparison.Ordinal)] + : v; + + public string DataDirectory => _paths.RootDir; + + public string Draft + { + get => _draft; + set { if (Set(ref _draft, value)) Raise(nameof(CanSend)); } + } + + /// True while a reply is in flight. Drives the send/stop button and input state. + public bool IsBusy + { + get => _isBusy; + private set + { + if (!Set(ref _isBusy, value)) return; + Raise(nameof(CanSend)); + Raise(nameof(IsIdle)); + } + } + + public bool IsIdle => !_isBusy; + + public bool CanSend => !_isBusy && !string.IsNullOrWhiteSpace(_draft); + + /// Shows the sign-in panel instead of the chat view. + public bool NeedsKey + { + get => _needsKey; + private set { if (Set(ref _needsKey, value)) Raise(nameof(IsReady)); } + } + + public bool IsReady => !_needsKey; + + public string KeyInput + { + get => _keyInput; + set => Set(ref _keyInput, value); + } + + public bool IsSigningIn + { + get => _isSigningIn; + private set => Set(ref _isSigningIn, value); + } + + public string? Status + { + get => _status; + private set { if (Set(ref _status, value)) Raise(nameof(HasStatus)); } + } + + public bool HasStatus => !string.IsNullOrEmpty(_status); + + public StatusKind StatusKind + { + get => _statusKind; + private set => Set(ref _statusKind, value); + } + + public string ActiveModel => _engine.ActiveModel?.Id ?? "Choosing automatically"; + + public async Task InitializeAsync() + { + if (!_keys.HasKey() || string.IsNullOrEmpty(_keys.Load())) + { + NeedsKey = true; + return; + } + + NeedsKey = false; + await _engine.ResumeAsync(CancellationToken.None); + + foreach (var turn in _engine.Turns.Where(t => t.Role != ChatMessage.SystemRole)) + { + Messages.Add(new MessageViewModel( + turn.Role == ChatMessage.UserRole ? Speaker.You : Speaker.Assistant, + turn.Content)); + } + + await LoadModelsAsync(); + } + + public async Task LoadModelsAsync() + { + try + { + var models = await _catalog.GetFreeModelsAsync(CancellationToken.None); + Models.Clear(); + foreach (var m in models) Models.Add(m); + } + catch (ChatException ex) + { + Show(StatusKind.Warn, Friendly(ex)); + } + } + + // ---- sign in ------------------------------------------------------------------------- + + public async Task SignInWithBrowserAsync() + { + IsSigningIn = true; + Show(StatusKind.Info, "Opening your browser to sign in to OpenRouter…"); + + try + { + var oauth = new OpenRouterOAuth(_http); + var key = await oauth.AcquireKeyAsync(_ => { }, CancellationToken.None); + await ValidateAndSaveAsync(key); + } + catch (OAuthPortInUseException) + { + Show(StatusKind.Warn, + "OpenKey needs a local port for a moment to receive the sign-in, and they're all in use. " + + "Paste a key below instead, or close the other app and try again."); + } + catch (ChatException ex) + { + Show(StatusKind.Warn, Friendly(ex)); + } + catch (OperationCanceledException) + { + Show(StatusKind.Info, "Sign-in cancelled."); + } + finally + { + IsSigningIn = false; + } + } + + public async Task UseTypedKeyAsync() + { + if (string.IsNullOrWhiteSpace(KeyInput)) + { + Show(StatusKind.Warn, "Paste a key to continue."); + return; + } + + IsSigningIn = true; + try + { + await ValidateAndSaveAsync(KeyInput.Trim()); + } + finally + { + IsSigningIn = false; + } + } + + private async Task ValidateAndSaveAsync(string key) + { + Show(StatusKind.Info, "Checking your key…"); + try + { + var probe = new OpenRouterProvider(_http, () => key); + var models = await probe.ListModelsAsync(CancellationToken.None); + if (models.Count == 0) + { + Show(StatusKind.Warn, "The key worked, but OpenRouter returned no models. This is usually temporary."); + return; + } + + _keys.Save(key); + KeyInput = string.Empty; + Show(StatusKind.Ok, "Key saved. You're ready to chat."); + NeedsKey = false; + await InitializeAsync(); + } + catch (ChatException ex) + { + Show(StatusKind.Warn, Friendly(ex)); + } + } + + // ---- chatting ------------------------------------------------------------------------ + + public async Task SendAsync() + { + if (!CanSend) return; + + var text = Draft.TrimEnd(); + Draft = string.Empty; + Status = null; + + Messages.Add(new MessageViewModel(Speaker.You, text)); + + var reply = new MessageViewModel(Speaker.Assistant) { IsStreaming = true }; + Messages.Add(reply); + + var cts = new CancellationTokenSource(); + Volatile.Write(ref _turnCts, cts); + IsBusy = true; + + var started = Stopwatch.StartNew(); + var rotations = 0; + void CountRotation(string _) => rotations++; + _engine.OnRotation += CountRotation; + + try + { + await foreach (var chunk in _engine.SendAsync(text, cts.Token)) + { + if (chunk.IsAttemptRestart) + { + // The engine abandoned that attempt; everything shown belongs to it. + rotations++; + await Dispatcher.UIThread.InvokeAsync(reply.Reset); + continue; + } + + if (!string.IsNullOrEmpty(chunk.DeltaText)) + { + await Dispatcher.UIThread.InvokeAsync(() => + { + reply.Append(chunk.DeltaText); + reply.ModelId = _engine.ActiveModel?.Id; + reply.Elapsed = started.Elapsed; + }); + } + + if (chunk.IsFinal) break; + } + + reply.Elapsed = started.Elapsed; + reply.ModelId = _engine.ActiveModel?.Id; + + if (!reply.HasText) + Show(StatusKind.Warn, "The model accepted the message but returned nothing. Try sending it again."); + else if (rotations > 0) + Show(StatusKind.Info, $"Moved past {rotations} busy model{(rotations == 1 ? "" : "s")}."); + } + catch (OperationCanceledException) when (cts.IsCancellationRequested) + { + if (!reply.HasText) Messages.Remove(reply); + Show(StatusKind.Info, "Stopped."); + } + catch (ChatException ex) + { + if (!reply.HasText) Messages.Remove(reply); + Show(StatusKind.Danger, Friendly(ex)); + } + finally + { + _engine.OnRotation -= CountRotation; + reply.Complete(); + Volatile.Write(ref _turnCts, null); + cts.Dispose(); + IsBusy = false; + Raise(nameof(ActiveModel)); + } + } + + public void Stop() => Volatile.Read(ref _turnCts)?.Cancel(); + + public async Task NewConversationAsync() + { + if (IsBusy) return; + await _engine.NewSessionAsync(CancellationToken.None); + Messages.Clear(); + Show(StatusKind.Ok, "Started a new conversation. Your key is untouched."); + } + + public void PinModel(ModelInfo? model) + { + _engine.PreferredModelId = model?.Id; + Show(StatusKind.Ok, model is null + ? "OpenKey will pick the best available model for each message." + : $"Now using {model.DisplayName}."); + Raise(nameof(ActiveModel)); + } + + /// + /// Erases everything, matching the console's /reset. The view is responsible for + /// confirming first — this states plainly what it costs but does not ask. + /// + public async Task ResetEverythingAsync() + { + await _engine.NewSessionAsync(CancellationToken.None); + _rotation.Clear(); + _catalog.ClearCache(); + _keys.Clear(); + _config.Save(OpenKeyConfig.Default); + + Messages.Clear(); + Models.Clear(); + NeedsKey = true; + Show(StatusKind.Ok, "Everything was erased. Sign in again to continue."); + } + + // ---- status -------------------------------------------------------------------------- + + private void Show(StatusKind kind, string message) + { + StatusKind = kind; + Status = message; + } + + public void DismissStatus() => Status = null; + + /// + /// Same rule as the console: say what happened and what to do next. Raw error-kind names and + /// exception text never reach the window. + /// + private static string Friendly(ChatException ex) => ex.Kind switch + { + ChatErrorKind.AuthFailure => + "OpenRouter refused the saved key. Use Erase everything to sign in again — that also clears your chat history.", + ChatErrorKind.QuotaExhausted => + "This key is out of credit. Wait for your free allowance to renew, or add credit at openrouter.ai.", + ChatErrorKind.NetworkDown => + ex.Message + " Check your connection and try again.", + ChatErrorKind.TransientRateLimit => + "Every free model is busy right now. Wait a moment and send again, or pick a specific model.", + ChatErrorKind.InvalidRequest => + "The model refused this message — it may be too long. Try a shorter one, or pick a different model.", + _ => "That didn't go through. Send it again, or pick a different model.", + }; +} + +public enum StatusKind +{ + Info, + Ok, + Warn, + Danger, +} diff --git a/src/OpenKey.Gui/ViewModels/MarkdownBlock.cs b/src/OpenKey.Gui/ViewModels/MarkdownBlock.cs new file mode 100644 index 0000000..12e7502 --- /dev/null +++ b/src/OpenKey.Gui/ViewModels/MarkdownBlock.cs @@ -0,0 +1,183 @@ +using System.Text; +using Markdig; +using Markdig.Syntax; +using Markdig.Syntax.Inlines; + +namespace OpenKey.Gui.ViewModels; + +public enum BlockKind +{ + Paragraph, + Heading, + Code, + Quote, + ListItem, + Rule, +} + +/// +/// A renderable piece of a reply. +/// +/// Deliberately a flat, plain model rather than a tree of Avalonia controls. The view binds to +/// these, so parsing stays free of UI types and is testable without a window — the same separation +/// the console keeps between MarkdownConsoleRenderer and the engine. +/// +/// +/// Inline formatting is flattened to text in this version. Code fences and structure carry most of +/// the readability benefit; inline bold and italic can come later without changing this shape. +/// +/// +public sealed record MarkdownBlock(BlockKind Kind, string Text, string? Language = null, int Level = 0, int Indent = 0) +{ + public bool IsCode => Kind == BlockKind.Code; + public bool IsRule => Kind == BlockKind.Rule; + public bool IsNotCode => Kind != BlockKind.Code && Kind != BlockKind.Rule; + + public double FontSize => Kind switch + { + BlockKind.Heading when Level <= 1 => 21, + BlockKind.Heading when Level == 2 => 18, + BlockKind.Heading => 16, + _ => 14, + }; + + public bool IsHeading => Kind == BlockKind.Heading; + public bool IsQuote => Kind == BlockKind.Quote; + + /// + /// A Thickness, not a double: binding a bare number to Margin indents all four sides, which + /// pushes list items down the page as well as across. + /// + public Avalonia.Thickness LeftMargin => + Kind == BlockKind.ListItem ? new Avalonia.Thickness(16 + (Indent * 16), 0, 0, 0) : default; + + public static IReadOnlyList Parse(string markdown) + { + var blocks = new List(); + if (string.IsNullOrWhiteSpace(markdown)) return blocks; + + try + { + var doc = Markdown.Parse(markdown); + foreach (var block in doc) Walk(block, blocks, indent: 0); + } + catch (Exception) + { + // Never let a parse failure lose the reply — fall back to showing it verbatim. + blocks.Clear(); + blocks.Add(new MarkdownBlock(BlockKind.Paragraph, markdown)); + } + + return blocks; + } + + private static void Walk(Block block, List into, int indent) + { + switch (block) + { + case HeadingBlock h: + into.Add(new MarkdownBlock(BlockKind.Heading, Inline(h.Inline), Level: h.Level)); + break; + + case FencedCodeBlock fenced: + into.Add(new MarkdownBlock( + BlockKind.Code, + fenced.Lines.ToString().TrimEnd('\n', '\r'), + Language: string.IsNullOrWhiteSpace(fenced.Info) ? null : fenced.Info.Trim().ToLowerInvariant())); + break; + + case CodeBlock code: + into.Add(new MarkdownBlock(BlockKind.Code, code.Lines.ToString().TrimEnd('\n', '\r'))); + break; + + case QuoteBlock quote: + foreach (var child in quote) + { + if (child is LeafBlock lb && lb.Inline is not null) + into.Add(new MarkdownBlock(BlockKind.Quote, Inline(lb.Inline))); + else + Walk(child, into, indent); + } + break; + + case ListBlock list: + { + var n = int.TryParse(list.OrderedStart, out var start) ? start : 1; + foreach (var item in list) + { + if (item is not ListItemBlock li) continue; + var marker = list.IsOrdered ? $"{n}." : "•"; + n++; + + var first = true; + foreach (var child in li) + { + if (first && child is ParagraphBlock p) + { + into.Add(new MarkdownBlock( + BlockKind.ListItem, $"{marker} {Inline(p.Inline)}", Indent: indent)); + first = false; + } + else + { + Walk(child, into, indent + 1); + } + } + } + break; + } + + case ThematicBreakBlock: + into.Add(new MarkdownBlock(BlockKind.Rule, string.Empty)); + break; + + case ParagraphBlock p2: + into.Add(new MarkdownBlock(BlockKind.Paragraph, Inline(p2.Inline))); + break; + + case ContainerBlock container: + foreach (var child in container) Walk(child, into, indent); + break; + + case LeafBlock leaf when leaf.Inline is not null: + into.Add(new MarkdownBlock(BlockKind.Paragraph, Inline(leaf.Inline))); + break; + } + } + + private static string Inline(ContainerInline? container) + { + if (container is null) return string.Empty; + var sb = new StringBuilder(); + Append(sb, container); + return sb.ToString(); + } + + private static void Append(StringBuilder sb, Inline inline) + { + switch (inline) + { + case LiteralInline lit: + sb.Append(lit.Content.ToString()); + break; + case CodeInline code: + sb.Append(code.Content); + break; + case LineBreakInline: + sb.Append('\n'); + break; + case LinkInline link: + { + foreach (var child in link) Append(sb, child); + if (!string.IsNullOrEmpty(link.Url)) sb.Append(" (").Append(link.Url).Append(')'); + break; + } + case AutolinkInline auto: + sb.Append(auto.Url); + break; + case ContainerInline container: + foreach (var child in container) Append(sb, child); + break; + } + } +} diff --git a/src/OpenKey.Gui/ViewModels/MessageViewModel.cs b/src/OpenKey.Gui/ViewModels/MessageViewModel.cs new file mode 100644 index 0000000..ef48b96 --- /dev/null +++ b/src/OpenKey.Gui/ViewModels/MessageViewModel.cs @@ -0,0 +1,115 @@ +using System.Collections.ObjectModel; +using OpenKey.Core.Providers; + +namespace OpenKey.Gui.ViewModels; + +public enum Speaker +{ + You, + Assistant, +} + +/// +/// One turn in the conversation. +/// +/// Text and rendered blocks are kept side by side on purpose: the raw text is what streams in and +/// what /copy and export need, while the blocks are what the view draws. Re-parsing markdown +/// on every delta would be wasteful, so blocks are rebuilt only when a block boundary is reached — +/// the same rule the console's streaming renderer uses. +/// +/// +public sealed class MessageViewModel : ObservableObject +{ + private string _text = string.Empty; + private bool _isStreaming; + private string? _modelId; + private TimeSpan _elapsed; + + public MessageViewModel(Speaker speaker, string text = "") + { + Speaker = speaker; + _text = text; + if (text.Length > 0) RebuildBlocks(); + } + + public Speaker Speaker { get; } + + public bool IsFromUser => Speaker == Speaker.You; + + public string Header => Speaker == Speaker.You ? Environment.UserName : "OpenKey AI"; + + public ObservableCollection Blocks { get; } = new(); + + public string Text + { + get => _text; + private set { if (Set(ref _text, value)) Raise(nameof(HasText)); } + } + + public bool HasText => _text.Length > 0; + + /// True while deltas are still arriving; drives the caret in the view. + public bool IsStreaming + { + get => _isStreaming; + set => Set(ref _isStreaming, value); + } + + public string? ModelId + { + get => _modelId; + set { if (Set(ref _modelId, value)) Raise(nameof(Subtitle)); } + } + + public TimeSpan Elapsed + { + get => _elapsed; + set { if (Set(ref _elapsed, value)) Raise(nameof(Subtitle)); } + } + + public string Subtitle => + _modelId is null + ? string.Empty + : $"{_modelId} · {(_elapsed.TotalSeconds < 60 ? $"{_elapsed.TotalSeconds:0.0}s" : $"{(int)_elapsed.TotalMinutes}m {_elapsed.Seconds}s")}"; + + public void Append(string delta) + { + if (string.IsNullOrEmpty(delta)) return; + Text += delta; + RebuildBlocks(); + } + + /// Discards everything from an abandoned attempt — see . + public void Reset() + { + Text = string.Empty; + Blocks.Clear(); + } + + public void Complete() + { + IsStreaming = false; + RebuildBlocks(); + } + + private void RebuildBlocks() + { + var parsed = MarkdownBlock.Parse(_text); + + // Replace in place rather than clearing: clearing makes the list view flash and lose scroll + // position on every delta, which is very visible while a reply streams. + for (var i = 0; i < parsed.Count; i++) + { + if (i < Blocks.Count) + { + if (!Blocks[i].Equals(parsed[i])) Blocks[i] = parsed[i]; + } + else + { + Blocks.Add(parsed[i]); + } + } + + while (Blocks.Count > parsed.Count) Blocks.RemoveAt(Blocks.Count - 1); + } +} diff --git a/src/OpenKey.Gui/ViewModels/ObservableObject.cs b/src/OpenKey.Gui/ViewModels/ObservableObject.cs new file mode 100644 index 0000000..db73b30 --- /dev/null +++ b/src/OpenKey.Gui/ViewModels/ObservableObject.cs @@ -0,0 +1,28 @@ +using System.ComponentModel; +using System.Runtime.CompilerServices; + +namespace OpenKey.Gui.ViewModels; + +/// +/// Minimal INotifyPropertyChanged base. +/// +/// Hand-rolled rather than pulling in an MVVM toolkit: this is the only thing such a package would +/// be used for here, and the source generators most of them rely on interact badly with the AOT +/// build. Twenty lines is cheaper than the dependency. +/// +/// +public abstract class ObservableObject : INotifyPropertyChanged +{ + public event PropertyChangedEventHandler? PropertyChanged; + + protected void Raise([CallerMemberName] string? name = null) => + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); + + protected bool Set(ref T field, T value, [CallerMemberName] string? name = null) + { + if (EqualityComparer.Default.Equals(field, value)) return false; + field = value; + Raise(name); + return true; + } +} diff --git a/src/OpenKey.Gui/Views/ConfirmWindow.axaml b/src/OpenKey.Gui/Views/ConfirmWindow.axaml new file mode 100644 index 0000000..78c33c7 --- /dev/null +++ b/src/OpenKey.Gui/Views/ConfirmWindow.axaml @@ -0,0 +1,27 @@ + + + + + + + + + +