diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e395f20..34f4ad0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,15 +31,19 @@ jobs: - name: Test run: dotnet test --no-build -c Release --verbosity normal - # Proves the publish profile in OpenKey.csproj still produces the shipping artifact - # without anyone pasting flags from a README. - - name: Verify single-file publish - run: dotnet publish src/OpenKey/OpenKey.csproj -c Release -r win-x64 -o publish-check + # Proves the publish profile in each csproj still produces the shipping artifact without + # anyone pasting flags from a README. + - name: Verify console publish + run: dotnet publish src/OpenKey/OpenKey.csproj -c Release -r win-x64 -o publish-check/console - - name: Confirm the exe exists + - name: Verify app publish + run: dotnet publish src/OpenKey.Gui/OpenKey.Gui.csproj -c Release -r win-x64 -o publish-check/app + + - name: Confirm both binaries exist shell: pwsh run: | - $exe = "publish-check/OpenKey.exe" - if (-not (Test-Path $exe)) { throw "Expected $exe to exist" } - $mb = [math]::Round((Get-Item $exe).Length / 1MB, 1) - Write-Host "OpenKey.exe is $mb MB" + foreach ($p in @("publish-check/console/OpenKey.exe", "publish-check/app/OpenKeyApp.exe")) { + if (-not (Test-Path $p)) { throw "Expected $p to exist" } + $mb = [math]::Round((Get-Item $p).Length / 1MB, 1) + Write-Host "$p is $mb MB" + } diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d9742eb..e776664 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,11 +28,17 @@ jobs: - name: Publish ${{ matrix.rid }} run: dotnet publish src/OpenKey/OpenKey.csproj -c Release -r ${{ matrix.rid }} -o out/${{ matrix.rid }} - - name: Name the artifact by platform + # Both surfaces ship from the same tag: the console for people who live in a terminal, + # the windowed app for everyone else. Same engine underneath. + - name: Publish the app ${{ matrix.rid }} + run: dotnet publish src/OpenKey.Gui/OpenKey.Gui.csproj -c Release -r ${{ matrix.rid }} -o out-app/${{ matrix.rid }} + + - name: Name the artifacts by platform shell: pwsh run: | New-Item -ItemType Directory -Force dist | Out-Null Copy-Item "out/${{ matrix.rid }}/OpenKey.exe" "dist/OpenKey-${{ matrix.rid }}.exe" + Copy-Item "out-app/${{ matrix.rid }}/OpenKeyApp.exe" "dist/OpenKeyApp-${{ matrix.rid }}.exe" # Code signing removes the SmartScreen "unrecognized app" warning, which is the biggest # friction point for a product handed over on a USB stick. Disabled until the secrets exist; @@ -58,9 +64,12 @@ jobs: - name: Checksum shell: pwsh run: | - $h = (Get-FileHash "dist/OpenKey-${{ matrix.rid }}.exe" -Algorithm SHA256).Hash.ToLower() - "$h OpenKey-${{ matrix.rid }}.exe" | Out-File -Encoding ascii "dist/${{ matrix.rid }}.sha256" - Write-Host "$h OpenKey-${{ matrix.rid }}.exe" + Remove-Item "dist/*.sha256" -ErrorAction SilentlyContinue + Get-ChildItem dist -Filter *.exe | ForEach-Object { + $h = (Get-FileHash $_.FullName -Algorithm SHA256).Hash.ToLower() + "$h $($_.Name)" | Out-File -Encoding ascii -Append "dist/${{ matrix.rid }}.sha256" + Write-Host "$h $($_.Name)" + } - uses: actions/upload-artifact@v4 with: diff --git a/BACKLOG.md b/BACKLOG.md index 24fa30f..54f5c6d 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -65,6 +65,15 @@ Also fixed en route: `Microsoft.ML.Tokenizers` 2.0.0 pulls in `Microsoft.Bcl.Mem carries a known high-severity advisory (GHSA-73j8-2gch-69rq). NuGet audit failed the build; pinned forward to 10.0.10. +### Phase 2 — Avalonia GUI — 2026-08-03 + +Shipped to the roadmap's bar: parity with the console feature set plus mouse selection, copy +buttons on code blocks, and syntax highlighting. `OpenKeyApp.exe` builds AOT and ships from the +same tag as the console. + +`OpenKey.Windows` was extracted so both hosts share DPAPI, app paths, OAuth and the tokenizer. +`OpenKey.Core` still has zero package references. + ### v0.1.0 — 2026-05-28 First release. See [`CHANGELOG.md`](CHANGELOG.md#010--2026-05-28). @@ -75,8 +84,16 @@ First release. See [`CHANGELOG.md`](CHANGELOG.md#010--2026-05-28). Ordered by user value within tier. Lowest tier wins. -Tier 2 and Tier 3 are complete — see **Done** above. What remains is Tier 4, which is Phase 5 work -and a step change in scope rather than more polish. +Tier 2, Tier 3 and Tier 5's Phase 2 (the GUI) are complete — see **Done** above. What remains is +Tier 4, which is Phase 5 work and a step change in scope rather than more polish. + +Smaller GUI follow-ups, none blocking: + +- Inline bold/italic inside paragraphs. The block model supports it; the renderer currently + flattens inline formatting to plain text. +- Window size and position persistence. Deliberately skipped: `config.json`'s shape is a contract + surface documented in `docs/05`, and window bounds do not belong in it without a decision. +- Per-message copy buttons, in addition to the toolbar's copy-last and the per-code-block copy. ### Tier 4 — providers diff --git a/CHANGELOG.md b/CHANGELOG.md index 31e82d0..719b362 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,30 @@ versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +## [0.3.0] — 2026-08-03 + +### Added + +- **A windowed app.** `OpenKeyApp.exe` ships alongside the console from the same release: the same + chat, the same models, the same saved conversation, in a normal window. Code blocks are syntax + highlighted and have their own copy button, text is selectable with the mouse, and there are + buttons for new chat, retry, copy, export, model choice, theme and about. +- Four colour themes in both surfaces — default, dark, light and mono — remembered between runs and + shared between the console and the app. +- Keyboard shortcuts in the app: Enter sends, Shift+Enter adds a line, Esc stops a reply, Ctrl+L + clears the chat. +- **Clear can be undone.** Clearing a chat offers an Undo for as long as you haven't sent anything + new, so a misclick doesn't cost you the conversation. + +### Changed + +- The app's "New chat" button is now "Clear". It never started a new conversation alongside the old + one — it ended the only one there is — and the old label implied otherwise. +- Theme, About and Erase everything moved into a settings menu, leaving the toolbar for things that + act on the conversation. Erase now sits alone at the bottom of that menu, away from Export. +- The model picker has an **Automatic** option again, so you can hand the choice back to OpenKey + after picking a specific model. + ## [0.2.1] — 2026-08-03 Two defects that shipped in 0.2.0, both found by using the app rather than reading it — plus a @@ -57,6 +81,13 @@ much smaller, faster binary. ### Fixed +- **A mistyped or revoked key was accepted and saved.** OpenKey checked keys against an endpoint + that does not require one, so any text passed — and then every message failed, with advice that + led back to the same place. Keys are now genuinely verified before being saved. +- **Piped or scripted input crashed the app.** Anything that needed a menu — choosing a model, + confirming an erase, first-run setup — closed OpenKey with an error when input didn't come from + a keyboard. Those now fall back to typing a number or a word. +- Choosing a model said the choice lasted "until you close OpenKey"; it is remembered. - Links whose address contained a bracket lost their target when displayed. - A message that kept failing could retry for several minutes; it is now bounded, and a reply that is genuinely arriving is never cut off. @@ -122,7 +153,8 @@ much smaller, faster binary. - Commands: `/about`, `/models`, `/model`, `/cls`, `/help`, `/reset`, `/quit`. - Single self-contained `.exe` that runs from a USB stick with nothing installed. -[Unreleased]: https://github.com/corecompiled/OpenKey/compare/v0.2.1...HEAD +[Unreleased]: https://github.com/corecompiled/OpenKey/compare/v0.3.0...HEAD +[0.3.0]: https://github.com/corecompiled/OpenKey/releases/tag/v0.3.0 [0.2.1]: https://github.com/corecompiled/OpenKey/releases/tag/v0.2.1 [0.2.0]: https://github.com/corecompiled/OpenKey/releases/tag/v0.2.0 [0.1.0]: https://github.com/corecompiled/OpenKey/releases/tag/v0.1.0 diff --git a/Directory.Build.props b/Directory.Build.props index 1f76133..b890730 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -8,7 +8,7 @@ true latest Recommended - 0.2.1 + 0.3.0 en-US 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/README.md b/README.md index 7235bb8..222fa3a 100644 --- a/README.md +++ b/README.md @@ -29,8 +29,15 @@ Patron ❯ ## Get it -Download `OpenKey.exe` from [Releases](https://github.com/corecompiled/OpenKey/releases) and -double-click it. One 11 MB file, nothing installed, runs from a USB stick. +Two ways to use it, same chat and same saved conversation underneath: + +| | | +|---|---| +| **`OpenKeyApp.exe`** | A normal window. Start here if you're not sure. | +| **`OpenKey.exe`** | The terminal version, if that's where you live. | + +Download either from [Releases](https://github.com/corecompiled/OpenKey/releases) and double-click. +Around 11–30 MB, nothing installed, runs from a USB stick. Or via [Scoop](https://scoop.sh), which also avoids the SmartScreen prompt: diff --git a/docs/07-roadmap.md b/docs/07-roadmap.md index 1af724e..cdb9cf3 100644 --- a/docs/07-roadmap.md +++ b/docs/07-roadmap.md @@ -68,21 +68,29 @@ Features that touch storage or DI but stay backward-compatible. - Why not WinUI 3: more setup friction (project templates change frequently in 2025/2026), packaging awkwardness. - Why not Electron/web tech: defeats the lightweight portable-exe ethos. -**Project layout addition:** +**Project layout as built:** ``` src\OpenKey.Gui\ - OpenKey.Gui.csproj (Sdk: Microsoft.NET.Sdk; AvaloniaUseCompiledBindingsByDefault=true) - App.axaml Avalonia app shell - MainWindow.axaml chat surface - ViewModels\ChatViewModel.cs binds to OpenKey.Core.ChatEngine + App.axaml / GuiTheme.cs app shell and palettes + Views\MainWindow.axaml chat surface + Views\CodeBlockView.axaml highlighted code with a copy button + Views\ConfirmWindow.axaml destructive confirms and About + ViewModels\ MainWindowViewModel, MessageViewModel, + MarkdownBlock, SyntaxHighlighter +src\OpenKey.Windows\ DPAPI, app paths, OAuth, tokenizer — + shared by both hosts ``` +`OpenKey.Windows` was extracted when the GUI needed the same platform pieces the console already had. `OpenKey.Core` still has zero package references and still targets `net10.0` rather than `net10.0-windows`. + **Key reuse principle:** `OpenKey.Core` and `OpenKey.Providers.OpenRouter` are referenced unchanged. `ChatEngine` is the boundary — the GUI binds an `IAsyncEnumerable` to a `TextBox`/`ItemsControl` exactly as the console renders it. -**New publish target:** Avalonia can also publish single-file self-contained. Same flags as Phase 1's `dotnet publish` line, swap project path to `src/OpenKey.Gui/OpenKey.Gui.csproj`. Output size: ~50–80 MB. +**Publish target:** `src/OpenKey.Gui/OpenKey.Gui.csproj`, NativeAOT like the console, shipping as `OpenKeyApp.exe` beside `OpenKey.exe` from the same tag. + +**Shipped.** Parity with the console feature set — new chat, retry, copy, export, model picker, theme, about, erase — plus the three GUI-specific items: mouse selection via `SelectableTextBlock`, a copy button on every code block, and syntax highlighting. -**Phase 2 ships when:** GUI feature-parity with Phase 1.2 console + GUI-specific QoL (mouse selection, copy code blocks, syntax highlighting via Avalonia.HtmlRenderer or Markdig + AvaloniaEdit). +Highlighting is a small in-house tokenizer rather than AvaloniaEdit or a TextMate grammar engine. Those are built for *editing* — buffers, folding, undo, grammar files — and none of that applies to text displayed once and never modified. A keyword in the wrong colour costs nothing; the dependency costs megabytes and an AOT risk. Recorded in [`architecture/08-decisions.md`](architecture/08-decisions.md). ## Phase 3 — Tool use / function calling diff --git a/docs/architecture/08-decisions.md b/docs/architecture/08-decisions.md index 73ae6ab..8b11d7d 100644 --- a/docs/architecture/08-decisions.md +++ b/docs/architecture/08-decisions.md @@ -155,6 +155,43 @@ as commands. --- +## A hand-written syntax highlighter, not AvaloniaEdit + +**Status:** decided + +The GUI colours code blocks in replies. The obvious options were AvaloniaEdit or a TextMate grammar +engine, and both were rejected: they are built for *editing* — buffers, folding, undo, grammar +files, incremental re-lex — and a chat reply is displayed once and never modified. + +The asymmetry decides it. Getting a keyword colour wrong is invisible to most readers and harmless +to all of them. Taking the dependency costs megabytes in a binary whose whole pitch is that it is +11 MB, plus an AOT compatibility risk in a build that is now fully native. + +`SyntaxHighlighter` is one regex per dialect family (C-style, hash-comment, SQL) and three keyword +sets. Comments and strings match first, because a keyword inside a comment is not a keyword. An +unknown language renders plain rather than guessing. + +The invariant that actually matters is tested: concatenating the tokens must reproduce the source +exactly. Highlighting is a view over the text, and a dropped character would silently corrupt code +the user is about to copy. + +## A second host, not a second implementation + +**Status:** decided + +`OpenKey.Gui` contains view models and views and nothing else — no chat logic, no rotation, no +persistence, no provider code. Composition in its `Program.cs` is line-for-line the console's. + +This was the test of whether the layering was real. It held: the GUI needed no change to +`OpenKey.Core` at all. The one thing it did force was extracting `OpenKey.Windows` — DPAPI, app +paths, OAuth and the tokenizer had been sitting inside the console executable where a second host +could not reach them. + +The GUI keeps the console's behavioural rules rather than inventing its own: block-level streaming, +`IsAttemptRestart` clearing an abandoned reply, errors that name a next step, destructive confirms +that default to Cancel. Themes are shared through `config.json`, so switching in one host and +opening the other keeps the choice — the *setting* is shared, the *rendering* is per-host. + ## The multi-provider seam is designed but unproven **Status:** acknowledged diff --git a/src/OpenKey.Core/Engine/ChatEngine.cs b/src/OpenKey.Core/Engine/ChatEngine.cs index 08b64b2..97a1e5d 100644 --- a/src/OpenKey.Core/Engine/ChatEngine.cs +++ b/src/OpenKey.Core/Engine/ChatEngine.cs @@ -92,6 +92,25 @@ public Task NewSessionAsync(CancellationToken ct) return Task.CompletedTask; } + /// + /// Puts a previous conversation back and re-persists it. Exists so a host can offer undo after + /// clearing — destroying someone's conversation should be reversible, and a confirmation + /// dialog interrupts everyone to protect against a rare mistake, where undo costs nothing + /// until it is needed. + /// + public async Task RestoreTurnsAsync(IReadOnlyList turns, CancellationToken ct) + { + _turns.Clear(); + _turns.AddRange(turns); + + if (_turns.Count == 0 || _turns[0].Role != ChatMessage.SystemRole) + _turns.Insert(0, new ChatMessage(ChatMessage.SystemRole, DefaultSystemPrompt)); + + await _sessions.SaveAsync( + new SessionSnapshot(ActiveModel?.Id ?? string.Empty, DateTimeOffset.UtcNow, _turns.ToArray()), + ct); + } + public async IAsyncEnumerable SendAsync( string userText, [EnumeratorCancellation] CancellationToken ct) diff --git a/src/OpenKey.Gui/App.axaml b/src/OpenKey.Gui/App.axaml new file mode 100644 index 0000000..12929db --- /dev/null +++ b/src/OpenKey.Gui/App.axaml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/OpenKey.Gui/App.axaml.cs b/src/OpenKey.Gui/App.axaml.cs new file mode 100644 index 0000000..89be926 --- /dev/null +++ b/src/OpenKey.Gui/App.axaml.cs @@ -0,0 +1,35 @@ +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(); + + // Before the window exists, so it never renders in one palette and repaints into + // another. The setting is shared with the console via config.json. + GuiTheme.Apply(this, vm.Theme); + vm.ThemeChanged += name => GuiTheme.Apply(this, name); + + 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/GuiTheme.cs b/src/OpenKey.Gui/GuiTheme.cs new file mode 100644 index 0000000..124142b --- /dev/null +++ b/src/OpenKey.Gui/GuiTheme.cs @@ -0,0 +1,109 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; +using Avalonia.Styling; + +namespace OpenKey.Gui; + +/// +/// Applies a palette to the running application. +/// +/// Same governing rule as the console: one accent, one neutral, three signals. Colour carries +/// meaning, never decoration, and no state is signalled by colour alone — which is what makes the +/// mono palette a legitimate option rather than a novelty. +/// +/// +/// Themes are shared with the console through config.json, so switching in one and opening +/// the other keeps your choice. Rendering is per-host; the setting is not. +/// +/// +internal static class GuiTheme +{ + public const string Default = "default"; + public const string Dark = "dark"; + public const string Light = "light"; + public const string Mono = "mono"; + + public static readonly string[] All = { Default, Dark, Light, Mono }; + + public static string Current { get; private set; } = Default; + + public static bool IsKnown(string? name) => + name is not null && All.Contains(name.Trim().ToLowerInvariant(), StringComparer.OrdinalIgnoreCase); + + public static void Apply(Application app, string? name) + { + var theme = (name ?? Default).Trim().ToLowerInvariant(); + if (!IsKnown(theme)) theme = Default; + Current = theme; + + app.RequestedThemeVariant = theme == Light ? ThemeVariant.Light : ThemeVariant.Dark; + + var r = app.Resources; + + switch (theme) + { + case Light: + Set(r, "Surface", "#F8FAFC"); + Set(r, "SurfaceRaised", "#EEF2F7"); + Set(r, "CodeSurface", "#F1F5F9"); + Set(r, "Line", "#CBD5E1"); + Set(r, "Body", "#0F172A"); + Set(r, "Muted", "#64748B"); + Set(r, "Brand", "#0E7490"); + Set(r, "Ok", "#15803D"); + Set(r, "Warn", "#B45309"); + Set(r, "Danger", "#B91C1C"); + Set(r, "OnBrand", "#F8FAFC"); + Set(r, "CodeKeyword", "#7C3AED"); + Set(r, "CodeString", "#047857"); + Set(r, "CodeComment", "#94A3B8"); + Set(r, "CodeNumber", "#B45309"); + Set(r, "CodeType", "#0E7490"); + break; + + case Mono: + // No hue at all. Everything must stay legible, which is the standing test that + // meaning never depended on colour in the first place. + Set(r, "Surface", "#101010"); + Set(r, "SurfaceRaised", "#1B1B1B"); + Set(r, "CodeSurface", "#161616"); + Set(r, "Line", "#3A3A3A"); + Set(r, "Body", "#E8E8E8"); + Set(r, "Muted", "#9A9A9A"); + Set(r, "Brand", "#E8E8E8"); + Set(r, "Ok", "#E8E8E8"); + Set(r, "Warn", "#C8C8C8"); + Set(r, "Danger", "#FFFFFF"); + Set(r, "OnBrand", "#101010"); + Set(r, "CodeKeyword", "#FFFFFF"); + Set(r, "CodeString", "#C8C8C8"); + Set(r, "CodeComment", "#7A7A7A"); + Set(r, "CodeNumber", "#C8C8C8"); + Set(r, "CodeType", "#E8E8E8"); + break; + + default: // default and dark are the same palette; "dark" exists so the name works + Set(r, "Surface", "#0F172A"); + Set(r, "SurfaceRaised", "#1E293B"); + Set(r, "CodeSurface", "#0B1220"); + Set(r, "Line", "#334155"); + Set(r, "Body", "#E2E8F0"); + Set(r, "Muted", "#94A3B8"); + Set(r, "Brand", "#22D3EE"); + Set(r, "Ok", "#4ADE80"); + Set(r, "Warn", "#FBBF24"); + Set(r, "Danger", "#F87171"); + Set(r, "OnBrand", "#04121A"); + Set(r, "CodeKeyword", "#C4B5FD"); + Set(r, "CodeString", "#86EFAC"); + Set(r, "CodeComment", "#64748B"); + Set(r, "CodeNumber", "#FCD34D"); + Set(r, "CodeType", "#7DD3FC"); + break; + } + } + + private static void Set(IResourceDictionary resources, string key, string hex) => + resources[key] = new SolidColorBrush(Color.Parse(hex)); +} diff --git a/src/OpenKey.Gui/OpenKey.Gui.csproj b/src/OpenKey.Gui/OpenKey.Gui.csproj new file mode 100644 index 0000000..800abc9 --- /dev/null +++ b/src/OpenKey.Gui/OpenKey.Gui.csproj @@ -0,0 +1,40 @@ + + + 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..916d7e2 --- /dev/null +++ b/src/OpenKey.Gui/ViewModels/MainWindowViewModel.cs @@ -0,0 +1,562 @@ +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)); + + StopCommand = new RelayCommand(Stop); + ClearCommand = new RelayCommand(() => _ = ClearConversationAsync()); + } + + 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(); + + private ModelChoice? _selectedModel; + + /// + /// Two-way bound so the picker reflects the saved choice on launch instead of showing a + /// placeholder while a model is actually pinned. + /// + public ModelChoice? SelectedModel + { + get => _selectedModel; + set + { + if (!Set(ref _selectedModel, value) || value is null) return; + PinModel(value.Model); + } + } + + 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(); + Models.Add(ModelChoice.Automatic); + foreach (var m in models) Models.Add(ModelChoice.For(m)); + + // Reflect what is actually saved. Assigning the backing field directly avoids the + // setter re-pinning the value we just read. + var pinned = _engine.PreferredModelId; + _selectedModel = pinned is null + ? ModelChoice.Automatic + : Models.FirstOrDefault(c => c.Model?.Id == pinned) ?? ModelChoice.Automatic; + Raise(nameof(SelectedModel)); + } + 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); + + // Auth check first: /models is public and answers 200 for anyone, so on its own it + // would accept any string as a valid key. + await probe.ValidateKeyAsync(CancellationToken.None); + 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; + + if (_clearedTurns is not null) + { + _clearedTurns = null; + _clearedMessages = null; + Raise(nameof(CanUndoClear)); + } + + 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(); + + // Commands exist only so the window's KeyBindings have something to bind to; every button + // calls the methods directly. + public System.Windows.Input.ICommand StopCommand { get; } + + public System.Windows.Input.ICommand ClearCommand { get; } + + /// Resends the last message. Goes through SendAsync so a retry takes the same path. + public async Task RetryAsync() + { + if (IsBusy) return; + + if (_engine.LastUserMessage is not { } last) + { + Show(StatusKind.Info, "Nothing to retry yet — send a message first."); + return; + } + + Draft = last; + await SendAsync(); + } + + public string? LastReply => + Messages.LastOrDefault(m => m.Speaker == Speaker.Assistant && m.HasText)?.Text; + + /// + /// Renders the conversation as markdown, matching the console's /export. Returns null + /// when there is nothing to write. + /// + public string? BuildExport() + { + var turns = Messages.Where(m => m.HasText).ToList(); + if (turns.Count == 0) return null; + + var culture = System.Globalization.CultureInfo.InvariantCulture; + var sb = new System.Text.StringBuilder(); + + sb.AppendLine("# OpenKey conversation").AppendLine(); + sb.Append(culture, $"Exported {DateTimeOffset.Now:yyyy-MM-dd HH:mm}").AppendLine(); + if (_engine.ActiveModel is { } m) sb.Append(culture, $"Model: {m.Id}").AppendLine(); + sb.AppendLine(); + + foreach (var turn in turns) + { + var who = turn.IsFromUser ? "You" : "OpenKey AI"; + sb.Append(culture, $"## {who}").AppendLine().AppendLine(); + sb.AppendLine(turn.Text.TrimEnd()).AppendLine(); + } + + return sb.ToString(); + } + + public static string SuggestedExportName => + $"OpenKey-chat-{DateTimeOffset.Now:yyyy-MM-dd-HHmm}.md"; + + // ---- theme --------------------------------------------------------------------------- + + /// Raised when the palette changes so the app can repaint. See GuiTheme. + public event Action? ThemeChanged; + + public string Theme => _config.Current.Theme; + + public IReadOnlyList Themes { get; } = new[] { "default", "dark", "light", "mono" }; + + public void SetTheme(string? name) + { + if (string.IsNullOrWhiteSpace(name)) return; + var wanted = name.Trim().ToLowerInvariant(); + if (wanted == _config.Current.Theme) return; + + _config.Save(_config.Current with { Theme = wanted }); + Raise(nameof(Theme)); + ThemeChanged?.Invoke(wanted); + } + + // ---- about --------------------------------------------------------------------------- + + public IReadOnlyList<(string Label, string Value)> AboutRows => new[] + { + ("Version", Version), + ("Answering with", _engine.ActiveModel?.Id ?? "Nothing yet — send a message"), + ("Model choice", _engine.PreferredModelId ?? "Automatic"), + ("Your data", _paths.RootDir), + ("Key security", "Encrypted for your Windows account, stored on this PC only"), + ("Developer", "Paolo Patron"), + }; + + public void NotifyStatus(StatusKind kind, string message) => Show(kind, message); + + private IReadOnlyList? _clearedTurns; + private MessageViewModel[]? _clearedMessages; + + /// True while a cleared conversation can still be brought back. + public bool CanUndoClear => _clearedTurns is not null; + + /// + /// Clears the conversation, keeping the key. + /// + /// This deletes the only conversation OpenKey stores, so it is offered with undo rather than + /// behind a confirmation. A dialog interrupts everyone every time to guard against a mistake + /// that is rare; undo costs nothing until the moment it is needed, and then it costs one + /// click. The button is also labelled "Clear chat" rather than "New chat" — the latter implies + /// the old conversation is still somewhere, and it is not. + /// + /// + public async Task ClearConversationAsync() + { + if (IsBusy) return; + + if (Messages.Count == 0) + { + Show(StatusKind.Info, "This chat is already empty."); + return; + } + + _clearedTurns = _engine.Turns.ToArray(); + _clearedMessages = Messages.ToArray(); + + await _engine.NewSessionAsync(CancellationToken.None); + Messages.Clear(); + + Raise(nameof(CanUndoClear)); + Show(StatusKind.Ok, "Chat cleared. Your key is untouched."); + } + + public async Task UndoClearAsync() + { + if (_clearedTurns is null || _clearedMessages is null) return; + + await _engine.RestoreTurnsAsync(_clearedTurns, CancellationToken.None); + + Messages.Clear(); + foreach (var m in _clearedMessages) Messages.Add(m); + + _clearedTurns = null; + _clearedMessages = null; + Raise(nameof(CanUndoClear)); + Show(StatusKind.Ok, "Chat restored."); + } + + 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/ModelChoice.cs b/src/OpenKey.Gui/ViewModels/ModelChoice.cs new file mode 100644 index 0000000..dd93d6e --- /dev/null +++ b/src/OpenKey.Gui/ViewModels/ModelChoice.cs @@ -0,0 +1,30 @@ +using OpenKey.Core.Providers; + +namespace OpenKey.Gui.ViewModels; + +/// +/// An entry in the model picker. +/// +/// A wrapper rather than binding directly, because the list needs one item +/// that is not a model: "Automatic". Without it, picking a model was a one-way door — +/// nothing in the window could hand the choice back to rotation, which is the default and the +/// right setting for most people. +/// +/// +public sealed record ModelChoice(string Label, string? Detail, ModelInfo? Model) +{ + public bool IsAutomatic => Model is null; + + public bool HasDetail => !string.IsNullOrEmpty(Detail); + + public static ModelChoice Automatic { get; } = + new("Automatic", "Best available, switches when one is busy", null); + + public static ModelChoice For(ModelInfo model) => + new(model.DisplayName, FormatContext(model.ContextLength), model); + + private static string FormatContext(int contextLength) => + contextLength <= 0 ? string.Empty + : contextLength >= 1000 ? $"{contextLength / 1000}k context" + : $"{contextLength} context"; +} 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/ViewModels/RelayCommand.cs b/src/OpenKey.Gui/ViewModels/RelayCommand.cs new file mode 100644 index 0000000..c82ac5d --- /dev/null +++ b/src/OpenKey.Gui/ViewModels/RelayCommand.cs @@ -0,0 +1,20 @@ +using System.Windows.Input; + +namespace OpenKey.Gui.ViewModels; + +/// +/// Minimal for the window's key bindings. Buttons call view-model methods +/// directly, so nothing here needs parameters or dynamic enablement. +/// +public sealed class RelayCommand : ICommand +{ + private readonly Action _execute; + + public RelayCommand(Action execute) => _execute = execute; + + public event EventHandler? CanExecuteChanged { add { } remove { } } + + public bool CanExecute(object? parameter) => true; + + public void Execute(object? parameter) => _execute(); +} diff --git a/src/OpenKey.Gui/ViewModels/SyntaxHighlighter.cs b/src/OpenKey.Gui/ViewModels/SyntaxHighlighter.cs new file mode 100644 index 0000000..29e2d5f --- /dev/null +++ b/src/OpenKey.Gui/ViewModels/SyntaxHighlighter.cs @@ -0,0 +1,145 @@ +using System.Text.RegularExpressions; + +namespace OpenKey.Gui.ViewModels; + +public enum TokenKind +{ + Plain, + Keyword, + StringLiteral, + Comment, + Number, + Type, +} + +public sealed record CodeToken(string Text, TokenKind Kind); + +/// +/// Colours code blocks in replies. +/// +/// Deliberately a small tokenizer rather than an AvaloniaEdit or TextMate dependency. Those are +/// built for editing — buffers, folding, undo, grammar files — and none of that applies to text +/// that is displayed once and never modified. The cost of getting this slightly wrong is a keyword +/// in the wrong colour; the cost of the dependency is megabytes and an AOT risk. +/// +/// +/// Comments and strings are matched first and win, because a keyword inside a comment is not a +/// keyword. Anything unrecognised renders plain, which is the correct failure mode. +/// +/// +internal static class SyntaxHighlighter +{ + private const RegexOptions Opts = RegexOptions.Compiled | RegexOptions.CultureInvariant; + + // One pass, alternation ordered by precedence: whatever matches first wins the span. + private static readonly Regex CStyle = new( + """ + (?//[^\n]*|/\*[\s\S]*?\*/) + |(?"(?:\\.|[^"\\\n])*"|'(?:\\.|[^'\\\n])*'|`(?:\\.|[^`\\])*`) + |(?\b\d[\d_]*(?:\.\d+)?(?:[eE][+-]?\d+)?[fFdDmMlLuU]?\b) + |(?[A-Za-z_][A-Za-z0-9_]*) + """, + Opts | RegexOptions.IgnorePatternWhitespace); + + // Five-quote delimiter: the pattern itself matches Python's triple-quoted strings, which would + // otherwise close a three-quote raw literal. + private static readonly Regex HashStyle = new( + """"" + (?\#[^\n]*) + |(?"""[\s\S]*?"""|'''[\s\S]*?'''|"(?:\\.|[^"\\\n])*"|'(?:\\.|[^'\\\n])*') + |(?\b\d[\d_]*(?:\.\d+)?(?:[eE][+-]?\d+)?\b) + |(?[A-Za-z_][A-Za-z0-9_]*) + """"", + Opts | RegexOptions.IgnorePatternWhitespace); + + private static readonly Regex SqlStyle = new( + """ + (?--[^\n]*|/\*[\s\S]*?\*/) + |(?'(?:''|[^'])*') + |(?\b\d+(?:\.\d+)?\b) + |(?[A-Za-z_][A-Za-z0-9_]*) + """, + Opts | RegexOptions.IgnorePatternWhitespace); + + private static readonly HashSet CommonKeywords = new(StringComparer.Ordinal) + { + "if", "else", "for", "while", "return", "break", "continue", "switch", "case", "default", + "try", "catch", "finally", "throw", "new", "class", "struct", "enum", "interface", + "public", "private", "protected", "internal", "static", "const", "readonly", "async", + "await", "using", "namespace", "var", "let", "const", "function", "def", "import", "from", + "export", "type", "true", "false", "null", "nil", "None", "True", "False", "this", "self", + "in", "is", "not", "and", "or", "with", "as", "yield", "lambda", "pass", "elif", "raise", + "match", "record", "override", "virtual", "abstract", "sealed", "out", "ref", "params", + }; + + private static readonly HashSet SqlKeywords = new(StringComparer.OrdinalIgnoreCase) + { + "select", "from", "where", "join", "inner", "left", "right", "outer", "on", "group", "by", + "order", "having", "insert", "into", "values", "update", "set", "delete", "create", "table", + "alter", "drop", "index", "view", "as", "and", "or", "not", "null", "is", "in", "exists", + "distinct", "limit", "offset", "union", "all", "case", "when", "then", "else", "end", + "primary", "key", "foreign", "references", "default", "constraint", "with", + }; + + private static readonly HashSet KnownTypes = new(StringComparer.Ordinal) + { + "string", "int", "long", "bool", "double", "float", "decimal", "byte", "char", "object", + "void", "str", "list", "dict", "set", "tuple", "number", "boolean", "any", "unknown", + "String", "Int32", "Boolean", "Task", "List", "Dictionary", "Array", "Object", + }; + + /// + /// Splits code into coloured spans. An unrecognised or absent language yields a single plain + /// token, so callers never need to special-case it. + /// + public static IReadOnlyList Tokenize(string code, string? language) + { + if (string.IsNullOrEmpty(code)) return Array.Empty(); + + var (regex, keywords) = Dialect(language); + if (regex is null) return new[] { new CodeToken(code, TokenKind.Plain) }; + + var tokens = new List(); + var last = 0; + + foreach (Match m in regex.Matches(code)) + { + if (m.Index > last) tokens.Add(new CodeToken(code[last..m.Index], TokenKind.Plain)); + + var kind = TokenKind.Plain; + if (m.Groups["comment"].Success) kind = TokenKind.Comment; + else if (m.Groups["string"].Success) kind = TokenKind.StringLiteral; + else if (m.Groups["number"].Success) kind = TokenKind.Number; + else if (m.Groups["word"].Success) + { + var word = m.Value; + if (keywords.Contains(word)) kind = TokenKind.Keyword; + else if (KnownTypes.Contains(word)) kind = TokenKind.Type; + } + + tokens.Add(new CodeToken(m.Value, kind)); + last = m.Index + m.Length; + } + + if (last < code.Length) tokens.Add(new CodeToken(code[last..], TokenKind.Plain)); + return tokens; + } + + private static (Regex? Regex, HashSet Keywords) Dialect(string? language) => + (language ?? string.Empty).Trim().ToLowerInvariant() switch + { + "python" or "py" or "ruby" or "rb" or "sh" or "bash" or "shell" or "zsh" or "yaml" or "yml" or "toml" + => (HashStyle, CommonKeywords), + + "sql" or "postgres" or "postgresql" or "mysql" or "sqlite" + => (SqlStyle, SqlKeywords), + + "c" or "cpp" or "c++" or "cs" or "csharp" or "c#" or "java" or "js" or "javascript" + or "ts" or "typescript" or "jsx" or "tsx" or "go" or "rust" or "rs" or "kotlin" + or "swift" or "php" or "scala" or "dart" or "json" + => (CStyle, CommonKeywords), + + // Unknown or absent language: render plain rather than guess wrong. + _ => (null, CommonKeywords), + }; +} diff --git a/src/OpenKey.Gui/Views/CodeBlockView.axaml b/src/OpenKey.Gui/Views/CodeBlockView.axaml new file mode 100644 index 0000000..30c5451 --- /dev/null +++ b/src/OpenKey.Gui/Views/CodeBlockView.axaml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + +