From 0f467b7810bea5a83f3ddb5384aa44f6ee0e0a93 Mon Sep 17 00:00:00 2001 From: corecompiled <285886213+corecompiled@users.noreply.github.com> Date: Mon, 3 Aug 2026 20:50:28 +0800 Subject: [PATCH 01/14] feat: conversations you can go back to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenKey stored exactly one conversation. Starting another destroyed the previous one, and the app's "New chat" button promised something that did not exist. This replaces the single session with a store of many. Storage is one JSON file per conversation under chats\, with index.json as a list cache. A folder rather than one document, so a corrupt write costs a single conversation instead of all of them — the same reasoning that quarantines a bad session rather than refusing to start. The index is never authoritative: it is validated against the actual files by id, not by count, and rebuilt whenever it disagrees. That check originally compared counts, which meant an index with the right number of wrong entries was trusted; a test caught it. Migration turns an existing session.json into the first chat, titled from its opening message, and renames the original to session.json.migrated rather than deleting it — a failed migration must never be the reason someone loses the only conversation they had. Verified against real data. Titles come from the first user message rather than a model call. A title is cosmetic and renameable, so paying tokens and latency for one on every new chat is a bad trade. Console gains /chats, /chat , /rename and /delete; /new stops destroying. The app gains a sidebar with click-to-switch, right-click to rename or delete, a collapse toggle, and Ctrl+N. The Undo added earlier is gone with the problem it patched: it existed because clearing destroyed the only conversation. Starting a new chat now destroys nothing and needs no undo, while deleting one is permanent and so asks first. Contract change, approved beforehand: ISessionStore is replaced by IChatStore and the %APPDATA% layout gains chats\. Both hosts updated. 145 tests. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01WGcarVYpt1mfjk6iFkcDpZ --- src/OpenKey.Core/Engine/ChatEngine.cs | 98 +++++- src/OpenKey.Core/Storage/IChatStore.cs | 67 +++++ src/OpenKey.Core/Storage/JsonChatStore.cs | 265 +++++++++++++++++ .../Storage/OpenKeyJsonContext.cs | 2 + .../Storage/OpenKeyJsonContextAccessor.cs | 15 + src/OpenKey.Gui/Program.cs | 2 +- .../ViewModels/MainWindowViewModel.cs | 137 ++++++--- src/OpenKey.Gui/Views/MainWindow.axaml | 58 +++- src/OpenKey.Gui/Views/MainWindow.axaml.cs | 33 +- src/OpenKey.Gui/Views/RenameWindow.axaml | 23 ++ src/OpenKey.Gui/Views/RenameWindow.axaml.cs | 26 ++ src/OpenKey/CommandRouter.cs | 155 +++++++++- src/OpenKey/ConsoleHost.cs | 6 +- src/OpenKey/Program.cs | 2 +- tests/OpenKey.Core.Tests/ChatEngineTests.cs | 6 +- tests/OpenKey.Core.Tests/ChatStoreTests.cs | 281 ++++++++++++++++++ tests/OpenKey.Core.Tests/ConfigStoreTests.cs | 4 +- 17 files changed, 1085 insertions(+), 95 deletions(-) create mode 100644 src/OpenKey.Core/Storage/IChatStore.cs create mode 100644 src/OpenKey.Core/Storage/JsonChatStore.cs create mode 100644 src/OpenKey.Core/Storage/OpenKeyJsonContextAccessor.cs create mode 100644 src/OpenKey.Gui/Views/RenameWindow.axaml create mode 100644 src/OpenKey.Gui/Views/RenameWindow.axaml.cs create mode 100644 tests/OpenKey.Core.Tests/ChatStoreTests.cs diff --git a/src/OpenKey.Core/Engine/ChatEngine.cs b/src/OpenKey.Core/Engine/ChatEngine.cs index 97a1e5d..2d41389 100644 --- a/src/OpenKey.Core/Engine/ChatEngine.cs +++ b/src/OpenKey.Core/Engine/ChatEngine.cs @@ -23,7 +23,7 @@ public sealed class ChatEngine private readonly IChatProvider _provider; private readonly IRotationPolicy _rotation; private readonly IModelCatalog _catalog; - private readonly ISessionStore _sessions; + private readonly IChatStore _chats; private readonly IConfigStore _config; private readonly ITokenCounter _tokens; @@ -33,14 +33,14 @@ public ChatEngine( IChatProvider provider, IRotationPolicy rotation, IModelCatalog catalog, - ISessionStore sessions, + IChatStore chats, IConfigStore config, ITokenCounter? tokens = null) { _provider = provider; _rotation = rotation; _catalog = catalog; - _sessions = sessions; + _chats = chats; _config = config; _tokens = tokens ?? new HeuristicTokenCounter(); PreferredModelId = config.Current.PinnedModel; @@ -72,26 +72,83 @@ public string? PreferredModelId public async Task ResumeAsync(CancellationToken ct) { - var snap = await _sessions.LoadAsync(ct); - if (snap is null) + var id = await _chats.MostRecentIdAsync(ct); + if (id is null) { - ResetTurnsToSystemOnly(); + StartNewChat(); return; } + await OpenChatAsync(id, ct); + } + + /// + /// Starts a conversation without touching the previous one. It gets an id on first save, so an + /// empty chat nobody used never reaches disk. + /// + public Task NewSessionAsync(CancellationToken ct) + { + StartNewChat(); + return Task.CompletedTask; + } + + /// The conversation currently open, or null before anything has been said. + public string? CurrentChatId { get; private set; } + + public string CurrentChatTitle { get; private set; } = Chat.Untitled; + + public Task> ListChatsAsync(CancellationToken ct) => _chats.ListAsync(ct); + + public async Task OpenChatAsync(string id, CancellationToken ct) + { + var chat = await _chats.LoadAsync(id, ct); + if (chat is null) return false; + _turns.Clear(); - _turns.AddRange(snap.Turns); + _turns.AddRange(chat.Turns); if (_turns.Count == 0 || _turns[0].Role != ChatMessage.SystemRole) _turns.Insert(0, new ChatMessage(ChatMessage.SystemRole, DefaultSystemPrompt)); + + CurrentChatId = chat.Id; + CurrentChatTitle = chat.Title; + _createdAt = chat.CreatedAt; + return true; } - public Task NewSessionAsync(CancellationToken ct) + public async Task DeleteChatAsync(string id, CancellationToken ct) + { + await _chats.DeleteAsync(id, ct); + + // Deleting the chat you are looking at should leave you somewhere sensible, not staring at + // a conversation that no longer exists. + if (CurrentChatId != id) return; + + var next = await _chats.MostRecentIdAsync(ct); + if (next is null || !await OpenChatAsync(next, ct)) StartNewChat(); + } + + public async Task RenameChatAsync(string id, string title, CancellationToken ct) + { + var chat = await _chats.LoadAsync(id, ct); + if (chat is null) return; + + var clean = string.IsNullOrWhiteSpace(title) ? Chat.Untitled : title.Trim(); + if (clean.Length > Chat.MaxTitleLength) clean = clean[..Chat.MaxTitleLength]; + + await _chats.SaveAsync(chat with { Title = clean }, ct); + if (CurrentChatId == id) CurrentChatTitle = clean; + } + + private void StartNewChat() { ResetTurnsToSystemOnly(); - _sessions.Clear(); - return Task.CompletedTask; + CurrentChatId = null; + CurrentChatTitle = Chat.Untitled; + _createdAt = DateTimeOffset.UtcNow; } + private DateTimeOffset _createdAt = DateTimeOffset.UtcNow; + /// /// 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 @@ -106,9 +163,8 @@ public async Task RestoreTurnsAsync(IReadOnlyList turns, Cancellati 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); + CurrentChatId ??= IChatStore.NewId(); + await PersistAsync(ActiveModel?.Id ?? string.Empty, ct); } public async IAsyncEnumerable SendAsync( @@ -309,8 +365,20 @@ private async Task CommitTurnAsync(ModelInfo model, string assistantText, Cancel { _rotation.MarkSuccess(model.Id); _turns.Add(new ChatMessage(ChatMessage.AssistantRole, assistantText)); - await _sessions.SaveAsync( - new SessionSnapshot(model.Id, DateTimeOffset.UtcNow, _turns.ToArray()), + await PersistAsync(model.Id, ct); + } + + /// + /// Writes the open conversation. The id and title are assigned on the first save, so a chat + /// only exists on disk once something was actually said in it. + /// + private async Task PersistAsync(string modelId, CancellationToken ct) + { + CurrentChatId ??= IChatStore.NewId(); + if (CurrentChatTitle == Chat.Untitled) CurrentChatTitle = Chat.TitleFrom(_turns); + + await _chats.SaveAsync( + new Chat(CurrentChatId, CurrentChatTitle, modelId, _createdAt, DateTimeOffset.UtcNow, _turns.ToArray()), ct); } diff --git a/src/OpenKey.Core/Storage/IChatStore.cs b/src/OpenKey.Core/Storage/IChatStore.cs new file mode 100644 index 0000000..532e6f1 --- /dev/null +++ b/src/OpenKey.Core/Storage/IChatStore.cs @@ -0,0 +1,67 @@ +using OpenKey.Core.Providers; + +namespace OpenKey.Core.Storage; + +/// One conversation, as stored. Shape is normative — see docs/05-persistence-and-reset.md. +public sealed record Chat( + string Id, + string Title, + string ModelId, + DateTimeOffset CreatedAt, + DateTimeOffset UpdatedAt, + IReadOnlyList Turns) +{ + /// Longest title kept. Long enough to recognise a chat, short enough for a sidebar. + public const int MaxTitleLength = 48; + + public const string Untitled = "New chat"; + + /// + /// A title taken from the opening message rather than generated by a model: a title is + /// cosmetic and renameable, and paying tokens and latency for one on every new chat is a bad + /// trade. + /// + public static string TitleFrom(IEnumerable turns) + { + var first = turns.FirstOrDefault(t => t.Role == ChatMessage.UserRole)?.Content; + if (string.IsNullOrWhiteSpace(first)) return Untitled; + + var flat = first.ReplaceLineEndings(" ").Trim(); + while (flat.Contains(" ", StringComparison.Ordinal)) + flat = flat.Replace(" ", " ", StringComparison.Ordinal); + + return flat.Length <= MaxTitleLength ? flat : flat[..(MaxTitleLength - 1)].TrimEnd() + "…"; + } +} + +/// +/// A chat as it appears in a list. Kept separate from so drawing a sidebar does +/// not mean reading and parsing every conversation on disk. +/// +public sealed record ChatSummary(string Id, string Title, DateTimeOffset UpdatedAt, int MessageCount); + +/// +/// Conversations on disk. Replaces the single-session store: OpenKey used to keep exactly one +/// conversation, so starting another meant destroying the previous one. +/// +public interface IChatStore +{ + /// Newest first. Never throws — an unreadable store lists nothing rather than failing a launch. + Task> ListAsync(CancellationToken ct); + + Task LoadAsync(string id, CancellationToken ct); + + Task SaveAsync(Chat chat, CancellationToken ct); + + Task DeleteAsync(string id, CancellationToken ct); + + /// The chat to open on launch, or null when there are none. + Task MostRecentIdAsync(CancellationToken ct); + + /// Removes every conversation. Used by the reset path. + void Clear(); + + /// A new, unused identifier. Sortable by time, and safe as a filename. + static string NewId() => + $"{DateTimeOffset.UtcNow:yyyyMMdd-HHmmss}-{Guid.NewGuid().ToString("N")[..6]}"; +} diff --git a/src/OpenKey.Core/Storage/JsonChatStore.cs b/src/OpenKey.Core/Storage/JsonChatStore.cs new file mode 100644 index 0000000..c270561 --- /dev/null +++ b/src/OpenKey.Core/Storage/JsonChatStore.cs @@ -0,0 +1,265 @@ +using System.Text.Json; +using OpenKey.Core.AppPaths; +using OpenKey.Core.Providers; + +namespace OpenKey.Core.Storage; + +/// +/// One JSON file per conversation under chats\, with index.json as a list cache. +/// +/// A folder of files rather than one large document, so a corrupt write costs a single +/// conversation instead of all of them — the same reasoning behind quarantining a bad session +/// rather than refusing to start. +/// +/// +/// index.json is a cache, never the source of truth. If it is missing, stale or +/// unreadable it is rebuilt by reading the chat files. That keeps a fast sidebar without creating +/// a second thing that can disagree with reality. +/// +/// +public sealed class JsonChatStore : IChatStore +{ + private readonly IAppPaths _paths; + private bool _migrated; + + public JsonChatStore(IAppPaths paths) => _paths = paths; + + private string Dir => Path.Combine(_paths.RootDir, "chats"); + + private string IndexFile => Path.Combine(Dir, "index.json"); + + private string FileFor(string id) => Path.Combine(Dir, id + ".json"); + + public async Task> ListAsync(CancellationToken ct) + { + await MigrateIfNeededAsync(ct); + + var index = ReadIndex(); + if (index is not null) return index; + + var rebuilt = await RebuildIndexAsync(ct); + WriteIndex(rebuilt); + return rebuilt; + } + + public async Task LoadAsync(string id, CancellationToken ct) + { + await MigrateIfNeededAsync(ct); + + var path = FileFor(id); + if (!File.Exists(path)) return null; + + try + { + await using var stream = File.OpenRead(path); + return await JsonSerializer.DeserializeAsync(stream, OpenKeyJsonContext.Default.Chat, ct); + } + catch (Exception ex) when (ex is JsonException or IOException or NotSupportedException) + { + Quarantine(path); + return null; + } + } + + public async Task SaveAsync(Chat chat, CancellationToken ct) + { + // Best-effort, like every store but the key: this runs right after a reply is generated + // and before it is shown, so a full disk must not cost the user their answer. + try + { + Directory.CreateDirectory(Dir); + + var path = FileFor(chat.Id); + var tmp = path + ".tmp"; + await using (var stream = File.Create(tmp)) + { + await JsonSerializer.SerializeAsync(stream, chat, OpenKeyJsonContext.Default.Chat, ct); + } + File.Move(tmp, path, overwrite: true); + + // Index follows the files; if this write fails the next List rebuilds it. + var summaries = (await RebuildIndexAsync(ct)); + WriteIndex(summaries); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + } + } + + public async Task DeleteAsync(string id, CancellationToken ct) + { + try + { + var path = FileFor(id); + if (File.Exists(path)) File.Delete(path); + WriteIndex(await RebuildIndexAsync(ct)); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + } + } + + public async Task MostRecentIdAsync(CancellationToken ct) + { + var all = await ListAsync(ct); + return all.Count > 0 ? all[0].Id : null; // ListAsync is newest-first + } + + public void Clear() + { + try + { + if (Directory.Exists(Dir)) Directory.Delete(Dir, recursive: true); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + } + } + + // ---- migration --------------------------------------------------------------------- + + /// + /// Turns a pre-history session.json into the first chat. + /// + /// The original is renamed rather than deleted, so a failure here can never be the reason + /// someone loses the only conversation they had. + /// + /// + private async Task MigrateIfNeededAsync(CancellationToken ct) + { + if (_migrated) return; + _migrated = true; + + try + { + var legacy = _paths.SessionFile; + if (!File.Exists(legacy) || Directory.Exists(Dir)) return; + + SessionSnapshot? snap; + await using (var stream = File.OpenRead(legacy)) + { + snap = await JsonSerializer.DeserializeAsync( + stream, OpenKeyJsonContext.Default.SessionSnapshot, ct); + } + + if (snap?.Turns is { Count: > 0 }) + { + var chat = new Chat( + IChatStore.NewId(), + Chat.TitleFrom(snap.Turns), + snap.ModelId, + snap.StartedAt, + snap.StartedAt, + snap.Turns); + + Directory.CreateDirectory(Dir); + await SaveAsync(chat, ct); + } + else + { + Directory.CreateDirectory(Dir); + } + + File.Move(legacy, legacy + ".migrated", overwrite: true); + } + catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException or NotSupportedException) + { + // A conversation that cannot be migrated is left exactly where it is. + } + } + + // ---- index ------------------------------------------------------------------------- + + private IReadOnlyList? ReadIndex() + { + if (!File.Exists(IndexFile)) return null; + + try + { + using var stream = File.OpenRead(IndexFile); + var envelope = JsonSerializer.Deserialize(stream, OpenKeyJsonContext.Default.ChatIndex); + if (envelope?.Chats is null) return null; + + // A cache that disagrees with the files is worse than no cache. Compare the actual + // ids, not just how many there are — an index with the right *number* of wrong + // entries would otherwise be trusted. + if (!Directory.Exists(Dir)) return null; + + var onDisk = Directory.GetFiles(Dir, "*.json") + .Select(Path.GetFileNameWithoutExtension) + .Where(name => !string.Equals(name, "index", StringComparison.OrdinalIgnoreCase)) + .ToHashSet(StringComparer.Ordinal); + + if (envelope.Chats.Count != onDisk.Count) return null; + foreach (var summary in envelope.Chats) + { + if (!onDisk.Contains(summary.Id)) return null; + } + + return envelope.Chats; + } + catch (Exception ex) when (ex is JsonException or IOException or NotSupportedException) + { + return null; + } + } + + private async Task> RebuildIndexAsync(CancellationToken ct) + { + if (!Directory.Exists(Dir)) return Array.Empty(); + + var summaries = new List(); + + foreach (var path in Directory.GetFiles(Dir, "*.json")) + { + if (string.Equals(Path.GetFileName(path), "index.json", StringComparison.OrdinalIgnoreCase)) + continue; + + try + { + await using var stream = File.OpenRead(path); + var chat = await JsonSerializer.DeserializeAsync(stream, OpenKeyJsonContext.Default.Chat, ct); + if (chat is null) continue; + + summaries.Add(new ChatSummary( + chat.Id, + string.IsNullOrWhiteSpace(chat.Title) ? Chat.Untitled : chat.Title, + chat.UpdatedAt, + chat.Turns.Count(t => t.Role != ChatMessage.SystemRole))); + } + catch (Exception ex) when (ex is JsonException or IOException or NotSupportedException) + { + Quarantine(path); + } + } + + summaries.Sort((a, b) => b.UpdatedAt.CompareTo(a.UpdatedAt)); + return summaries; + } + + private void WriteIndex(IReadOnlyList summaries) + { + try + { + Directory.CreateDirectory(Dir); + var tmp = IndexFile + ".tmp"; + using (var stream = File.Create(tmp)) + { + JsonSerializer.Serialize(stream, new ChatIndex(summaries), OpenKeyJsonContext.Default.ChatIndex); + } + File.Move(tmp, IndexFile, overwrite: true); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + } + } + + private static void Quarantine(string path) + { + try { File.Move(path, path + ".broken-" + DateTimeOffset.UtcNow.ToUnixTimeSeconds()); } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { } + } +} + +/// Cache of the chat list. Rebuildable from the chat files; never authoritative. +public sealed record ChatIndex(IReadOnlyList Chats); diff --git a/src/OpenKey.Core/Storage/OpenKeyJsonContext.cs b/src/OpenKey.Core/Storage/OpenKeyJsonContext.cs index 9c84660..58e4405 100644 --- a/src/OpenKey.Core/Storage/OpenKeyJsonContext.cs +++ b/src/OpenKey.Core/Storage/OpenKeyJsonContext.cs @@ -13,6 +13,8 @@ namespace OpenKey.Core.Storage; WriteIndented = true, PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] [JsonSerializable(typeof(SessionSnapshot))] +[JsonSerializable(typeof(Chat))] +[JsonSerializable(typeof(ChatIndex))] [JsonSerializable(typeof(OpenKeyConfig))] [JsonSerializable(typeof(JsonModelCatalog.CacheEnvelope))] [JsonSerializable(typeof(RotationPolicy.StateEnvelope))] diff --git a/src/OpenKey.Core/Storage/OpenKeyJsonContextAccessor.cs b/src/OpenKey.Core/Storage/OpenKeyJsonContextAccessor.cs new file mode 100644 index 0000000..88285a7 --- /dev/null +++ b/src/OpenKey.Core/Storage/OpenKeyJsonContextAccessor.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization.Metadata; + +namespace OpenKey.Core.Storage; + +/// +/// Exposes the generated type metadata to tests. The context itself stays internal because nothing +/// outside Core should be serialising these shapes, but tests need to write a fixture file in the +/// exact format the store will read. +/// +public static class OpenKeyJsonContextAccessor +{ + public static JsonTypeInfo Session => OpenKeyJsonContext.Default.SessionSnapshot; + + public static JsonTypeInfo Chat => OpenKeyJsonContext.Default.Chat; +} diff --git a/src/OpenKey.Gui/Program.cs b/src/OpenKey.Gui/Program.cs index 1180d78..409646e 100644 --- a/src/OpenKey.Gui/Program.cs +++ b/src/OpenKey.Gui/Program.cs @@ -27,7 +27,7 @@ public static int Main(string[] args) services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/OpenKey.Gui/ViewModels/MainWindowViewModel.cs b/src/OpenKey.Gui/ViewModels/MainWindowViewModel.cs index 4933fc8..c54ce0a 100644 --- a/src/OpenKey.Gui/ViewModels/MainWindowViewModel.cs +++ b/src/OpenKey.Gui/ViewModels/MainWindowViewModel.cs @@ -54,7 +54,7 @@ public MainWindowViewModel( Messages.CollectionChanged += (_, _) => Raise(nameof(IsConversationEmpty)); StopCommand = new RelayCommand(Stop); - ClearCommand = new RelayCommand(() => _ = ClearConversationAsync()); + NewChatCommand = new RelayCommand(() => _ = NewChatAsync()); } public ObservableCollection Messages { get; } = new(); @@ -64,6 +64,34 @@ public MainWindowViewModel( public ObservableCollection Models { get; } = new(); + /// Saved conversations, newest first. Drives the sidebar. + public ObservableCollection Chats { get; } = new(); + + private ChatSummary? _selectedChat; + + /// + /// The chat the sidebar highlights. Setting it opens that conversation; assigning the backing + /// field directly is how the code reflects a switch without re-triggering one. + /// + public ChatSummary? SelectedChat + { + get => _selectedChat; + set + { + if (!Set(ref _selectedChat, value) || value is null) return; + if (value.Id == _engine.CurrentChatId) return; + _ = OpenChatAsync(value.Id); + } + } + + private bool _showChats = true; + + public bool ShowChats + { + get => _showChats; + set => Set(ref _showChats, value); + } + private ModelChoice? _selectedModel; /// @@ -159,13 +187,8 @@ public async Task InitializeAsync() 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)); - } - + LoadMessagesFromEngine(); + await RefreshChatsAsync(); await LoadModelsAsync(); // Not awaited: the window is usable immediately, and a new version is never urgent. @@ -297,13 +320,6 @@ public async Task SendAsync() 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 }; @@ -378,7 +394,7 @@ await Dispatcher.UIThread.InvokeAsync(() => // calls the methods directly. public System.Windows.Input.ICommand StopCommand { get; } - public System.Windows.Input.ICommand ClearCommand { get; } + public System.Windows.Input.ICommand NewChatCommand { get; } /// Resends the last message. Goes through SendAsync so a retry takes the same path. public async Task RetryAsync() @@ -462,57 +478,82 @@ public void SetTheme(string? name) public void NotifyStatus(StatusKind kind, string message) => Show(kind, message); - private IReadOnlyList? _clearedTurns; - private MessageViewModel[]? _clearedMessages; + private void LoadMessagesFromEngine() + { + Messages.Clear(); + 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)); + } + } + + public async Task RefreshChatsAsync() + { + var chats = await _engine.ListChatsAsync(CancellationToken.None); + + Chats.Clear(); + foreach (var c in chats) Chats.Add(c); + + // Assign the field, not the property: the setter opens a chat, and this is only + // reflecting which one is already open. + _selectedChat = chats.FirstOrDefault(c => c.Id == _engine.CurrentChatId); + Raise(nameof(SelectedChat)); + Raise(nameof(HasChats)); + } - /// True while a cleared conversation can still be brought back. - public bool CanUndoClear => _clearedTurns is not null; + public bool HasChats => Chats.Count > 0; /// - /// 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. - /// + /// Starts a conversation alongside the existing ones. Nothing is destroyed, which is why this + /// needs no confirmation and no undo — the previous chat is still in the sidebar. /// - public async Task ClearConversationAsync() + public async Task NewChatAsync() + { + if (IsBusy) return; + + await _engine.NewSessionAsync(CancellationToken.None); + Messages.Clear(); + await RefreshChatsAsync(); + Status = null; + } + + public async Task OpenChatAsync(string id) { if (IsBusy) return; - if (Messages.Count == 0) + if (!await _engine.OpenChatAsync(id, CancellationToken.None)) { - Show(StatusKind.Info, "This chat is already empty."); + Show(StatusKind.Warn, "That chat couldn't be opened."); 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."); + LoadMessagesFromEngine(); + await RefreshChatsAsync(); + Status = null; } - public async Task UndoClearAsync() + public async Task DeleteChatAsync(ChatSummary chat) { - if (_clearedTurns is null || _clearedMessages is null) return; + if (IsBusy) return; - await _engine.RestoreTurnsAsync(_clearedTurns, CancellationToken.None); + await _engine.DeleteChatAsync(chat.Id, CancellationToken.None); + LoadMessagesFromEngine(); + await RefreshChatsAsync(); + Show(StatusKind.Ok, $"Deleted \"{chat.Title}\"."); + } - Messages.Clear(); - foreach (var m in _clearedMessages) Messages.Add(m); + public async Task RenameCurrentChatAsync(string title) + { + if (_engine.CurrentChatId is not { } id) return; - _clearedTurns = null; - _clearedMessages = null; - Raise(nameof(CanUndoClear)); - Show(StatusKind.Ok, "Chat restored."); + await _engine.RenameChatAsync(id, title, CancellationToken.None); + await RefreshChatsAsync(); } + public string CurrentChatTitle => _engine.CurrentChatTitle; + public void PinModel(ModelInfo? model) { _engine.PreferredModelId = model?.Id; diff --git a/src/OpenKey.Gui/Views/MainWindow.axaml b/src/OpenKey.Gui/Views/MainWindow.axaml index b5a7b39..d7f5da2 100644 --- a/src/OpenKey.Gui/Views/MainWindow.axaml +++ b/src/OpenKey.Gui/Views/MainWindow.axaml @@ -4,6 +4,7 @@ xmlns:conv="using:OpenKey.Gui.Converters" xmlns:views="using:OpenKey.Gui.Views" xmlns:core="using:OpenKey.Core.Providers" + xmlns:storage="using:OpenKey.Core.Storage" x:Class="OpenKey.Gui.Views.MainWindow" x:DataType="vm:MainWindowViewModel" Title="OpenKey" @@ -19,7 +20,7 @@ most: it is the one thing someone reaches for in a hurry. --> - + @@ -48,6 +49,8 @@ BorderBrush="{DynamicResource Line}" BorderThickness="0,0,0,1"> + -public sealed record MarkdownBlock(BlockKind Kind, string Text, string? Language = null, int Level = 0, int Indent = 0) +public sealed record MarkdownBlock( + BlockKind Kind, + string Text, + string? Language = null, + int Level = 0, + int Indent = 0, + IReadOnlyList? Spans = null) { + /// + /// The block's text split into styled runs. Falls back to one unstyled run, so a block built + /// without spans — a code fence, or the verbatim fallback after a parse failure — still renders. + /// + /// remains the plain-text form and stays the source for copy and export: a + /// pasted transcript should not carry styling the destination cannot honour. + /// + /// + public IReadOnlyList Runs => Spans ?? new[] { new InlineSpan(Text) }; + public bool IsCode => Kind == BlockKind.Code; public bool IsRule => Kind == BlockKind.Rule; public bool IsNotCode => Kind != BlockKind.Code && Kind != BlockKind.Rule; @@ -41,6 +58,18 @@ public sealed record MarkdownBlock(BlockKind Kind, string Text, string? Language _ => 14, }; + /// + /// Explicit leading. Avalonia's default is the font's own line spacing, which for Inter at + /// 14px is roughly 1.2× — fine for a label, too tight for paragraphs of prose, and the + /// clearest single tell of an interface nobody laid out. Headings take a tighter ratio + /// because larger type needs proportionally less air to stay one unit. + /// + public double LineHeight => Kind switch + { + BlockKind.Heading => Math.Round(FontSize * 1.3), + _ => Math.Round(FontSize * 1.55), + }; + public bool IsHeading => Kind == BlockKind.Heading; public bool IsQuote => Kind == BlockKind.Quote; @@ -76,7 +105,7 @@ 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)); + into.Add(new MarkdownBlock(BlockKind.Heading, Inline(h.Inline), Level: h.Level, Spans: BuildSpans(h.Inline))); break; case FencedCodeBlock fenced: @@ -94,7 +123,7 @@ private static void Walk(Block block, List into, int indent) foreach (var child in quote) { if (child is LeafBlock lb && lb.Inline is not null) - into.Add(new MarkdownBlock(BlockKind.Quote, Inline(lb.Inline))); + into.Add(new MarkdownBlock(BlockKind.Quote, Inline(lb.Inline), Spans: BuildSpans(lb.Inline))); else Walk(child, into, indent); } @@ -114,8 +143,14 @@ private static void Walk(Block block, List into, int indent) { if (first && child is ParagraphBlock p) { + // The marker is a span of its own so it never picks up the emphasis of + // the first word — "- **Done**" must not embolden the bullet. + var itemSpans = new List { new($"{marker} ") }; + itemSpans.AddRange(BuildSpans(p.Inline)); + into.Add(new MarkdownBlock( - BlockKind.ListItem, $"{marker} {Inline(p.Inline)}", Indent: indent)); + BlockKind.ListItem, $"{marker} {Inline(p.Inline)}", + Indent: indent, Spans: itemSpans)); first = false; } else @@ -132,7 +167,7 @@ private static void Walk(Block block, List into, int indent) break; case ParagraphBlock p2: - into.Add(new MarkdownBlock(BlockKind.Paragraph, Inline(p2.Inline))); + into.Add(new MarkdownBlock(BlockKind.Paragraph, Inline(p2.Inline), Spans: BuildSpans(p2.Inline))); break; case ContainerBlock container: @@ -140,11 +175,96 @@ private static void Walk(Block block, List into, int indent) break; case LeafBlock leaf when leaf.Inline is not null: - into.Add(new MarkdownBlock(BlockKind.Paragraph, Inline(leaf.Inline))); + into.Add(new MarkdownBlock(BlockKind.Paragraph, Inline(leaf.Inline), Spans: BuildSpans(leaf.Inline))); break; } } + /// + /// Flattens Markdig's inline tree into styled runs. + /// + /// Style is threaded down and OR-ed rather than replaced, because markdown nests: + /// ***x*** parses as bold wrapping italic, and reassigning would lose the outer one. + /// + /// + private static List BuildSpans(ContainerInline? container) + { + var spans = new List(); + if (container is not null) AppendSpans(spans, container, InlineStyle.None, null); + return spans; + } + + private static void AppendSpans(List into, Inline inline, InlineStyle style, string? url) + { + switch (inline) + { + case LiteralInline lit: + AddSpan(into, lit.Content.ToString(), style, url); + break; + + case CodeInline code: + AddSpan(into, code.Content, style | InlineStyle.Code, url); + break; + + case EmphasisInline em: + { + var added = em.DelimiterChar == '~' + ? InlineStyle.Strikethrough + : em.DelimiterCount >= 2 ? InlineStyle.Bold : InlineStyle.Italic; + + foreach (var child in em) AppendSpans(into, child, style | added, url); + break; + } + + case LinkInline link: + { + var target = string.IsNullOrEmpty(link.Url) ? url : link.Url; + var linkStyle = target is null ? style : style | InlineStyle.Link; + + var before = into.Count; + foreach (var child in link) AppendSpans(into, child, linkStyle, target); + + // A link with no label — or an image, whose alt text may be empty — would otherwise + // vanish entirely. Show the URL rather than nothing. + if (into.Count == before && target is not null) AddSpan(into, target, linkStyle, target); + break; + } + + case AutolinkInline auto: + AddSpan(into, auto.Url, style | InlineStyle.Link, auto.Url); + break; + + case LineBreakInline: + AddSpan(into, "\n", style, url); + break; + + case ContainerInline container: + foreach (var child in container) AppendSpans(into, child, style, url); + break; + } + } + + /// + /// Appends a run, merging it into the previous one when they share a style. Markdig emits + /// literals in fragments, so without this a plain sentence becomes a dozen runs. + /// + private static void AddSpan(List into, string text, InlineStyle style, string? url) + { + if (text.Length == 0) return; + + if (into.Count > 0) + { + var last = into[^1]; + if (last.Style == style && last.Url == url) + { + into[^1] = last with { Text = last.Text + text }; + return; + } + } + + into.Add(new InlineSpan(text, style, url)); + } + private static string Inline(ContainerInline? container) { if (container is null) return string.Empty; diff --git a/src/OpenKey.Gui/Views/CodeBlockView.axaml b/src/OpenKey.Gui/Views/CodeBlockView.axaml index 30c5451..a37e1b7 100644 --- a/src/OpenKey.Gui/Views/CodeBlockView.axaml +++ b/src/OpenKey.Gui/Views/CodeBlockView.axaml @@ -4,8 +4,10 @@ x:Class="OpenKey.Gui.Views.CodeBlockView" x:DataType="vm:MarkdownBlock"> + @@ -14,10 +16,11 @@ - @@ -115,13 +92,18 @@ - + - + + + - -