diff --git a/CHANGELOG.md b/CHANGELOG.md index 76d92bf..b144aa4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,41 @@ # Changelog +## [0.13.0] — 2026-07-24 + +Code-review fixes across both platforms, plus per-provider regenerate on +Windows. + +### Added +- Windows: "Regenerate with …" — regenerate the last answer through a + specific provider, from the regenerate menu. macOS has had this since + Epic C3. + +### Fixed +- Windows: fix random crashes when background work overlapped with the UI. + The store keeps a single SQLite connection, which is not thread-safe, but + it was used concurrently by the embedding worker, the folder watcher, the + retriever, and every `Task.Run` in the view models. All database access is + now serialized. (macOS was never affected — GRDB's `DatabaseQueue` already + serializes.) +- Windows: an assistant message is now tagged with the model that actually + produced it, instead of the model captured when the chat engine was built. +- Windows: the provider router no longer discards a provider-qualified model, + which is what "Regenerate with …" above needs to reach a chosen provider. +- Windows: a rate-limited provider's `Retry-After` is now honored instead of + retrying after the app's own much shorter backoff. +- Windows: backups are written with SQLite's online-backup API. The previous + code checkpointed a write-ahead log that was never enabled and then copied + the database file, which could capture a partial write. +- Both platforms: attachment reads and writes can no longer escape the + attachments folder. The filename was already reduced to a single path + component; the note-folder segment beside it was not, and it arrives from + the editor's `attachment://` URL — so a crafted link in a note reached one + directory above the attachments root. +- Both platforms: a retried answer no longer renders appended to the partial + text of the attempt that failed. +- Windows: a credential that cannot be removed from Credential Manager now + reports the failure instead of being silently treated as deleted. + ## [0.12.2] — 2026-07-19 Windows fixes. diff --git a/Sources/AINotebookApp/ChatView.swift b/Sources/AINotebookApp/ChatView.swift index 1b68fab..48d7826 100644 --- a/Sources/AINotebookApp/ChatView.swift +++ b/Sources/AINotebookApp/ChatView.swift @@ -508,10 +508,16 @@ struct ChatView: View { sessionId: sid, notebookId: notebook.id!, sourceIds: effectiveSourceIds, - model: model - ) { token in - Task { @MainActor in streamingDraft += token } - } + model: model, + onToken: { token in + Task { @MainActor in streamingDraft += token } + }, + onRetry: { + // Drop the failed attempt's partial text so the retry does + // not render appended to it. + Task { @MainActor in streamingDraft = "" } + } + ) await reloadMessages() } catch { errorMessage = providerErrorText(error, text: settings.text) @@ -547,10 +553,16 @@ struct ChatView: View { sourceIds: effectiveSourceIds, useWebSearch: settings.webSearchEnabled && useWebForNextMessage, model: activePersona?.model, - instructionsOverride: personaInstructions - ) { token in - Task { @MainActor in streamingDraft += token } - } + instructionsOverride: personaInstructions, + onToken: { token in + Task { @MainActor in streamingDraft += token } + }, + onRetry: { + // Drop the failed attempt's partial text so the retry does + // not render appended to it. + Task { @MainActor in streamingDraft = "" } + } + ) await reloadMessages() await generateFollowups(userText: text, answer: reply.content) } catch { diff --git a/Sources/AINotebookCore/AINotebookVersion.swift b/Sources/AINotebookCore/AINotebookVersion.swift index 8555e40..6536cbf 100644 --- a/Sources/AINotebookCore/AINotebookVersion.swift +++ b/Sources/AINotebookCore/AINotebookVersion.swift @@ -1,4 +1,4 @@ // Non-isolated top-level constant so it can be read from any actor context. // Must equal the repo-root VERSION file — AINotebookVersionTests enforces it. // Surfaced in Settings and used by the update checker. -public let AINotebookVersion = "0.12.2" +public let AINotebookVersion = "0.13.0" diff --git a/Sources/AINotebookCore/AttachmentStore.swift b/Sources/AINotebookCore/AttachmentStore.swift index 70ccea6..807542e 100644 --- a/Sources/AINotebookCore/AttachmentStore.swift +++ b/Sources/AINotebookCore/AttachmentStore.swift @@ -1,6 +1,12 @@ import Foundation import GRDB +public enum AttachmentStoreError: Error, Equatable, Sendable { + /// The note-folder segment reduced to a traversal component (`..`, `.`, or + /// empty) instead of a note UUID. + case invalidNoteFolder(String) +} + @MainActor public final class AttachmentStore { @@ -32,7 +38,7 @@ public final class AttachmentStore { mime: String, bytes: Data ) throws -> NoteAttachment { - let folder = root.appendingPathComponent(noteUuid, isDirectory: true) + let folder = root.appendingPathComponent(try Self.safeSegment(noteUuid), isDirectory: true) try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) let resolved = uniqueFilename(in: folder, requested: Self.safeFilename(filename)) let url = folder.appendingPathComponent(resolved) @@ -52,7 +58,7 @@ public final class AttachmentStore { public func read(noteUuid: String, filename: String) throws -> Data { let url = root - .appendingPathComponent(noteUuid, isDirectory: true) + .appendingPathComponent(try Self.safeSegment(noteUuid), isDirectory: true) .appendingPathComponent(Self.safeFilename(filename)) return try Data(contentsOf: url) } @@ -66,6 +72,23 @@ public final class AttachmentStore { return base } + /// The same reduction for the note-folder segment, which used to be joined + /// onto `root` raw — so a `..` reaching this far escaped the attachments + /// root by one level, into the directory holding the database. The scheme + /// handler hands us the URL host, so the value is not trusted. + /// + /// Unlike `safeFilename` this throws instead of substituting a default: + /// every legitimate caller passes a real note UUID, so anything reducing to + /// a traversal segment is a bug or an attack, and failing closed is the + /// only correct answer. + static func safeSegment(_ requested: String) throws -> String { + let base = (requested as NSString).lastPathComponent + guard !base.isEmpty, base != ".", base != ".." else { + throw AttachmentStoreError.invalidNoteFolder(requested) + } + return base + } + public func list(noteId: Int64) throws -> [NoteAttachment] { try store.runOnDatabase { db in try NoteAttachment @@ -76,7 +99,7 @@ public final class AttachmentStore { } public func deleteFolder(noteUuid: String) throws { - let folder = root.appendingPathComponent(noteUuid, isDirectory: true) + let folder = root.appendingPathComponent(try Self.safeSegment(noteUuid), isDirectory: true) if FileManager.default.fileExists(atPath: folder.path) { try FileManager.default.removeItem(at: folder) } diff --git a/Sources/AINotebookCore/ChatEngine.swift b/Sources/AINotebookCore/ChatEngine.swift index 30bb658..70f3b48 100644 --- a/Sources/AINotebookCore/ChatEngine.swift +++ b/Sources/AINotebookCore/ChatEngine.swift @@ -53,7 +53,8 @@ public actor ChatEngine { useWebSearch: Bool = false, model: String? = nil, instructionsOverride: String? = nil, - onToken: @escaping @Sendable (String) -> Void + onToken: @escaping @Sendable (String) -> Void, + onRetry: @escaping @Sendable () -> Void = {} ) async throws -> ChatMessage { // 1) Persist the user message. let storeRef = store @@ -74,7 +75,8 @@ public actor ChatEngine { sourceIds: sourceIds, useWebSearch: useWebSearch, instructionsOverride: instructionsOverride, - onToken: onToken + onToken: onToken, + onRetry: onRetry ) } @@ -88,7 +90,8 @@ public actor ChatEngine { currentNoteContent: String? = nil, sourceIds: Set = [], model: String? = nil, - onToken: @escaping @Sendable (String) -> Void + onToken: @escaping @Sendable (String) -> Void, + onRetry: @escaping @Sendable () -> Void = {} ) async throws -> ChatMessage { let storeRef = store let history = try await MainActor.run { try storeRef.messages(sessionId: sessionId) } @@ -108,7 +111,8 @@ public actor ChatEngine { model: model ?? chatModel, currentNoteContent: currentNoteContent, sourceIds: sourceIds, - onToken: onToken + onToken: onToken, + onRetry: onRetry ) } @@ -125,7 +129,8 @@ public actor ChatEngine { sourceIds: Set, useWebSearch: Bool = false, instructionsOverride: String? = nil, - onToken: @escaping @Sendable (String) -> Void + onToken: @escaping @Sendable (String) -> Void, + onRetry: @escaping @Sendable () -> Void = {} ) async throws -> ChatMessage { let storeRef = store // 2) Retrieve context. @@ -188,6 +193,10 @@ public actor ChatEngine { case .rateLimit(let retryAfterSeconds): if attempt >= retryAttempts { throw providerError } attempt += 1 + // Tokens from the failed attempt already reached + // onToken, so the caller's buffer holds a partial + // answer this retry would append to. Tell it to drop. + onRetry() let fallback = Double(retryBackoffMillis) * pow(2.0, Double(attempt - 1)) / 1000.0 let seconds = retryAfterSeconds ?? fallback try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) @@ -198,6 +207,7 @@ public actor ChatEngine { } if attempt >= retryAttempts { throw error } attempt += 1 + onRetry() let delayNs = UInt64(retryBackoffMillis * Int(pow(2.0, Double(attempt - 1)))) * 1_000_000 try? await Task.sleep(nanoseconds: delayNs) } diff --git a/Tests/AINotebookCoreTests/AttachmentStoreTraversalTests.swift b/Tests/AINotebookCoreTests/AttachmentStoreTraversalTests.swift new file mode 100644 index 0000000..e30bb85 --- /dev/null +++ b/Tests/AINotebookCoreTests/AttachmentStoreTraversalTests.swift @@ -0,0 +1,88 @@ +import XCTest +@testable import AINotebookCore + +/// The attachment filename has always been reduced to one path component, but +/// the note-folder segment beside it was joined onto the root raw. The scheme +/// handler passes the URL host straight in, so a crafted `attachment://../x` +/// image in a note resolved one level above the attachments root — the +/// directory holding the database. Windows has the same test in +/// AttachmentStoreTraversalTests.cs. +@MainActor +final class AttachmentStoreTraversalTests: XCTestCase { + + private func tempRoot() -> URL { + FileManager.default.temporaryDirectory + .appendingPathComponent("aino-att-trav-\(UUID().uuidString)") + } + + private func fixture() throws -> (atts: AttachmentStore, root: URL, noteId: Int64) { + let store = try NotebookStore(path: .inMemory) + let nb = try store.createNotebook(name: "NB") + // attachments.note_id is a real FK, so the row needs a real note. + let note = try store.createNote(notebookId: nb.id!, title: "T", bodyMd: "") + let root = tempRoot() + return (AttachmentStore(store: store, root: root), root, note.id!) + } + + /// Asserts the SPECIFIC rejection, not merely "it threw". A plain + /// XCTAssertThrowsError passes even with the fix removed, because reading + /// a path that happens not to exist throws too — the escape has to be + /// distinguished from a miss, hence the real file planted outside the root. + private func assertRejects( + _ label: String, _ body: () throws -> Void, + file: StaticString = #filePath, line: UInt = #line + ) { + XCTAssertThrowsError(try body(), label, file: file, line: line) { error in + guard let e = error as? AttachmentStoreError, case .invalidNoteFolder = e else { + return XCTFail("\(label): expected .invalidNoteFolder, got \(error)", + file: file, line: line) + } + } + } + + func testTraversalNoteFolderIsRejected() throws { + let (atts, root, noteId) = try fixture() + defer { try? FileManager.default.removeItem(at: root) } + + // A REAL file one level above the attachments root — what the old code + // handed back to the WebView. Without this the read would fail as + // "no such file" and mask a missing guard. + let escaped = root.deletingLastPathComponent().appendingPathComponent("aino-escaped.bin") + try Data([9, 9, 9]).write(to: escaped) + defer { try? FileManager.default.removeItem(at: escaped) } + + for uuid in ["..", ".", "", "../..", "foo/.."] { + let shown = uuid.isEmpty ? "" : uuid + assertRejects("read \(shown)") { + _ = try atts.read(noteUuid: uuid, filename: "aino-escaped.bin") + } + assertRejects("deleteFolder \(shown)") { try atts.deleteFolder(noteUuid: uuid) } + assertRejects("save \(shown)") { + _ = try atts.save(noteId: noteId, noteUuid: uuid, + filename: "planted.bin", mime: "image/png", + bytes: Data([1])) + } + } + + // The guard held: the outside file is untouched and no new one appeared + // beside it. + XCTAssertEqual(try Data(contentsOf: escaped), Data([9, 9, 9])) + let planted = root.deletingLastPathComponent().appendingPathComponent("planted.bin") + XCTAssertFalse(FileManager.default.fileExists(atPath: planted.path), + "traversal write escaped the attachments root") + } + + func testRoundTripsInsideTheNoteFolder() throws { + let (atts, root, noteId) = try fixture() + defer { try? FileManager.default.removeItem(at: root) } + + let uuid = UUID().uuidString.lowercased() + let bytes = Data([1, 2, 3]) + let att = try atts.save(noteId: noteId, noteUuid: uuid, + filename: "shot.png", mime: "image/png", bytes: bytes) + + XCTAssertEqual(try atts.read(noteUuid: uuid, filename: att.filename), bytes) + let onDisk = root.appendingPathComponent(uuid).appendingPathComponent(att.filename) + XCTAssertTrue(FileManager.default.fileExists(atPath: onDisk.path)) + } +} diff --git a/Tests/AINotebookCoreTests/ChatEngineRetryTests.swift b/Tests/AINotebookCoreTests/ChatEngineRetryTests.swift index d9739eb..dc12a4a 100644 --- a/Tests/AINotebookCoreTests/ChatEngineRetryTests.swift +++ b/Tests/AINotebookCoreTests/ChatEngineRetryTests.swift @@ -106,6 +106,60 @@ final class ChatEngineRetryTests: XCTestCase { XCTAssertEqual(chat.attempts, 1) } + /// Tokens from a failed attempt have already reached `onToken`, so the + /// caller's buffer holds a partial answer the retry would append to. The + /// engine must tell it to discard. Windows parity: + /// ChatEngineModelAndRetryTests.OnRetryFiresOncePerFailedAttempt. + func testOnRetryFiresOncePerFailedAttempt() async throws { + let (store, sessionId, notebookId) = try makeChatFixture() + let chat = FlakyChat(failuresRemaining: 2, tokens: ["ok"]) + let engine = makeEngine(store: store, chat: chat) + + // A stand-in for the view's streamingDraft: appended per token, cleared + // on retry. Without the clear it would read "partialpartialok". + actor Draft { + private(set) var text = "" + private(set) var clears = 0 + func append(_ t: String) { text += t } + func clear() { text = ""; clears += 1 } + } + let draft = Draft() + + _ = try await engine.send( + sessionId: sessionId, notebookId: notebookId, userText: "hi", + onToken: { t in Task { await draft.append(t) } }, + onRetry: { Task { await draft.clear() } } + ) + + XCTAssertEqual(chat.attempts, 3, "initial + 2 retries") + // Let the detached Tasks above settle before reading. + try await Task.sleep(nanoseconds: 50_000_000) + let clears = await draft.clears + XCTAssertEqual(clears, 2, "one clear per failed attempt") + } + + func testOnRetryIsNotFiredWhenTheFirstAttemptSucceeds() async throws { + let (store, sessionId, notebookId) = try makeChatFixture() + let chat = FlakyChat(failuresRemaining: 0, tokens: ["ok"]) + let engine = makeEngine(store: store, chat: chat) + + let counter = Counter() + _ = try await engine.send( + sessionId: sessionId, notebookId: notebookId, userText: "hi", + onToken: { _ in }, + onRetry: { Task { await counter.bump() } } + ) + + try await Task.sleep(nanoseconds: 50_000_000) + let n = await counter.value + XCTAssertEqual(n, 0) + } + + actor Counter { + private(set) var value = 0 + func bump() { value += 1 } + } + func testRateLimitRetriesWithServerHint() async throws { let (store, sessionId, notebookId) = try makeChatFixture() let chat = ThrowingChat(error: ProviderError.rateLimit(retryAfterSeconds: 0.01)) diff --git a/VERSION b/VERSION index 26acbf0..54d1a4f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.12.2 +0.13.0 diff --git a/windows/src/AINotebook.App/Services/ProviderRouter.cs b/windows/src/AINotebook.App/Services/ProviderRouter.cs index 3490bbb..511f38d 100644 --- a/windows/src/AINotebook.App/Services/ProviderRouter.cs +++ b/windows/src/AINotebook.App/Services/ProviderRouter.cs @@ -14,10 +14,15 @@ namespace AINotebook.App.Services; /// The two interfaces are handled differently (mirrors /// Sources/AINotebookCore/Providers/ProviderRouter.swift): /// -/// - (chat): the `model` parameter is ignored. -/// Legacy callers (ChatEngine etc.) capture their model at launch; the -/// router always reads the live (provider, model) selection so a Settings -/// change takes effect immediately on the next call. +/// - (chat): the `model` parameter is HONORED as a +/// composite `"{providerId}:{rawModel}"` key, but only when the prefix +/// before the first colon is a REAL provider id — raw chat model names +/// routinely contain colons themselves (`llama3.2:3b`), so a bare colon is +/// not enough to call something composite. That validated form is how FR-C3 +/// "regenerate with this model" reaches a specific provider. Everything else +/// (the default send path, whose callers captured their model at launch) +/// falls back to the live (provider, model) selection, so a Settings change +/// takes effect immediately on the next call. /// - : the `model` parameter is HONORED as a /// composite `"{providerId}:{rawModel}"` key when it contains a colon. /// snapshots this composite key @@ -70,12 +75,25 @@ public ProviderRouter( // ── IChatStreaming ────────────────────────────────────────────────────── public IAsyncEnumerable StreamAsync( - string model, // ignored — router reads live settings + string model, // provider-qualified key when it names a real provider — see class doc IReadOnlyList messages, CancellationToken ct = default) { - var providerId = _settings.SelectedChatProviderId; - var activeModel = _settings.SelectedChatModel; + string providerId; + string activeModel; + // Validate the prefix against the providers table before treating this + // as composite (macOS parity — ProviderRouter.swift's stream). Without + // the lookup, an ordinary Ollama tag like `llama3.2:3b` would be split + // into provider `llama3.2` / model `3b` and routed to the fallback. + if (ParseCompositeKey(model) is { } parsed && _store.Provider(parsed.ProviderId) is not null) + { + (providerId, activeModel) = parsed; + } + else + { + providerId = _settings.SelectedChatProviderId; + activeModel = _settings.SelectedChatModel; + } var adapter = GetChatAdapter(providerId); return adapter.StreamAsync(activeModel, messages, ct); } diff --git a/windows/src/AINotebook.App/Services/WindowsPasswordVaultSecretStore.cs b/windows/src/AINotebook.App/Services/WindowsPasswordVaultSecretStore.cs index 90a444a..dadf3d6 100644 --- a/windows/src/AINotebook.App/Services/WindowsPasswordVaultSecretStore.cs +++ b/windows/src/AINotebook.App/Services/WindowsPasswordVaultSecretStore.cs @@ -10,6 +10,10 @@ public sealed class WindowsPasswordVaultSecretStore : ISecretStore { private const string Resource = "AINotebook"; + /// HRESULT for ERROR_NOT_FOUND, which PasswordVault raises for a lookup + /// that matched nothing. The only failure either operation may ignore. + private const int ElementNotFound = unchecked((int)0x80070490); + public void Save(string id, string secret) { Delete(id); // PasswordVault throws on duplicate — remove first @@ -26,7 +30,7 @@ public void Save(string id, string secret) cred.RetrievePassword(); return cred.Password; } - catch (Exception ex) when (ex.HResult == unchecked((int)0x80070490)) + catch (Exception ex) when (ex.HResult == ElementNotFound) { // Element not found — key was never stored. return null; @@ -41,6 +45,13 @@ public void Delete(string id) var cred = vault.Retrieve(Resource, id); vault.Remove(cred); } - catch { /* not stored — nothing to remove */ } + catch (Exception ex) when (ex.HResult == ElementNotFound) + { + // Not stored — nothing to remove. + } + // Any other failure propagates. Swallowing everything here meant a + // credential that genuinely could not be removed looked deleted, and + // then Save()'s delete-first step silently left the old entry in place + // so the following Add() threw on the duplicate instead. } } diff --git a/windows/src/AINotebook.App/ViewModels/ChatViewModel.cs b/windows/src/AINotebook.App/ViewModels/ChatViewModel.cs index 136fe64..64168ec 100644 --- a/windows/src/AINotebook.App/ViewModels/ChatViewModel.cs +++ b/windows/src/AINotebook.App/ViewModels/ChatViewModel.cs @@ -57,6 +57,13 @@ public partial class ChatViewModel : ObservableObject // C2: source set picker. public ObservableCollection SourceSets { get; } = new(); + // C3: enabled providers offered in the "Regenerate with" menu, so the last + // answer can be re-generated through a specific provider (macOS parity — + // ChatView.swift's regenerate Menu). Empty means the menu shows only the + // plain Regenerate entry. + public ObservableCollection ChatProviders { get; } = new(); + public bool HasChatProviders => ChatProviders.Count > 0; + // E3: per-message web search toggle. [ObservableProperty] public partial bool UseWebSearch { get; set; } @@ -101,9 +108,23 @@ public async Task LoadAsync(long notebookId) _notebookId = notebookId; LoadScopeSources(); LoadSourceSets(); + LoadChatProviders(); await EnsureSessionsAsync(); } + // C3: providers for the "Regenerate with" menu — enabled ones only, same + // filter macOS applies (ChatView.swift: `store.providers().filter(\.enabled)`). + private void LoadChatProviders() + { + ChatProviders.Clear(); + try + { + foreach (var p in _store.Providers().Where(p => p.Enabled)) ChatProviders.Add(p); + } + catch { } + OnPropertyChanged(nameof(HasChatProviders)); + } + // C2: load saved source sets for the notebook. private void LoadSourceSets() { @@ -266,7 +287,22 @@ private void EditLast() // C3: regenerate — delete last exchange, re-send same question [RelayCommand] - private async Task RegenerateAsync() + private Task RegenerateAsync() => RegenerateCoreAsync(null); + + /// + /// C3: regenerate the last answer through a specific provider. The engine + /// gets a provider-qualified `"{providerId}:{model}"` key, which + /// validates against the + /// providers table and routes accordingly — and which the stored message is + /// then tagged with. Mirrors ChatView.swift's per-provider regenerate menu. + /// + [RelayCommand] + private Task RegenerateWithAsync(ProviderConfig? provider) => + provider is null + ? Task.CompletedTask + : RegenerateCoreAsync($"{provider.Id}:{_settings.SelectedChatModel}"); + + private async Task RegenerateCoreAsync(string? modelOverride) { if (SelectedSession?.Id is not { } sid) return; var lastUser = Messages.LastOrDefault(m => m.Message?.Role == ChatRole.User); @@ -275,7 +311,7 @@ private async Task RegenerateAsync() _store.DeleteLastExchange(sid); Input = text; IsEditMode = false; - await SendAsync(); + await SendCoreAsync(modelOverride); } // C3: commit edit — delete last exchange then send new text @@ -288,7 +324,7 @@ private async Task CommitEditAsync() _store.DeleteLastExchange(sid); Input = text; IsEditMode = false; - await SendAsync(); + await SendCoreAsync(null); } [RelayCommand] @@ -308,7 +344,14 @@ private void ShowCitations(MessageViewModel? vm) private void CloseCitationPanel() => IsCitationPanelOpen = false; [RelayCommand(CanExecute = nameof(CanSend))] - private async Task SendAsync() + private Task SendAsync() => SendCoreAsync(null); + + /// + /// Provider-qualified `"{providerId}:{model}"` key for FR-C3 regenerate- + /// with-model, or null for the default path (the router then uses the live + /// Settings selection). + /// + private async Task SendCoreAsync(string? modelOverride) { if (SelectedSession?.Id is not { } sid) return; var text = Input.Trim(); @@ -329,7 +372,11 @@ await _chatHolder.Engine.SendAsync( sid, _notebookId, text, currentNoteContent: null, sourceIds: SelectedSourceIds(), webResults: webResults, - onToken: token => _dispatcher.TryEnqueue(() => StreamingDraft += token)); + model: modelOverride, + onToken: token => _dispatcher.TryEnqueue(() => StreamingDraft += token), + // A retry restarts the answer from scratch, so drop whatever the + // failed attempt already streamed instead of appending to it. + onRetry: () => _dispatcher.TryEnqueue(() => StreamingDraft = "")); await ReloadMessagesAsync(); await GenerateFollowupsAsync(text); } diff --git a/windows/src/AINotebook.App/Views/ChatPage.xaml b/windows/src/AINotebook.App/Views/ChatPage.xaml index 27e1c4d..27f2167 100644 --- a/windows/src/AINotebook.App/Views/ChatPage.xaml +++ b/windows/src/AINotebook.App/Views/ChatPage.xaml @@ -191,11 +191,22 @@ Visibility="{x:Bind ViewModel.CanEditLast, Mode=OneWay, Converter={StaticResource BoolToVisibility}}"> - + + + + + + + + diff --git a/windows/src/AINotebook.App/Views/ChatPage.xaml.cs b/windows/src/AINotebook.App/Views/ChatPage.xaml.cs index c4522e2..5ce4c05 100644 --- a/windows/src/AINotebook.App/Views/ChatPage.xaml.cs +++ b/windows/src/AINotebook.App/Views/ChatPage.xaml.cs @@ -34,6 +34,12 @@ public ChatPage() CitationPanelTitle.Text = _t.Get(StringKey.CitationPanelTitle); ToolTipService.SetToolTip(EditLastButton, _t.Get(StringKey.ChatEditButton)); ToolTipService.SetToolTip(RegenerateButton, _t.Get(StringKey.ChatRegenerateButton)); + RegenerateMenuItem.Text = _t.Get(StringKey.ChatRegenerateButton); + RegenerateWithSubMenu.Text = _t.Get(StringKey.ChatRegenerateWithButton); + // MenuFlyoutSubItem.Items has no ItemsSource, so the per-provider + // entries are built in code whenever the provider list changes. + ViewModel.ChatProviders.CollectionChanged += (_, _) => RebuildRegenerateWithMenu(); + RebuildRegenerateWithMenu(); CommitEditButton.Content = _t.Get(StringKey.UnsavedSaveButton); CancelEditButton.Content = _t.Get(StringKey.CancelButton); SourceSetsLabel.Text = _t.Get(StringKey.SourceSetsSectionTitle); @@ -52,6 +58,23 @@ public ChatPage() // Called by the shell when the notebook changes (mirrors .task(id: notebook.id)). public async void Load(long notebookId) => await ViewModel.LoadAsync(notebookId); + /// C3: one entry per enabled provider; picking one regenerates the last + /// answer through that provider (the VM builds the provider-qualified key). + private void RebuildRegenerateWithMenu() + { + RegenerateWithSubMenu.Items.Clear(); + foreach (var provider in ViewModel.ChatProviders) + { + var item = new MenuFlyoutItem + { + Text = provider.Name, + Command = ViewModel.RegenerateWithCommand, + CommandParameter = provider + }; + RegenerateWithSubMenu.Items.Add(item); + } + } + private void ScrollToBottom() => MessagesScroller.ChangeView(null, MessagesScroller.ScrollableHeight, null); private void OnSendAccelerator(KeyboardAccelerator sender, KeyboardAcceleratorInvokedEventArgs args) diff --git a/windows/src/AINotebook.Core/AINotebookVersion.cs b/windows/src/AINotebook.Core/AINotebookVersion.cs index c88e653..8bb2a1b 100644 --- a/windows/src/AINotebook.Core/AINotebookVersion.cs +++ b/windows/src/AINotebook.Core/AINotebookVersion.cs @@ -4,5 +4,5 @@ namespace AINotebook.Core; /// file (copied into the test output) and enforces the match on every build. public static class AINotebookVersion { - public const string Current = "0.12.2"; + public const string Current = "0.13.0"; } diff --git a/windows/src/AINotebook.Core/Providers/OpenAIStyleWire.cs b/windows/src/AINotebook.Core/Providers/OpenAIStyleWire.cs index 3f96d9c..53f8988 100644 --- a/windows/src/AINotebook.Core/Providers/OpenAIStyleWire.cs +++ b/windows/src/AINotebook.Core/Providers/OpenAIStyleWire.cs @@ -56,11 +56,33 @@ internal static void ThrowForStatus(HttpResponseMessage resp) if (resp.StatusCode == HttpStatusCode.Unauthorized) throw new ProviderAuthException("Invalid API key (401)."); if (resp.StatusCode == (HttpStatusCode)429) - throw new ProviderRateLimitException("Rate limit exceeded (429)."); + throw new ProviderRateLimitException("Rate limit exceeded (429).", RetryAfter(resp)); if (!resp.IsSuccessStatusCode) throw new ProviderException($"HTTP {(int)resp.StatusCode}."); } + /// + /// The server's Retry-After hint, or null when it sent none. HTTP allows + /// both forms — delta-seconds and an HTTP-date — and HttpClient parses them + /// into Delta and Date respectively, so both are handled. + /// A past or negative date yields null rather than a negative delay. + /// (macOS reads the same header in ProviderWire.error(forStatus:), but only + /// the delta-seconds form.) + /// + private static TimeSpan? RetryAfter(HttpResponseMessage resp) + { + var header = resp.Headers.RetryAfter; + if (header is null) return null; + if (header.Delta is { } delta) + return delta > TimeSpan.Zero ? delta : null; + if (header.Date is { } date) + { + var wait = date - DateTimeOffset.UtcNow; + return wait > TimeSpan.Zero ? wait : null; + } + return null; + } + /// The one OpenAI-shape SSE runner: send, map status, split lines, parse /// deltas, honor [DONE]. internal static async IAsyncEnumerable StreamAsync( diff --git a/windows/src/AINotebook.Core/Providers/ProviderExceptions.cs b/windows/src/AINotebook.Core/Providers/ProviderExceptions.cs index e412dd3..e8f0458 100644 --- a/windows/src/AINotebook.Core/Providers/ProviderExceptions.cs +++ b/windows/src/AINotebook.Core/Providers/ProviderExceptions.cs @@ -2,7 +2,18 @@ namespace AINotebook.Core.Providers; public class ProviderException(string message, Exception? inner = null) : Exception(message, inner); public sealed class ProviderAuthException(string message) : ProviderException(message); -public sealed class ProviderRateLimitException(string message) : ProviderException(message); +/// +/// A 429 from the provider. carries the server's +/// Retry-After hint when it sent one, so ChatEngine can wait the +/// requested interval instead of hammering back after its own short backoff. +/// Mirrors Sources/AINotebookCore/Providers/ProviderError.swift's +/// .rateLimit(retryAfterSeconds:). +/// +public sealed class ProviderRateLimitException(string message, TimeSpan? retryAfter = null) + : ProviderException(message) +{ + public TimeSpan? RetryAfter { get; } = retryAfter; +} public sealed class ProviderRefusalException(string message) : ProviderException(message); /// diff --git a/windows/src/AINotebook.Core/Rag/ChatEngine.cs b/windows/src/AINotebook.Core/Rag/ChatEngine.cs index 0b16d4b..551e5b3 100644 --- a/windows/src/AINotebook.Core/Rag/ChatEngine.cs +++ b/windows/src/AINotebook.Core/Rag/ChatEngine.cs @@ -33,8 +33,15 @@ public async Task SendAsync( string? currentNoteContent = null, IReadOnlyCollection? sourceIds = null, IReadOnlyList? webResults = null, string? model = null, string? instructionsOverride = null, - Action? onToken = null, CancellationToken ct = default) + Action? onToken = null, Action? onRetry = null, CancellationToken ct = default) { + // The model actually used for this turn: an explicit override (FR-C3 + // regenerate-with-model) wins over the model captured when the engine + // was built. Resolved once so the network call and the row we persist + // can never disagree — they used to, because step 6 wrote ChatModel + // while step 4 streamed with `model ?? ChatModel`. + var activeModel = model ?? ChatModel; + // 1) Persist the user message. _store.AppendMessage(new ChatMessage(null, sessionId, ChatRole.User, userText, Array.Empty(), DateTime.UtcNow)); @@ -77,7 +84,7 @@ public async Task SendAsync( try { var partial = ""; - await foreach (var token in _chat.StreamAsync(model ?? ChatModel, turns, ct)) + await foreach (var token in _chat.StreamAsync(activeModel, turns, ct)) { partial += token; onToken?.Invoke(token); @@ -95,8 +102,18 @@ public async Task SendAsync( throw; if (attempt >= RetryAttempts) throw; attempt++; - var delayMs = RetryBackoffMillis * (int)Math.Pow(2, attempt - 1); - await Task.Delay(delayMs, ct); + + // Tokens from the attempt that just failed have already been + // handed to onToken, so the caller's buffer holds a partial + // answer that the retry would append to. Tell it to discard. + onRetry?.Invoke(); + + // Honor the server's Retry-After on a 429 instead of coming + // straight back after our own (much shorter) backoff — macOS + // parity, ChatEngine.swift's .rateLimit case. + var backoff = TimeSpan.FromMilliseconds(RetryBackoffMillis * Math.Pow(2, attempt - 1)); + var delay = (ex as ProviderRateLimitException)?.RetryAfter ?? backoff; + await Task.Delay(delay, ct); } } @@ -112,8 +129,9 @@ public async Task SendAsync( citations.Add(new Citation(m, h.ChunkId, h.SourceId, h.Snippet)); } - // 6) Persist the assistant message with the active chat model. - var stored = new ChatMessage(null, sessionId, ChatRole.Assistant, assembled, citations, DateTime.UtcNow, ChatModel); + // 6) Persist the assistant message tagged with the model that produced + // it (FR-C3) — activeModel, not the engine's construction-time default. + var stored = new ChatMessage(null, sessionId, ChatRole.Assistant, assembled, citations, DateTime.UtcNow, activeModel); _store.AppendMessage(stored); return stored; } diff --git a/windows/src/AINotebook.Core/Rag/Retriever.cs b/windows/src/AINotebook.Core/Rag/Retriever.cs index 63b61f5..8221d41 100644 --- a/windows/src/AINotebook.Core/Rag/Retriever.cs +++ b/windows/src/AINotebook.Core/Rag/Retriever.cs @@ -89,6 +89,18 @@ public async Task> SearchAsync( private List<(long ChunkId, long SourceId, string Snippet)> FtsTopK( long notebookId, string query, int k, IReadOnlyCollection? sourceIds) + { + // The store owns a single, non-thread-safe SqliteConnection and this + // runs on whatever thread the EmbedAsync continuation landed on, so + // hold the store's gate for the whole command. + lock (_store.Gate) + { + return FtsTopKLocked(notebookId, query, k, sourceIds); + } + } + + private List<(long ChunkId, long SourceId, string Snippet)> FtsTopKLocked( + long notebookId, string query, int k, IReadOnlyCollection? sourceIds) { var conn = _store.Connection; using var cmd = conn.CreateCommand(); @@ -128,9 +140,18 @@ ORDER BY bm25(chunks_fts) private Dictionary Snippets(IReadOnlyList chunkIds) { - var result = new Dictionary(); - if (chunkIds.Count == 0) return result; + if (chunkIds.Count == 0) return new Dictionary(); + // Same reason as FtsTopK: single shared connection, background thread. + lock (_store.Gate) + { + return SnippetsLocked(chunkIds); + } + } + + private Dictionary SnippetsLocked(IReadOnlyList chunkIds) + { + var result = new Dictionary(); var conn = _store.Connection; var placeholders = string.Join(",", chunkIds.Select((_, i) => "$p" + i)); using var cmd = conn.CreateCommand(); diff --git a/windows/src/AINotebook.Core/Storage/AttachmentStore.cs b/windows/src/AINotebook.Core/Storage/AttachmentStore.cs index 55a05fa..169b7d8 100644 --- a/windows/src/AINotebook.Core/Storage/AttachmentStore.cs +++ b/windows/src/AINotebook.Core/Storage/AttachmentStore.cs @@ -30,24 +30,30 @@ public static string DefaultRoot() public NoteAttachment Save(long noteId, string noteUuid, string filename, string mime, byte[] bytes) { - var folder = Path.Combine(Root, noteUuid); + var folder = Path.Combine(Root, SafeSegment(noteUuid)); Directory.CreateDirectory(folder); var resolved = UniqueFilename(folder, SafeFilename(filename)); File.WriteAllBytes(Path.Combine(folder, resolved), bytes); var now = DateTime.UtcNow; - var id = _store.Connection.ExecuteScalar( - """ - INSERT INTO attachments(note_id, note_uuid, filename, mime, byte_size, created_at) - VALUES($nid, $uuid, $file, $mime, $size, $created); - SELECT last_insert_rowid(); - """, - new { nid = noteId, uuid = noteUuid, file = resolved, mime, size = (long)bytes.Length, created = SqliteDate.ToDb(now) }); + // The store's SqliteConnection is not thread-safe and attachment saves + // arrive from the WebView2 message thread — take the store's gate. + long id; + lock (_store.Gate) + { + id = _store.Connection.ExecuteScalar( + """ + INSERT INTO attachments(note_id, note_uuid, filename, mime, byte_size, created_at) + VALUES($nid, $uuid, $file, $mime, $size, $created); + SELECT last_insert_rowid(); + """, + new { nid = noteId, uuid = noteUuid, file = resolved, mime, size = (long)bytes.Length, created = SqliteDate.ToDb(now) }); + } return new NoteAttachment(id, noteId, noteUuid, resolved, mime, bytes.Length, now); } public byte[] Read(string noteUuid, string filename) => - File.ReadAllBytes(Path.Combine(Root, noteUuid, SafeFilename(filename))); + File.ReadAllBytes(Path.Combine(Root, SafeSegment(noteUuid), SafeFilename(filename))); /// /// Reduces an attachment name to a single safe path component, defeating @@ -62,18 +68,42 @@ private static string SafeFilename(string requested) return name; } - public IReadOnlyList List(long noteId) => - _store.Connection.Query( - "SELECT id, note_id, note_uuid, filename, mime, byte_size, created_at FROM attachments WHERE note_id=$nid ORDER BY created_at ASC", - new { nid = noteId }) - .Select(r => new NoteAttachment( - (long)r.id, (long)r.note_id, (string)r.note_uuid, (string)r.filename, - (string)r.mime, (long)r.byte_size, SqliteDate.FromDb((string)r.created_at))) - .ToList(); + /// + /// Same reduction for the note-folder segment, which used to be passed to + /// Path.Combine raw. That was exploitable: on the serving side the value is + /// uri.Host from the editor's attachment:// scheme, and .NET parses + /// attachment://../db.sqlite into Host == ".." — so a crafted + /// image URL in a note resolved one level above the attachments root, into + /// the directory holding db.sqlite and settings.json. + /// + /// Unlike this throws rather than substituting a + /// default: every legitimate caller passes a real note UUID, so anything + /// that reduces to a traversal segment is a bug or an attack, and failing + /// closed is the only correct answer. + /// + private static string SafeSegment(string requested) + { + var name = Path.GetFileName(requested); + if (string.IsNullOrEmpty(name) || name == "." || name == "..") + throw new ArgumentException($"Invalid attachment folder segment: '{requested}'.", nameof(requested)); + return name; + } + + public IReadOnlyList List(long noteId) + { + lock (_store.Gate) + return _store.Connection.Query( + "SELECT id, note_id, note_uuid, filename, mime, byte_size, created_at FROM attachments WHERE note_id=$nid ORDER BY created_at ASC", + new { nid = noteId }) + .Select(r => new NoteAttachment( + (long)r.id, (long)r.note_id, (string)r.note_uuid, (string)r.filename, + (string)r.mime, (long)r.byte_size, SqliteDate.FromDb((string)r.created_at))) + .ToList(); + } public void DeleteFolder(string noteUuid) { - var folder = Path.Combine(Root, noteUuid); + var folder = Path.Combine(Root, SafeSegment(noteUuid)); if (Directory.Exists(folder)) Directory.Delete(folder, recursive: true); } diff --git a/windows/src/AINotebook.Core/Storage/NotebookStore.Chat.cs b/windows/src/AINotebook.Core/Storage/NotebookStore.Chat.cs index 38e8879..adf5707 100644 --- a/windows/src/AINotebook.Core/Storage/NotebookStore.Chat.cs +++ b/windows/src/AINotebook.Core/Storage/NotebookStore.Chat.cs @@ -17,67 +17,82 @@ private sealed record CitationJson( public ChatSession CreateChatSession(long notebookId, string title) { - var trimmed = title.Trim(); - var resolved = trimmed.Length == 0 ? "New chat" : trimmed; - var now = DateTime.UtcNow; - var id = Connection.ExecuteScalar( - """ - INSERT INTO chat_sessions(notebook_id, title, created_at) - VALUES($nb, $title, $created); - SELECT last_insert_rowid(); - """, - new { nb = notebookId, title = resolved, created = SqliteDate.ToDb(now) }); - return new ChatSession(id, notebookId, resolved, now); + lock (_gate) + { + var trimmed = title.Trim(); + var resolved = trimmed.Length == 0 ? "New chat" : trimmed; + var now = DateTime.UtcNow; + var id = Connection.ExecuteScalar( + """ + INSERT INTO chat_sessions(notebook_id, title, created_at) + VALUES($nb, $title, $created); + SELECT last_insert_rowid(); + """, + new { nb = notebookId, title = resolved, created = SqliteDate.ToDb(now) }); + return new ChatSession(id, notebookId, resolved, now); + } } - public IReadOnlyList ChatSessions(long notebookId) => - Connection.Query( - "SELECT id, notebook_id, title, created_at FROM chat_sessions WHERE notebook_id=$nb ORDER BY created_at DESC", - new { nb = notebookId }) - .Select(r => new ChatSession((long)r.id, (long)r.notebook_id, (string)r.title, - SqliteDate.FromDb((string)r.created_at))) - .ToList(); + public IReadOnlyList ChatSessions(long notebookId) + { + lock (_gate) + return Connection.Query( + "SELECT id, notebook_id, title, created_at FROM chat_sessions WHERE notebook_id=$nb ORDER BY created_at DESC", + new { nb = notebookId }) + .Select(r => new ChatSession((long)r.id, (long)r.notebook_id, (string)r.title, + SqliteDate.FromDb((string)r.created_at))) + .ToList(); + } - public void DeleteChatSession(long id) => - Connection.Execute("DELETE FROM chat_sessions WHERE id=$id", new { id }); + public void DeleteChatSession(long id) + { + lock (_gate) + Connection.Execute("DELETE FROM chat_sessions WHERE id=$id", new { id }); + } public void AppendMessage(ChatMessage message) { - string? json = message.Citations.Count == 0 - ? null - : JsonSerializer.Serialize( - message.Citations.Select(c => new CitationJson(c.Marker, c.ChunkId, c.SourceId, c.Snippet)).ToList()); - Connection.Execute( - """ - INSERT INTO messages(session_id, role, content, citations_json, created_at, model) - VALUES($sid, $role, $content, $cit, $created, $model) - """, - new - { - sid = message.SessionId, role = message.Role.ToDb(), content = message.Content, - cit = json, created = SqliteDate.ToDb(message.CreatedAt), model = message.Model - }); + lock (_gate) + { + string? json = message.Citations.Count == 0 + ? null + : JsonSerializer.Serialize( + message.Citations.Select(c => new CitationJson(c.Marker, c.ChunkId, c.SourceId, c.Snippet)).ToList()); + Connection.Execute( + """ + INSERT INTO messages(session_id, role, content, citations_json, created_at, model) + VALUES($sid, $role, $content, $cit, $created, $model) + """, + new + { + sid = message.SessionId, role = message.Role.ToDb(), content = message.Content, + cit = json, created = SqliteDate.ToDb(message.CreatedAt), model = message.Model + }); + } } - public IReadOnlyList Messages(long sessionId) => - Connection.Query( - "SELECT id, session_id, role, content, citations_json, created_at, model FROM messages WHERE session_id=$sid ORDER BY created_at ASC", - new { sid = sessionId }) - .Select(r => - { - IReadOnlyList cits = Array.Empty(); - if (r.citations_json is string raw && raw.Length > 0) + public IReadOnlyList Messages(long sessionId) + { + lock (_gate) + return Connection.Query( + "SELECT id, session_id, role, content, citations_json, created_at, model FROM messages WHERE session_id=$sid ORDER BY created_at ASC", + new { sid = sessionId }) + .Select(r => { - var decoded = JsonSerializer.Deserialize>(raw); - if (decoded is not null) - cits = decoded.Select(c => new Citation(c.Marker, c.ChunkId, c.SourceId, c.Snippet)).ToList(); - } - return new ChatMessage( - (long)r.id, (long)r.session_id, ChatRoleExtensions.FromDb((string)r.role), - (string)r.content, cits, SqliteDate.FromDb((string)r.created_at), - r.model is null ? null : (string)r.model); - }) - .ToList(); + IReadOnlyList cits = Array.Empty(); + if (r.citations_json is string raw && raw.Length > 0) + { + var decoded = JsonSerializer.Deserialize>(raw); + if (decoded is not null) + cits = decoded.Select(c => new Citation(c.Marker, c.ChunkId, c.SourceId, c.Snippet)).ToList(); + } + return new ChatMessage( + (long)r.id, (long)r.session_id, ChatRoleExtensions.FromDb((string)r.role), + (string)r.content, cits, SqliteDate.FromDb((string)r.created_at), + r.model is null ? null : (string)r.model); + }) + .ToList(); + } /// /// Deletes the last user+assistant pair from a session (for edit/regenerate). @@ -85,13 +100,16 @@ public IReadOnlyList Messages(long sessionId) => /// public void DeleteLastExchange(long sessionId) { - using var tx = Connection.BeginTransaction(); - var ids = Connection.Query( - "SELECT id FROM messages WHERE session_id=$sid ORDER BY created_at DESC LIMIT 2", - new { sid = sessionId }, tx) - .ToList(); - foreach (var id in ids) - Connection.Execute("DELETE FROM messages WHERE id=$id", new { id }, tx); - tx.Commit(); + lock (_gate) + { + using var tx = Connection.BeginTransaction(); + var ids = Connection.Query( + "SELECT id FROM messages WHERE session_id=$sid ORDER BY created_at DESC LIMIT 2", + new { sid = sessionId }, tx) + .ToList(); + foreach (var id in ids) + Connection.Execute("DELETE FROM messages WHERE id=$id", new { id }, tx); + tx.Commit(); + } } } diff --git a/windows/src/AINotebook.Core/Storage/NotebookStore.Embeddings.cs b/windows/src/AINotebook.Core/Storage/NotebookStore.Embeddings.cs index 5e3c048..69bfbbe 100644 --- a/windows/src/AINotebook.Core/Storage/NotebookStore.Embeddings.cs +++ b/windows/src/AINotebook.Core/Storage/NotebookStore.Embeddings.cs @@ -7,65 +7,80 @@ public sealed partial class NotebookStore { public void StoreEmbedding(long chunkId, string model, EmbeddingVector vector) { - Connection.Execute( - """ - INSERT INTO chunk_embeddings(chunk_id, dim, model, embedding) - VALUES($cid, $dim, $model, $emb) - ON CONFLICT(chunk_id) DO UPDATE SET - dim = excluded.dim, - model = excluded.model, - embedding = excluded.embedding - """, - new { cid = chunkId, dim = vector.Dim, model, emb = vector.ToBytes() }); + lock (_gate) + { + Connection.Execute( + """ + INSERT INTO chunk_embeddings(chunk_id, dim, model, embedding) + VALUES($cid, $dim, $model, $emb) + ON CONFLICT(chunk_id) DO UPDATE SET + dim = excluded.dim, + model = excluded.model, + embedding = excluded.embedding + """, + new { cid = chunkId, dim = vector.Dim, model, emb = vector.ToBytes() }); + } } public IReadOnlyList Embeddings(long notebookId, string model) { - return Connection.Query( - """ - SELECT ce.chunk_id AS chunk_id, sc.source_id AS source_id, ce.embedding AS embedding - FROM chunk_embeddings ce - JOIN source_chunks sc ON sc.id = ce.chunk_id - JOIN sources s ON s.id = sc.source_id - WHERE s.notebook_id = $nb AND ce.model = $model - """, - new { nb = notebookId, model }) - .Select(r => new StoredEmbedding( - (long)r.chunk_id, (long)r.source_id, - EmbeddingVector.FromBytes((byte[])r.embedding))) - .ToList(); + lock (_gate) + { + return Connection.Query( + """ + SELECT ce.chunk_id AS chunk_id, sc.source_id AS source_id, ce.embedding AS embedding + FROM chunk_embeddings ce + JOIN source_chunks sc ON sc.id = ce.chunk_id + JOIN sources s ON s.id = sc.source_id + WHERE s.notebook_id = $nb AND ce.model = $model + """, + new { nb = notebookId, model }) + .Select(r => new StoredEmbedding( + (long)r.chunk_id, (long)r.source_id, + EmbeddingVector.FromBytes((byte[])r.embedding))) + .ToList(); + } } public IReadOnlyList UnembeddedChunks(string model, int limit) { - return Connection.Query( - """ - SELECT sc.id AS id, sc.source_id AS source_id, sc.ord AS ord, - sc.text AS text, sc.token_count AS token_count, sc.page_hint AS page_hint - FROM source_chunks sc - LEFT JOIN chunk_embeddings ce ON ce.chunk_id = sc.id AND ce.model = $model - WHERE ce.chunk_id IS NULL - ORDER BY sc.id ASC - LIMIT $limit - """, - new { model, limit }) - .Select(r => new SourceChunk( - (long)r.id, (long)r.source_id, (int)(long)r.ord, (string)r.text, - (int)(long)r.token_count, r.page_hint is null ? (int?)null : (int)(long)r.page_hint)) - .ToList(); + lock (_gate) + { + return Connection.Query( + """ + SELECT sc.id AS id, sc.source_id AS source_id, sc.ord AS ord, + sc.text AS text, sc.token_count AS token_count, sc.page_hint AS page_hint + FROM source_chunks sc + LEFT JOIN chunk_embeddings ce ON ce.chunk_id = sc.id AND ce.model = $model + WHERE ce.chunk_id IS NULL + ORDER BY sc.id ASC + LIMIT $limit + """, + new { model, limit }) + .Select(r => new SourceChunk( + (long)r.id, (long)r.source_id, (int)(long)r.ord, (string)r.text, + (int)(long)r.token_count, r.page_hint is null ? (int?)null : (int)(long)r.page_hint)) + .ToList(); + } } public int UnembeddedCount(string model) { - return Connection.ExecuteScalar( - """ - SELECT count(*) FROM source_chunks sc - LEFT JOIN chunk_embeddings ce ON ce.chunk_id = sc.id AND ce.model = $model - WHERE ce.chunk_id IS NULL - """, - new { model }); + lock (_gate) + { + return Connection.ExecuteScalar( + """ + SELECT count(*) FROM source_chunks sc + LEFT JOIN chunk_embeddings ce ON ce.chunk_id = sc.id AND ce.model = $model + WHERE ce.chunk_id IS NULL + """, + new { model }); + } } - public void DeleteAllEmbeddings(string model) => - Connection.Execute("DELETE FROM chunk_embeddings WHERE model = $model", new { model }); + public void DeleteAllEmbeddings(string model) + { + lock (_gate) + Connection.Execute("DELETE FROM chunk_embeddings WHERE model = $model", new { model }); + } } diff --git a/windows/src/AINotebook.Core/Storage/NotebookStore.NoteVersions.cs b/windows/src/AINotebook.Core/Storage/NotebookStore.NoteVersions.cs index f655a15..8c6fe37 100644 --- a/windows/src/AINotebook.Core/Storage/NotebookStore.NoteVersions.cs +++ b/windows/src/AINotebook.Core/Storage/NotebookStore.NoteVersions.cs @@ -7,57 +7,66 @@ public sealed partial class NotebookStore { public const int NoteVersionCap = 50; - public IReadOnlyList NoteVersions(long noteId) => - Connection.Query( - "SELECT id, note_id, title, body_md, saved_at, reason FROM note_versions WHERE note_id=$nid ORDER BY saved_at ASC", - new { nid = noteId }) - .Select(r => new NoteVersion( - (long)r.id, (long)r.note_id, (string)r.title, (string)r.body_md, - SqliteDate.FromDb((string)r.saved_at), - NoteVersionReasonExtensions.FromDb((string)r.reason))) - .ToList(); - - public NoteVersion? SnapshotNoteVersion(long noteId, NoteVersionReason reason) + public IReadOnlyList NoteVersions(long noteId) { - var note = Note(noteId); - if (note is null) return null; - var savedAt = DateTime.UtcNow; - var id = Connection.ExecuteScalar( - """ - INSERT INTO note_versions(note_id, title, body_md, saved_at, reason) - VALUES($nid, $title, $body, $saved, $reason); - SELECT last_insert_rowid(); - """, - new { nid = noteId, title = note.Title, body = note.BodyMd, saved = SqliteDate.ToDb(savedAt), reason = reason.ToDb() }); - PruneIfNeeded(noteId); - return new NoteVersion(id, noteId, note.Title, note.BodyMd, savedAt, reason); + lock (_gate) + return Connection.Query( + "SELECT id, note_id, title, body_md, saved_at, reason FROM note_versions WHERE note_id=$nid ORDER BY saved_at ASC", + new { nid = noteId }) + .Select(r => new NoteVersion( + (long)r.id, (long)r.note_id, (string)r.title, (string)r.body_md, + SqliteDate.FromDb((string)r.saved_at), + NoteVersionReasonExtensions.FromDb((string)r.reason))) + .ToList(); } - public void RestoreNoteVersion(long versionId) + public NoteVersion? SnapshotNoteVersion(long noteId, NoteVersionReason reason) { - var row = Connection.QueryFirstOrDefault( - "SELECT note_id, title, body_md FROM note_versions WHERE id=$id", new { id = versionId }); - if (row is null) return; - long noteId = (long)row.note_id; - var current = Note(noteId); - if (current is not null) + lock (_gate) { - Connection.Execute( + var note = Note(noteId); + if (note is null) return null; + var savedAt = DateTime.UtcNow; + var id = Connection.ExecuteScalar( """ INSERT INTO note_versions(note_id, title, body_md, saved_at, reason) - VALUES($nid, $title, $body, $saved, $reason) + VALUES($nid, $title, $body, $saved, $reason); + SELECT last_insert_rowid(); """, - new - { - nid = noteId, title = current.Title, body = current.BodyMd, - saved = SqliteDate.ToDb(DateTime.UtcNow), reason = NoteVersionReason.Restore.ToDb() - }); + new { nid = noteId, title = note.Title, body = note.BodyMd, saved = SqliteDate.ToDb(savedAt), reason = reason.ToDb() }); PruneIfNeeded(noteId); + return new NoteVersion(id, noteId, note.Title, note.BodyMd, savedAt, reason); + } + } + + public void RestoreNoteVersion(long versionId) + { + lock (_gate) + { + var row = Connection.QueryFirstOrDefault( + "SELECT note_id, title, body_md FROM note_versions WHERE id=$id", new { id = versionId }); + if (row is null) return; + long noteId = (long)row.note_id; + var current = Note(noteId); + if (current is not null) + { + Connection.Execute( + """ + INSERT INTO note_versions(note_id, title, body_md, saved_at, reason) + VALUES($nid, $title, $body, $saved, $reason) + """, + new + { + nid = noteId, title = current.Title, body = current.BodyMd, + saved = SqliteDate.ToDb(DateTime.UtcNow), reason = NoteVersionReason.Restore.ToDb() + }); + PruneIfNeeded(noteId); + } + Connection.Execute( + "UPDATE notes SET title=$title, body_md=$body, updated_at=$updated WHERE id=$id", + new { title = (string)row.title, body = (string)row.body_md, updated = SqliteDate.ToDb(DateTime.UtcNow), id = noteId }); + FireNoteSaved(noteId); } - Connection.Execute( - "UPDATE notes SET title=$title, body_md=$body, updated_at=$updated WHERE id=$id", - new { title = (string)row.title, body = (string)row.body_md, updated = SqliteDate.ToDb(DateTime.UtcNow), id = noteId }); - FireNoteSaved(noteId); } private void PruneIfNeeded(long noteId) diff --git a/windows/src/AINotebook.Core/Storage/NotebookStore.Notes.cs b/windows/src/AINotebook.Core/Storage/NotebookStore.Notes.cs index d936f3d..6d60e22 100644 --- a/windows/src/AINotebook.Core/Storage/NotebookStore.Notes.cs +++ b/windows/src/AINotebook.Core/Storage/NotebookStore.Notes.cs @@ -19,59 +19,77 @@ public sealed partial class NotebookStore public Note CreateNote(long notebookId, string title, string bodyMd, NoteOrigin origin = NoteOrigin.Manual, long? originRef = null) { - var now = Now(); - var uuid = Guid.NewGuid().ToString().ToLowerInvariant(); - var trimmed = title.Trim(); - var id = Connection.ExecuteScalar( - """ - INSERT INTO notes(notebook_id, title, body_md, origin, origin_ref, note_uuid, created_at, updated_at) - VALUES($nb, $title, $body, $origin, $ref, $uuid, $created, $updated); - SELECT last_insert_rowid(); - """, - new - { - nb = notebookId, title = trimmed, body = bodyMd, origin = origin.ToDb(), - @ref = originRef, uuid, created = SqliteDate.ToDb(now), updated = SqliteDate.ToDb(now) - }); - FireNoteSaved(id); - return new Note(id, notebookId, trimmed, bodyMd, origin, originRef, null, uuid, now, now); + lock (_gate) + { + var now = Now(); + var uuid = Guid.NewGuid().ToString().ToLowerInvariant(); + var trimmed = title.Trim(); + var id = Connection.ExecuteScalar( + """ + INSERT INTO notes(notebook_id, title, body_md, origin, origin_ref, note_uuid, created_at, updated_at) + VALUES($nb, $title, $body, $origin, $ref, $uuid, $created, $updated); + SELECT last_insert_rowid(); + """, + new + { + nb = notebookId, title = trimmed, body = bodyMd, origin = origin.ToDb(), + @ref = originRef, uuid, created = SqliteDate.ToDb(now), updated = SqliteDate.ToDb(now) + }); + FireNoteSaved(id); + return new Note(id, notebookId, trimmed, bodyMd, origin, originRef, null, uuid, now, now); + } } - public IReadOnlyList Notes(long notebookId) => - Connection.Query( - $"SELECT {NoteCols} FROM notes WHERE notebook_id=$nb ORDER BY updated_at DESC", - new { nb = notebookId }) - .Select(r => (Note)MapNote(r)).ToList(); + public IReadOnlyList Notes(long notebookId) + { + lock (_gate) + return Connection.Query( + $"SELECT {NoteCols} FROM notes WHERE notebook_id=$nb ORDER BY updated_at DESC", + new { nb = notebookId }) + .Select(r => (Note)MapNote(r)).ToList(); + } public Note? Note(long id) { - var row = Connection.QueryFirstOrDefault($"SELECT {NoteCols} FROM notes WHERE id=$id", new { id }); - return row is null ? null : MapNote(row); + lock (_gate) + { + var row = Connection.QueryFirstOrDefault($"SELECT {NoteCols} FROM notes WHERE id=$id", new { id }); + return row is null ? null : MapNote(row); + } } public void UpdateNote(long id, string title, string bodyMd) { - // Snapshot the PRE-update content as an autosave version FIRST. - SnapshotNoteVersion(id, NoteVersionReason.Autosave); - Connection.Execute( - "UPDATE notes SET title=$title, body_md=$body, updated_at=$updated WHERE id=$id", - new { title = title.Trim(), body = bodyMd, updated = SqliteDate.ToDb(Now()), id }); - FireNoteSaved(id); + lock (_gate) + { + // Snapshot the PRE-update content as an autosave version FIRST. + SnapshotNoteVersion(id, NoteVersionReason.Autosave); + Connection.Execute( + "UPDATE notes SET title=$title, body_md=$body, updated_at=$updated WHERE id=$id", + new { title = title.Trim(), body = bodyMd, updated = SqliteDate.ToDb(Now()), id }); + FireNoteSaved(id); + } } public string? DeleteNote(long id) { - var uuid = Connection.QueryFirstOrDefault( - "SELECT note_uuid FROM notes WHERE id=$id", new { id }); - Connection.Execute("DELETE FROM notes WHERE id=$id", new { id }); - if (uuid is not null && OnNoteDeleted is not null) - _ = OnNoteDeleted(uuid); - return uuid; + lock (_gate) + { + var uuid = Connection.QueryFirstOrDefault( + "SELECT note_uuid FROM notes WHERE id=$id", new { id }); + Connection.Execute("DELETE FROM notes WHERE id=$id", new { id }); + if (uuid is not null && OnNoteDeleted is not null) + _ = OnNoteDeleted(uuid); + return uuid; + } } - public void LinkNoteToShadowSource(long noteId, long sourceId) => - Connection.Execute("UPDATE notes SET auto_source_id=$src WHERE id=$id", - new { src = sourceId, id = noteId }); + public void LinkNoteToShadowSource(long noteId, long sourceId) + { + lock (_gate) + Connection.Execute("UPDATE notes SET auto_source_id=$src WHERE id=$id", + new { src = sourceId, id = noteId }); + } private void FireNoteSaved(long noteId) { diff --git a/windows/src/AINotebook.Core/Storage/NotebookStore.Personas.cs b/windows/src/AINotebook.Core/Storage/NotebookStore.Personas.cs index 409124a..f63b998 100644 --- a/windows/src/AINotebook.Core/Storage/NotebookStore.Personas.cs +++ b/windows/src/AINotebook.Core/Storage/NotebookStore.Personas.cs @@ -5,40 +5,52 @@ namespace AINotebook.Core.Storage; public sealed partial class NotebookStore { - public IReadOnlyList Personas(long notebookId) => - Connection.Query( - "SELECT id, notebook_id, name, instructions, source_set_id, model, created_at FROM personas WHERE notebook_id=$nb ORDER BY name ASC", - new { nb = notebookId }) - .Select(r => new Persona( - (long)r.id, - (long)r.notebook_id, - (string)r.name, - (string)r.instructions, - r.source_set_id is null ? (long?)null : (long)r.source_set_id, - r.model is null ? null : (string)r.model, - SqliteDate.FromDb((string)r.created_at))) - .ToList(); + public IReadOnlyList Personas(long notebookId) + { + lock (_gate) + return Connection.Query( + "SELECT id, notebook_id, name, instructions, source_set_id, model, created_at FROM personas WHERE notebook_id=$nb ORDER BY name ASC", + new { nb = notebookId }) + .Select(r => new Persona( + (long)r.id, + (long)r.notebook_id, + (string)r.name, + (string)r.instructions, + r.source_set_id is null ? (long?)null : (long)r.source_set_id, + r.model is null ? null : (string)r.model, + SqliteDate.FromDb((string)r.created_at))) + .ToList(); + } public Persona CreatePersona(long notebookId, string name, string instructions = "", long? sourceSetId = null, string? model = null) { - var trimmed = name.Trim(); - var now = Now(); - var id = Connection.ExecuteScalar( - """ - INSERT INTO personas(notebook_id, name, instructions, source_set_id, model, created_at) - VALUES($nb, $name, $instructions, $setId, $model, $created); - SELECT last_insert_rowid(); - """, - new { nb = notebookId, name = trimmed, instructions, setId = sourceSetId, model, created = SqliteDate.ToDb(now) }); - return new Persona(id, notebookId, trimmed, instructions, sourceSetId, model, now); + lock (_gate) + { + var trimmed = name.Trim(); + var now = Now(); + var id = Connection.ExecuteScalar( + """ + INSERT INTO personas(notebook_id, name, instructions, source_set_id, model, created_at) + VALUES($nb, $name, $instructions, $setId, $model, $created); + SELECT last_insert_rowid(); + """, + new { nb = notebookId, name = trimmed, instructions, setId = sourceSetId, model, created = SqliteDate.ToDb(now) }); + return new Persona(id, notebookId, trimmed, instructions, sourceSetId, model, now); + } } - public void UpdatePersona(Persona persona) => - Connection.Execute( - "UPDATE personas SET name=$name, instructions=$instructions, source_set_id=$setId, model=$model WHERE id=$id", - new { name = persona.Name.Trim(), instructions = persona.Instructions, setId = persona.SourceSetId, model = persona.Model, id = persona.Id }); + public void UpdatePersona(Persona persona) + { + lock (_gate) + Connection.Execute( + "UPDATE personas SET name=$name, instructions=$instructions, source_set_id=$setId, model=$model WHERE id=$id", + new { name = persona.Name.Trim(), instructions = persona.Instructions, setId = persona.SourceSetId, model = persona.Model, id = persona.Id }); + } - public void DeletePersona(long id) => - Connection.Execute("DELETE FROM personas WHERE id=$id", new { id }); + public void DeletePersona(long id) + { + lock (_gate) + Connection.Execute("DELETE FROM personas WHERE id=$id", new { id }); + } } diff --git a/windows/src/AINotebook.Core/Storage/NotebookStore.Providers.cs b/windows/src/AINotebook.Core/Storage/NotebookStore.Providers.cs index e6c39fa..90ceef2 100644 --- a/windows/src/AINotebook.Core/Storage/NotebookStore.Providers.cs +++ b/windows/src/AINotebook.Core/Storage/NotebookStore.Providers.cs @@ -7,22 +7,28 @@ public sealed partial class NotebookStore { public IReadOnlyList Providers() { - using var cmd = Connection.CreateCommand(); - cmd.CommandText = "SELECT id, type, name, base_url, enabled, privacy_acknowledged, created_at FROM providers ORDER BY created_at"; - using var r = cmd.ExecuteReader(); - var result = new List(); - while (r.Read()) - result.Add(ReadProvider(r)); - return result; + lock (_gate) + { + using var cmd = Connection.CreateCommand(); + cmd.CommandText = "SELECT id, type, name, base_url, enabled, privacy_acknowledged, created_at FROM providers ORDER BY created_at"; + using var r = cmd.ExecuteReader(); + var result = new List(); + while (r.Read()) + result.Add(ReadProvider(r)); + return result; + } } public ProviderConfig? Provider(string id) { - using var cmd = Connection.CreateCommand(); - cmd.CommandText = "SELECT id, type, name, base_url, enabled, privacy_acknowledged, created_at FROM providers WHERE id = $id"; - cmd.Parameters.AddWithValue("$id", id); - using var r = cmd.ExecuteReader(); - return r.Read() ? ReadProvider(r) : null; + lock (_gate) + { + using var cmd = Connection.CreateCommand(); + cmd.CommandText = "SELECT id, type, name, base_url, enabled, privacy_acknowledged, created_at FROM providers WHERE id = $id"; + cmd.Parameters.AddWithValue("$id", id); + using var r = cmd.ExecuteReader(); + return r.Read() ? ReadProvider(r) : null; + } } /// @@ -37,42 +43,51 @@ public IReadOnlyList Providers() /// public ProviderConfig SaveProvider(ProviderConfig p) { - using var cmd = Connection.CreateCommand(); - cmd.CommandText = """ - INSERT INTO providers(id, type, name, base_url, enabled, privacy_acknowledged, created_at) - VALUES($id, $type, $name, $url, $enabled, $priv, $at) - ON CONFLICT(id) DO UPDATE SET - type = excluded.type, - name = excluded.name, - base_url = excluded.base_url, - enabled = excluded.enabled - """; - cmd.Parameters.AddWithValue("$id", p.Id); - cmd.Parameters.AddWithValue("$type", p.Type.ToStorageString()); - cmd.Parameters.AddWithValue("$name", p.Name); - cmd.Parameters.AddWithValue("$url", p.BaseUrl); - cmd.Parameters.AddWithValue("$enabled", p.Enabled ? 1 : 0); - cmd.Parameters.AddWithValue("$priv", p.PrivacyAcknowledged ? 1 : 0); - cmd.Parameters.AddWithValue("$at", SqliteDate.ToDb(p.CreatedAt)); - cmd.ExecuteNonQuery(); - return p; + lock (_gate) + { + using var cmd = Connection.CreateCommand(); + cmd.CommandText = """ + INSERT INTO providers(id, type, name, base_url, enabled, privacy_acknowledged, created_at) + VALUES($id, $type, $name, $url, $enabled, $priv, $at) + ON CONFLICT(id) DO UPDATE SET + type = excluded.type, + name = excluded.name, + base_url = excluded.base_url, + enabled = excluded.enabled + """; + cmd.Parameters.AddWithValue("$id", p.Id); + cmd.Parameters.AddWithValue("$type", p.Type.ToStorageString()); + cmd.Parameters.AddWithValue("$name", p.Name); + cmd.Parameters.AddWithValue("$url", p.BaseUrl); + cmd.Parameters.AddWithValue("$enabled", p.Enabled ? 1 : 0); + cmd.Parameters.AddWithValue("$priv", p.PrivacyAcknowledged ? 1 : 0); + cmd.Parameters.AddWithValue("$at", SqliteDate.ToDb(p.CreatedAt)); + cmd.ExecuteNonQuery(); + return p; + } } public void DeleteProvider(string id) { - if (id == ProviderConfig.OllamaId) return; // built-in cannot be deleted - using var cmd = Connection.CreateCommand(); - cmd.CommandText = "DELETE FROM providers WHERE id = $id"; - cmd.Parameters.AddWithValue("$id", id); - cmd.ExecuteNonQuery(); + lock (_gate) + { + if (id == ProviderConfig.OllamaId) return; // built-in cannot be deleted + using var cmd = Connection.CreateCommand(); + cmd.CommandText = "DELETE FROM providers WHERE id = $id"; + cmd.Parameters.AddWithValue("$id", id); + cmd.ExecuteNonQuery(); + } } public void AcknowledgePrivacy(string providerId) { - using var cmd = Connection.CreateCommand(); - cmd.CommandText = "UPDATE providers SET privacy_acknowledged = 1 WHERE id = $id"; - cmd.Parameters.AddWithValue("$id", providerId); - cmd.ExecuteNonQuery(); + lock (_gate) + { + using var cmd = Connection.CreateCommand(); + cmd.CommandText = "UPDATE providers SET privacy_acknowledged = 1 WHERE id = $id"; + cmd.Parameters.AddWithValue("$id", providerId); + cmd.ExecuteNonQuery(); + } } private static ProviderConfig ReadProvider(SqliteDataReader r) => new( diff --git a/windows/src/AINotebook.Core/Storage/NotebookStore.Search.cs b/windows/src/AINotebook.Core/Storage/NotebookStore.Search.cs index a538cfc..c43b76e 100644 --- a/windows/src/AINotebook.Core/Storage/NotebookStore.Search.cs +++ b/windows/src/AINotebook.Core/Storage/NotebookStore.Search.cs @@ -12,59 +12,65 @@ public sealed partial class NotebookStore /// Full-text search within a single notebook's notes. public IReadOnlyList SearchNotes(long notebookId, string query) { - if (string.IsNullOrWhiteSpace(query)) return []; - return Connection.Query( - """ - SELECT n.id, n.notebook_id, n.title, - snippet(notes_fts, 1, '', '', '…', 20) AS snippet - FROM notes_fts - JOIN notes n ON n.id = notes_fts.rowid - WHERE notes_fts MATCH $q - AND n.notebook_id = $nb - ORDER BY rank - LIMIT 50 - """, - new { q = EscapeFtsQuery(query), nb = notebookId }) - .Select(r => new NoteSearchHit((long)r.id, (long)r.notebook_id, (string)r.title, (string)r.snippet)) - .ToList(); + lock (_gate) + { + if (string.IsNullOrWhiteSpace(query)) return []; + return Connection.Query( + """ + SELECT n.id, n.notebook_id, n.title, + snippet(notes_fts, 1, '', '', '…', 20) AS snippet + FROM notes_fts + JOIN notes n ON n.id = notes_fts.rowid + WHERE notes_fts MATCH $q + AND n.notebook_id = $nb + ORDER BY rank + LIMIT 50 + """, + new { q = EscapeFtsQuery(query), nb = notebookId }) + .Select(r => new NoteSearchHit((long)r.id, (long)r.notebook_id, (string)r.title, (string)r.snippet)) + .ToList(); + } } /// Cross-notebook search across notes and source titles. public GlobalSearchResult GlobalSearch(string query) { - if (string.IsNullOrWhiteSpace(query)) - return new GlobalSearchResult([], []); + lock (_gate) + { + if (string.IsNullOrWhiteSpace(query)) + return new GlobalSearchResult([], []); - var escaped = EscapeFtsQuery(query); + var escaped = EscapeFtsQuery(query); - var notes = Connection.Query( - """ - SELECT n.id, n.notebook_id, n.title, - snippet(notes_fts, 1, '', '', '…', 20) AS snippet - FROM notes_fts - JOIN notes n ON n.id = notes_fts.rowid - WHERE notes_fts MATCH $q - ORDER BY rank - LIMIT 100 - """, - new { q = escaped }) - .Select(r => new NoteSearchHit((long)r.id, (long)r.notebook_id, (string)r.title, (string)r.snippet)) - .ToList(); + var notes = Connection.Query( + """ + SELECT n.id, n.notebook_id, n.title, + snippet(notes_fts, 1, '', '', '…', 20) AS snippet + FROM notes_fts + JOIN notes n ON n.id = notes_fts.rowid + WHERE notes_fts MATCH $q + ORDER BY rank + LIMIT 100 + """, + new { q = escaped }) + .Select(r => new NoteSearchHit((long)r.id, (long)r.notebook_id, (string)r.title, (string)r.snippet)) + .ToList(); - var sources = Connection.Query( - """ - SELECT s.id, s.notebook_id, s.title - FROM sources_fts - JOIN sources s ON s.id = sources_fts.rowid - WHERE sources_fts MATCH $q - ORDER BY rank - LIMIT 50 - """, - new { q = escaped }) - .Select(r => new SourceSearchHit((long)r.id, (long)r.notebook_id, (string)r.title)) - .ToList(); + var sources = Connection.Query( + """ + SELECT s.id, s.notebook_id, s.title + FROM sources_fts + JOIN sources s ON s.id = sources_fts.rowid + WHERE sources_fts MATCH $q + ORDER BY rank + LIMIT 50 + """, + new { q = escaped }) + .Select(r => new SourceSearchHit((long)r.id, (long)r.notebook_id, (string)r.title)) + .ToList(); - return new GlobalSearchResult(notes, sources); + return new GlobalSearchResult(notes, sources); + } } private static string EscapeFtsQuery(string raw) diff --git a/windows/src/AINotebook.Core/Storage/NotebookStore.SourceSets.cs b/windows/src/AINotebook.Core/Storage/NotebookStore.SourceSets.cs index c69e303..0f7e70f 100644 --- a/windows/src/AINotebook.Core/Storage/NotebookStore.SourceSets.cs +++ b/windows/src/AINotebook.Core/Storage/NotebookStore.SourceSets.cs @@ -5,49 +5,67 @@ namespace AINotebook.Core.Storage; public sealed partial class NotebookStore { - public IReadOnlyList SourceSets(long notebookId) => - Connection.Query( - "SELECT id, notebook_id, name, created_at FROM source_sets WHERE notebook_id=$nb ORDER BY name ASC", - new { nb = notebookId }) - .Select(r => new SourceSet((long)r.id, (long)r.notebook_id, (string)r.name, - SqliteDate.FromDb((string)r.created_at))) - .ToList(); + public IReadOnlyList SourceSets(long notebookId) + { + lock (_gate) + return Connection.Query( + "SELECT id, notebook_id, name, created_at FROM source_sets WHERE notebook_id=$nb ORDER BY name ASC", + new { nb = notebookId }) + .Select(r => new SourceSet((long)r.id, (long)r.notebook_id, (string)r.name, + SqliteDate.FromDb((string)r.created_at))) + .ToList(); + } public SourceSet CreateSourceSet(long notebookId, string name) { - var trimmed = name.Trim(); - var now = Now(); - var id = Connection.ExecuteScalar( - """ - INSERT INTO source_sets(notebook_id, name, created_at) - VALUES($nb, $name, $created); - SELECT last_insert_rowid(); - """, - new { nb = notebookId, name = trimmed, created = SqliteDate.ToDb(now) }); - return new SourceSet(id, notebookId, trimmed, now); + lock (_gate) + { + var trimmed = name.Trim(); + var now = Now(); + var id = Connection.ExecuteScalar( + """ + INSERT INTO source_sets(notebook_id, name, created_at) + VALUES($nb, $name, $created); + SELECT last_insert_rowid(); + """, + new { nb = notebookId, name = trimmed, created = SqliteDate.ToDb(now) }); + return new SourceSet(id, notebookId, trimmed, now); + } } - public void RenameSourceSet(long id, string name) => - Connection.Execute( - "UPDATE source_sets SET name=$name WHERE id=$id", - new { name = name.Trim(), id }); + public void RenameSourceSet(long id, string name) + { + lock (_gate) + Connection.Execute( + "UPDATE source_sets SET name=$name WHERE id=$id", + new { name = name.Trim(), id }); + } - public void DeleteSourceSet(long id) => - Connection.Execute("DELETE FROM source_sets WHERE id=$id", new { id }); + public void DeleteSourceSet(long id) + { + lock (_gate) + Connection.Execute("DELETE FROM source_sets WHERE id=$id", new { id }); + } public void SetSourceSetMembers(long setId, IReadOnlyList sourceIds) { - using var tx = Connection.BeginTransaction(); - Connection.Execute("DELETE FROM source_set_members WHERE set_id=$sid", new { sid = setId }, tx); - foreach (var srcId in sourceIds) - Connection.Execute( - "INSERT OR IGNORE INTO source_set_members(set_id, source_id) VALUES($sid, $src)", - new { sid = setId, src = srcId }, tx); - tx.Commit(); + lock (_gate) + { + using var tx = Connection.BeginTransaction(); + Connection.Execute("DELETE FROM source_set_members WHERE set_id=$sid", new { sid = setId }, tx); + foreach (var srcId in sourceIds) + Connection.Execute( + "INSERT OR IGNORE INTO source_set_members(set_id, source_id) VALUES($sid, $src)", + new { sid = setId, src = srcId }, tx); + tx.Commit(); + } } - public IReadOnlyList SourceSetMembers(long setId) => - Connection.Query( - "SELECT source_id FROM source_set_members WHERE set_id=$sid", new { sid = setId }) - .ToList(); + public IReadOnlyList SourceSetMembers(long setId) + { + lock (_gate) + return Connection.Query( + "SELECT source_id FROM source_set_members WHERE set_id=$sid", new { sid = setId }) + .ToList(); + } } diff --git a/windows/src/AINotebook.Core/Storage/NotebookStore.Sources.cs b/windows/src/AINotebook.Core/Storage/NotebookStore.Sources.cs index ebf4dc8..cf3ddb1 100644 --- a/windows/src/AINotebook.Core/Storage/NotebookStore.Sources.cs +++ b/windows/src/AINotebook.Core/Storage/NotebookStore.Sources.cs @@ -19,119 +19,158 @@ public sealed partial class NotebookStore public Source CreateSource(long notebookId, SourceType type, string title, string? uri, string? rawPath) { - var trimmed = title.Trim(); - if (trimmed.Length == 0) throw new StoreException.InvalidSourceTitle(title); - var now = DateTime.UtcNow; - var id = Connection.ExecuteScalar( - """ - INSERT INTO sources(notebook_id, type, title, uri, raw_path, status, error, ingested_at) - VALUES($nb, $type, $title, $uri, $raw, $status, NULL, $ingested); - SELECT last_insert_rowid(); - """, - new - { - nb = notebookId, type = type.ToDb(), title = trimmed, uri, raw = rawPath, - status = SourceStatus.Pending.ToDb(), ingested = SqliteDate.ToDb(now) - }); - return new Source(id, notebookId, type, trimmed, uri, rawPath, SourceStatus.Pending, null, now); + lock (_gate) + { + var trimmed = title.Trim(); + if (trimmed.Length == 0) throw new StoreException.InvalidSourceTitle(title); + var now = DateTime.UtcNow; + var id = Connection.ExecuteScalar( + """ + INSERT INTO sources(notebook_id, type, title, uri, raw_path, status, error, ingested_at) + VALUES($nb, $type, $title, $uri, $raw, $status, NULL, $ingested); + SELECT last_insert_rowid(); + """, + new + { + nb = notebookId, type = type.ToDb(), title = trimmed, uri, raw = rawPath, + status = SourceStatus.Pending.ToDb(), ingested = SqliteDate.ToDb(now) + }); + return new Source(id, notebookId, type, trimmed, uri, rawPath, SourceStatus.Pending, null, now); + } } private const string SourceCols = "id, notebook_id, type, title, uri, raw_path, status, error, ingested_at, last_synced_at, content_hash"; - public IReadOnlyList Sources(long notebookId) => - Connection.Query( - $"SELECT {SourceCols} FROM sources WHERE notebook_id=$nb AND type<>'note' ORDER BY ingested_at DESC", - new { nb = notebookId }) - .Select(r => (Source)MapSource(r)).ToList(); + public IReadOnlyList Sources(long notebookId) + { + lock (_gate) + return Connection.Query( + $"SELECT {SourceCols} FROM sources WHERE notebook_id=$nb AND type<>'note' ORDER BY ingested_at DESC", + new { nb = notebookId }) + .Select(r => (Source)MapSource(r)).ToList(); + } - public IReadOnlyList SourcesIncludingShadow(long notebookId) => - Connection.Query( - $"SELECT {SourceCols} FROM sources WHERE notebook_id=$nb ORDER BY ingested_at DESC", - new { nb = notebookId }) - .Select(r => (Source)MapSource(r)).ToList(); + public IReadOnlyList SourcesIncludingShadow(long notebookId) + { + lock (_gate) + return Connection.Query( + $"SELECT {SourceCols} FROM sources WHERE notebook_id=$nb ORDER BY ingested_at DESC", + new { nb = notebookId }) + .Select(r => (Source)MapSource(r)).ToList(); + } public Source? Source(long id) { - var row = Connection.QueryFirstOrDefault( - $"SELECT {SourceCols} FROM sources WHERE id=$id", new { id }); - return row is null ? null : MapSource(row); + lock (_gate) + { + var row = Connection.QueryFirstOrDefault( + $"SELECT {SourceCols} FROM sources WHERE id=$id", new { id }); + return row is null ? null : MapSource(row); + } } public void UpdateSourceStatus(long id, SourceStatus status, string? error) { - var rows = Connection.Execute( - "UPDATE sources SET status=$status, error=$error WHERE id=$id", - new { status = status.ToDb(), error, id }); - if (rows == 0) throw new StoreException.SourceNotFound(id); + lock (_gate) + { + var rows = Connection.Execute( + "UPDATE sources SET status=$status, error=$error WHERE id=$id", + new { status = status.ToDb(), error, id }); + if (rows == 0) throw new StoreException.SourceNotFound(id); + } } public void UpdateSourceTitle(long id, string title) { - using var cmd = Connection.CreateCommand(); - cmd.CommandText = "UPDATE sources SET title = $t WHERE id = $id"; - cmd.Parameters.AddWithValue("$t", title); - cmd.Parameters.AddWithValue("$id", id); - cmd.ExecuteNonQuery(); + lock (_gate) + { + using var cmd = Connection.CreateCommand(); + cmd.CommandText = "UPDATE sources SET title = $t WHERE id = $id"; + cmd.Parameters.AddWithValue("$t", title); + cmd.Parameters.AddWithValue("$id", id); + cmd.ExecuteNonQuery(); + } } public void UpdateSourceSyncInfo(long id, DateTime lastSyncedAt, string contentHash) { - Connection.Execute( - "UPDATE sources SET last_synced_at=$ts, content_hash=$hash WHERE id=$id", - new { ts = SqliteDate.ToDb(lastSyncedAt), hash = contentHash, id }); + lock (_gate) + { + Connection.Execute( + "UPDATE sources SET last_synced_at=$ts, content_hash=$hash WHERE id=$id", + new { ts = SqliteDate.ToDb(lastSyncedAt), hash = contentHash, id }); + } } public void DeleteSource(long id) { - var rows = Connection.Execute("DELETE FROM sources WHERE id=$id", new { id }); - if (rows == 0) throw new StoreException.SourceNotFound(id); + lock (_gate) + { + var rows = Connection.Execute("DELETE FROM sources WHERE id=$id", new { id }); + if (rows == 0) throw new StoreException.SourceNotFound(id); + } } public void ReplaceChunks(long sourceId, IReadOnlyList chunks) { - using var tx = Connection.BeginTransaction(); - Connection.Execute("DELETE FROM source_chunks WHERE source_id=$sid", new { sid = sourceId }, tx); - int ord = 0; - foreach (var draft in chunks) + lock (_gate) { - Connection.Execute( - """ - INSERT INTO source_chunks(source_id, ord, text, token_count, page_hint) - VALUES($sid, $ord, $text, $tc, $ph) - """, - new { sid = sourceId, ord, text = draft.Text, tc = draft.TokenCount, ph = draft.PageHint }, - tx); - ord++; + using var tx = Connection.BeginTransaction(); + Connection.Execute("DELETE FROM source_chunks WHERE source_id=$sid", new { sid = sourceId }, tx); + int ord = 0; + foreach (var draft in chunks) + { + Connection.Execute( + """ + INSERT INTO source_chunks(source_id, ord, text, token_count, page_hint) + VALUES($sid, $ord, $text, $tc, $ph) + """, + new { sid = sourceId, ord, text = draft.Text, tc = draft.TokenCount, ph = draft.PageHint }, + tx); + ord++; + } + tx.Commit(); } - tx.Commit(); } - public IReadOnlyList Chunks(long sourceId) => - Connection.Query( - "SELECT id, source_id, ord, text, token_count, page_hint, context FROM source_chunks WHERE source_id=$sid ORDER BY ord ASC", - new { sid = sourceId }) - .Select(r => new SourceChunk( - (long)r.id, (long)r.source_id, (int)(long)r.ord, (string)r.text, - (int)(long)r.token_count, - r.page_hint is null ? (int?)null : (int)(long)r.page_hint, - r.context is null ? null : (string)r.context)) - .ToList(); + public IReadOnlyList Chunks(long sourceId) + { + lock (_gate) + return Connection.Query( + "SELECT id, source_id, ord, text, token_count, page_hint, context FROM source_chunks WHERE source_id=$sid ORDER BY ord ASC", + new { sid = sourceId }) + .Select(r => new SourceChunk( + (long)r.id, (long)r.source_id, (int)(long)r.ord, (string)r.text, + (int)(long)r.token_count, + r.page_hint is null ? (int?)null : (int)(long)r.page_hint, + r.context is null ? null : (string)r.context)) + .ToList(); + } public void SetChunkContext(long sourceId, long? chunkId, string context) { - if (chunkId is null) return; - Connection.Execute( - "UPDATE source_chunks SET context=$ctx WHERE id=$id", - new { ctx = context, id = chunkId }); + lock (_gate) + { + if (chunkId is null) return; + Connection.Execute( + "UPDATE source_chunks SET context=$ctx WHERE id=$id", + new { ctx = context, id = chunkId }); + } } /// The persisted summary for a source, or null if not yet computed. - public string? SourceSummary(long id) => - Connection.ExecuteScalar( - "SELECT summary FROM sources WHERE id=$id", new { id }); + public string? SourceSummary(long id) + { + lock (_gate) + return Connection.ExecuteScalar( + "SELECT summary FROM sources WHERE id=$id", new { id }); + } - public void SetSourceSummary(long id, string text) => - Connection.Execute( - "UPDATE sources SET summary=$text WHERE id=$id", new { text, id }); + public void SetSourceSummary(long id, string text) + { + lock (_gate) + Connection.Execute( + "UPDATE sources SET summary=$text WHERE id=$id", new { text, id }); + } } diff --git a/windows/src/AINotebook.Core/Storage/NotebookStore.Tags.cs b/windows/src/AINotebook.Core/Storage/NotebookStore.Tags.cs index 164e8f1..c55ca0e 100644 --- a/windows/src/AINotebook.Core/Storage/NotebookStore.Tags.cs +++ b/windows/src/AINotebook.Core/Storage/NotebookStore.Tags.cs @@ -5,70 +5,97 @@ namespace AINotebook.Core.Storage; public sealed partial class NotebookStore { - public IReadOnlyList Tags() => - Connection.Query("SELECT id, name FROM tags ORDER BY name ASC") - .Select(r => new Tag((long)r.id, (string)r.name)) - .ToList(); + public IReadOnlyList Tags() + { + lock (_gate) + return Connection.Query("SELECT id, name FROM tags ORDER BY name ASC") + .Select(r => new Tag((long)r.id, (string)r.name)) + .ToList(); + } public Tag CreateTag(string name) { - var trimmed = name.Trim(); - var id = Connection.ExecuteScalar( - """ - INSERT INTO tags(name) VALUES($name) - ON CONFLICT(name) DO UPDATE SET name=excluded.name; - SELECT id FROM tags WHERE name=$name; - """, - new { name = trimmed }); - return new Tag(id, trimmed); + lock (_gate) + { + var trimmed = name.Trim(); + var id = Connection.ExecuteScalar( + """ + INSERT INTO tags(name) VALUES($name) + ON CONFLICT(name) DO UPDATE SET name=excluded.name; + SELECT id FROM tags WHERE name=$name; + """, + new { name = trimmed }); + return new Tag(id, trimmed); + } } - public void DeleteTag(long id) => - Connection.Execute("DELETE FROM tags WHERE id=$id", new { id }); + public void DeleteTag(long id) + { + lock (_gate) + Connection.Execute("DELETE FROM tags WHERE id=$id", new { id }); + } public void SetNoteTags(long noteId, IReadOnlyList tagIds) { - using var tx = Connection.BeginTransaction(); - Connection.Execute("DELETE FROM note_tags WHERE note_id=$nid", new { nid = noteId }, tx); - foreach (var tid in tagIds) - Connection.Execute( - "INSERT OR IGNORE INTO note_tags(note_id, tag_id) VALUES($nid, $tid)", - new { nid = noteId, tid }, tx); - tx.Commit(); + lock (_gate) + { + using var tx = Connection.BeginTransaction(); + Connection.Execute("DELETE FROM note_tags WHERE note_id=$nid", new { nid = noteId }, tx); + foreach (var tid in tagIds) + Connection.Execute( + "INSERT OR IGNORE INTO note_tags(note_id, tag_id) VALUES($nid, $tid)", + new { nid = noteId, tid }, tx); + tx.Commit(); + } } public void SetSourceTags(long sourceId, IReadOnlyList tagIds) { - using var tx = Connection.BeginTransaction(); - Connection.Execute("DELETE FROM source_tags WHERE source_id=$sid", new { sid = sourceId }, tx); - foreach (var tid in tagIds) - Connection.Execute( - "INSERT OR IGNORE INTO source_tags(source_id, tag_id) VALUES($sid, $tid)", - new { sid = sourceId, tid }, tx); - tx.Commit(); + lock (_gate) + { + using var tx = Connection.BeginTransaction(); + Connection.Execute("DELETE FROM source_tags WHERE source_id=$sid", new { sid = sourceId }, tx); + foreach (var tid in tagIds) + Connection.Execute( + "INSERT OR IGNORE INTO source_tags(source_id, tag_id) VALUES($sid, $tid)", + new { sid = sourceId, tid }, tx); + tx.Commit(); + } } - public IReadOnlyList NoteTagIds(long noteId) => - Connection.Query( - "SELECT tag_id FROM note_tags WHERE note_id=$nid", new { nid = noteId }) - .ToList(); + public IReadOnlyList NoteTagIds(long noteId) + { + lock (_gate) + return Connection.Query( + "SELECT tag_id FROM note_tags WHERE note_id=$nid", new { nid = noteId }) + .ToList(); + } - public IReadOnlyList SourceTagIds(long sourceId) => - Connection.Query( - "SELECT tag_id FROM source_tags WHERE source_id=$sid", new { sid = sourceId }) - .ToList(); + public IReadOnlyList SourceTagIds(long sourceId) + { + lock (_gate) + return Connection.Query( + "SELECT tag_id FROM source_tags WHERE source_id=$sid", new { sid = sourceId }) + .ToList(); + } - public IReadOnlyList TagsForNote(long noteId) => - Connection.Query( - "SELECT t.id, t.name FROM tags t JOIN note_tags nt ON nt.tag_id=t.id WHERE nt.note_id=$nid ORDER BY t.name", - new { nid = noteId }) - .Select(r => new Tag((long)r.id, (string)r.name)) - .ToList(); + public IReadOnlyList TagsForNote(long noteId) + { + lock (_gate) + return Connection.Query( + "SELECT t.id, t.name FROM tags t JOIN note_tags nt ON nt.tag_id=t.id WHERE nt.note_id=$nid ORDER BY t.name", + new { nid = noteId }) + .Select(r => new Tag((long)r.id, (string)r.name)) + .ToList(); + } - public IReadOnlyList TagsForSource(long sourceId) => - Connection.Query( - "SELECT t.id, t.name FROM tags t JOIN source_tags st ON st.tag_id=t.id WHERE st.source_id=$sid ORDER BY t.name", - new { sid = sourceId }) - .Select(r => new Tag((long)r.id, (string)r.name)) - .ToList(); + public IReadOnlyList TagsForSource(long sourceId) + { + lock (_gate) + return Connection.Query( + "SELECT t.id, t.name FROM tags t JOIN source_tags st ON st.tag_id=t.id WHERE st.source_id=$sid ORDER BY t.name", + new { sid = sourceId }) + .Select(r => new Tag((long)r.id, (string)r.name)) + .ToList(); + } } diff --git a/windows/src/AINotebook.Core/Storage/NotebookStore.Transformations.cs b/windows/src/AINotebook.Core/Storage/NotebookStore.Transformations.cs index 50568c7..a7c7ede 100644 --- a/windows/src/AINotebook.Core/Storage/NotebookStore.Transformations.cs +++ b/windows/src/AINotebook.Core/Storage/NotebookStore.Transformations.cs @@ -16,53 +16,74 @@ public sealed partial class NotebookStore public Transformation CreateTransformation(string name, string promptTemplate, TransformationScope scope, bool isBuiltin = false, string description = "") { - var id = Connection.ExecuteScalar( - """ - INSERT INTO transformations(name, prompt_template, scope, is_builtin, description) - VALUES($name, $prompt, $scope, $builtin, $desc); - SELECT last_insert_rowid(); - """, - new { name, prompt = promptTemplate, scope = scope.ToDb(), builtin = isBuiltin ? 1 : 0, desc = description }); - return new Transformation(id, name, promptTemplate, scope, isBuiltin, description); + lock (_gate) + { + var id = Connection.ExecuteScalar( + """ + INSERT INTO transformations(name, prompt_template, scope, is_builtin, description) + VALUES($name, $prompt, $scope, $builtin, $desc); + SELECT last_insert_rowid(); + """, + new { name, prompt = promptTemplate, scope = scope.ToDb(), builtin = isBuiltin ? 1 : 0, desc = description }); + return new Transformation(id, name, promptTemplate, scope, isBuiltin, description); + } } - public IReadOnlyList Transformations() => - Connection.Query( - $"SELECT {TransformationCols} FROM transformations ORDER BY is_builtin DESC, name ASC") - .Select(r => (Transformation)MapTransformation(r)).ToList(); + public IReadOnlyList Transformations() + { + lock (_gate) + return Connection.Query( + $"SELECT {TransformationCols} FROM transformations ORDER BY is_builtin DESC, name ASC") + .Select(r => (Transformation)MapTransformation(r)).ToList(); + } - public void UpdateTransformation(long id, string name, string promptTemplate, string description = "") => - Connection.Execute( - "UPDATE transformations SET name=$name, prompt_template=$prompt, description=$desc WHERE id=$id", - new { name, prompt = promptTemplate, desc = description, id }); + public void UpdateTransformation(long id, string name, string promptTemplate, string description = "") + { + lock (_gate) + Connection.Execute( + "UPDATE transformations SET name=$name, prompt_template=$prompt, description=$desc WHERE id=$id", + new { name, prompt = promptTemplate, desc = description, id }); + } - public void UpdateTransformationScope(long id, TransformationScope scope) => - Connection.Execute("UPDATE transformations SET scope=$scope WHERE id=$id", - new { scope = scope.ToDb(), id }); + public void UpdateTransformationScope(long id, TransformationScope scope) + { + lock (_gate) + Connection.Execute("UPDATE transformations SET scope=$scope WHERE id=$id", + new { scope = scope.ToDb(), id }); + } - public void DeleteTransformation(long id) => - Connection.Execute("DELETE FROM transformations WHERE id=$id", new { id }); + public void DeleteTransformation(long id) + { + lock (_gate) + Connection.Execute("DELETE FROM transformations WHERE id=$id", new { id }); + } public TransformationRun RecordTransformationRun(long transformationId, long? sourceId, long? resultNoteId) { - var ranAt = DateTime.UtcNow; - var id = Connection.ExecuteScalar( - """ - INSERT INTO transformation_runs(transformation_id, source_id, result_note_id, ran_at) - VALUES($tid, $sid, $nid, $ran); - SELECT last_insert_rowid(); - """, - new { tid = transformationId, sid = sourceId, nid = resultNoteId, ran = SqliteDate.ToDb(ranAt) }); - return new TransformationRun(id, transformationId, sourceId, resultNoteId, ranAt); + lock (_gate) + { + var ranAt = DateTime.UtcNow; + var id = Connection.ExecuteScalar( + """ + INSERT INTO transformation_runs(transformation_id, source_id, result_note_id, ran_at) + VALUES($tid, $sid, $nid, $ran); + SELECT last_insert_rowid(); + """, + new { tid = transformationId, sid = sourceId, nid = resultNoteId, ran = SqliteDate.ToDb(ranAt) }); + return new TransformationRun(id, transformationId, sourceId, resultNoteId, ranAt); + } } - public IReadOnlyList TransformationRuns() => - Connection.Query( - "SELECT id, transformation_id, source_id, result_note_id, ran_at FROM transformation_runs ORDER BY ran_at DESC") - .Select(r => new TransformationRun( - (long)r.id, (long)r.transformation_id, - r.source_id is null ? (long?)null : (long)r.source_id, - r.result_note_id is null ? (long?)null : (long)r.result_note_id, - SqliteDate.FromDb((string)r.ran_at))) - .ToList(); + public IReadOnlyList TransformationRuns() + { + lock (_gate) + return Connection.Query( + "SELECT id, transformation_id, source_id, result_note_id, ran_at FROM transformation_runs ORDER BY ran_at DESC") + .Select(r => new TransformationRun( + (long)r.id, (long)r.transformation_id, + r.source_id is null ? (long?)null : (long)r.source_id, + r.result_note_id is null ? (long?)null : (long)r.result_note_id, + SqliteDate.FromDb((string)r.ran_at))) + .ToList(); + } } diff --git a/windows/src/AINotebook.Core/Storage/NotebookStore.cs b/windows/src/AINotebook.Core/Storage/NotebookStore.cs index 49bd042..5638365 100644 --- a/windows/src/AINotebook.Core/Storage/NotebookStore.cs +++ b/windows/src/AINotebook.Core/Storage/NotebookStore.cs @@ -14,6 +14,33 @@ public sealed partial class NotebookStore : IDisposable private readonly SqliteConnection _conn; private readonly AppLanguage _language; + /// + /// Serializes every use of . Microsoft.Data.Sqlite's + /// SqliteConnection is NOT thread-safe, and this store deliberately keeps + /// exactly one connection for its whole lifetime (see the constructor) — + /// yet it is used concurrently: EmbeddingWorker drains on a thread-pool + /// thread, FolderWatchService ingests on another, the ViewModels wrap most + /// calls in Task.Run, and Retriever queries from async continuations. + /// Without this gate those overlap on one connection and fault at random. + /// + /// macOS has no equivalent because GRDB's DatabaseQueue already serializes + /// all access; the C# port lost that guarantee and this restores it. + /// + /// Monitor is re-entrant, so a store method calling another store method + /// on the same thread is fine. The lock is held for the whole of each + /// public method — not per statement — so compound read-then-write methods + /// stay atomic against each other. + /// + private readonly object _gate = new(); + + /// + /// The same gate, for the two in-assembly collaborators that reach for + /// directly instead of going through a store + /// method (Retriever's FTS/snippet queries and AttachmentStore's row + /// writes). They must hold it for the duration of their command. + /// + internal object Gate => _gate; + /// Fires after createNote/updateNote with the affected note id. public Func? OnNoteSaved { get; set; } @@ -48,11 +75,46 @@ public NotebookStore(StorePath path, AppLanguage language = AppLanguage.English) private int Execute(string sql, object? param = null) => _conn.Execute(sql, param); - /// B3: flush WAL and copy the DB file to destPath. + /// + /// B3: write a consistent snapshot of the database to destPath. + /// + /// Uses SQLite's online-backup API — the same mechanism macOS reaches + /// through GRDB's `dbQueue.backup(to:)` — so the copy is taken under a read + /// transaction and is always internally consistent. + /// + /// The previous implementation ran `PRAGMA wal_checkpoint(FULL)` and then + /// File.Copy'd the database file, which was wrong twice over: journal_mode + /// is never set to WAL anywhere in this app, so the checkpoint was a no-op, + /// and a raw file copy of a live database can capture a torn write. It also + /// could not back up the in-memory store at all (DataSource is not a path). + /// + /// `VACUUM INTO` would have been the smaller change, but it silently does + /// nothing when the source database is in-memory — no exception, no file — + /// which would have left the tests as the only thing standing between us + /// and a backup button that reports success and writes nothing. + /// + /// The destination is opened through SqliteConnectionStringBuilder rather + /// than string interpolation: this path comes from a save-file picker, so a + /// filename containing ';' or '=' would otherwise corrupt the connection + /// string. Pooling=False for the same reason as the main connection — it + /// releases the file handle as soon as the connection is disposed. + /// public void BackupTo(string destPath) { - _conn.Execute("PRAGMA wal_checkpoint(FULL)"); - File.Copy(_conn.DataSource, destPath, overwrite: true); + lock (_gate) + { + // The backup API appends into an existing destination rather than + // replacing it, so clear whatever the picker pointed at first. + if (File.Exists(destPath)) File.Delete(destPath); + var connStr = new SqliteConnectionStringBuilder + { + DataSource = destPath, + Pooling = false + }.ToString(); + using var dest = new SqliteConnection(connStr); + dest.Open(); + _conn.BackupDatabase(dest); + } } // Swift's Date() stores sub-millisecond precision so created_at/updated_at @@ -73,64 +135,82 @@ private DateTime Now() return now; } - public void Dispose() => _conn.Dispose(); + public void Dispose() + { + lock (_gate) _conn.Dispose(); + } // ---- Notebooks ---- public Notebook CreateNotebook(string name, string description = "") { - var trimmed = name.Trim(); - if (trimmed.Length == 0) throw new StoreException.InvalidNotebookName(name); - var now = Now(); - var id = _conn.ExecuteScalar( - """ - INSERT INTO notebooks(name, description, created_at, updated_at) - VALUES($name, $desc, $created, $updated); - SELECT last_insert_rowid(); - """, - new { name = trimmed, desc = description, created = SqliteDate.ToDb(now), updated = SqliteDate.ToDb(now) }); - return new Notebook(id, trimmed, description, now, now); + lock (_gate) + { + var trimmed = name.Trim(); + if (trimmed.Length == 0) throw new StoreException.InvalidNotebookName(name); + var now = Now(); + var id = _conn.ExecuteScalar( + """ + INSERT INTO notebooks(name, description, created_at, updated_at) + VALUES($name, $desc, $created, $updated); + SELECT last_insert_rowid(); + """, + new { name = trimmed, desc = description, created = SqliteDate.ToDb(now), updated = SqliteDate.ToDb(now) }); + return new Notebook(id, trimmed, description, now, now); + } } public IReadOnlyList Notebooks() { - return _conn.Query( - "SELECT id, name, description, created_at, updated_at, instructions FROM notebooks ORDER BY updated_at DESC") - .Select(r => new Notebook( - (long)r.id, (string)r.name, (string)r.description, - SqliteDate.FromDb((string)r.created_at), SqliteDate.FromDb((string)r.updated_at), - r.instructions is null ? "" : (string)r.instructions)) - .ToList(); + lock (_gate) + { + return _conn.Query( + "SELECT id, name, description, created_at, updated_at, instructions FROM notebooks ORDER BY updated_at DESC") + .Select(r => new Notebook( + (long)r.id, (string)r.name, (string)r.description, + SqliteDate.FromDb((string)r.created_at), SqliteDate.FromDb((string)r.updated_at), + r.instructions is null ? "" : (string)r.instructions)) + .ToList(); + } } public Notebook RenameNotebook(long id, string newName) { - var trimmed = newName.Trim(); - if (trimmed.Length == 0) throw new StoreException.InvalidNotebookName(newName); - var now = Now(); - var rows = _conn.Execute( - "UPDATE notebooks SET name=$name, updated_at=$updated WHERE id=$id", - new { name = trimmed, updated = SqliteDate.ToDb(now), id }); - if (rows == 0) throw new StoreException.NotebookNotFound(id); - var row = _conn.QuerySingle( - "SELECT id, name, description, created_at, updated_at, instructions FROM notebooks WHERE id=$id", new { id }); - return new Notebook((long)row.id, (string)row.name, (string)row.description, - SqliteDate.FromDb((string)row.created_at), SqliteDate.FromDb((string)row.updated_at), - row.instructions is null ? "" : (string)row.instructions); + lock (_gate) + { + var trimmed = newName.Trim(); + if (trimmed.Length == 0) throw new StoreException.InvalidNotebookName(newName); + var now = Now(); + var rows = _conn.Execute( + "UPDATE notebooks SET name=$name, updated_at=$updated WHERE id=$id", + new { name = trimmed, updated = SqliteDate.ToDb(now), id }); + if (rows == 0) throw new StoreException.NotebookNotFound(id); + var row = _conn.QuerySingle( + "SELECT id, name, description, created_at, updated_at, instructions FROM notebooks WHERE id=$id", new { id }); + return new Notebook((long)row.id, (string)row.name, (string)row.description, + SqliteDate.FromDb((string)row.created_at), SqliteDate.FromDb((string)row.updated_at), + row.instructions is null ? "" : (string)row.instructions); + } } public void UpdateNotebookInstructions(long id, string instructions) { - var now = Now(); - var rows = _conn.Execute( - "UPDATE notebooks SET instructions=$ins, updated_at=$updated WHERE id=$id", - new { ins = instructions, updated = SqliteDate.ToDb(now), id }); - if (rows == 0) throw new StoreException.NotebookNotFound(id); + lock (_gate) + { + var now = Now(); + var rows = _conn.Execute( + "UPDATE notebooks SET instructions=$ins, updated_at=$updated WHERE id=$id", + new { ins = instructions, updated = SqliteDate.ToDb(now), id }); + if (rows == 0) throw new StoreException.NotebookNotFound(id); + } } public void DeleteNotebook(long id) { - var rows = _conn.Execute("DELETE FROM notebooks WHERE id=$id", new { id }); - if (rows == 0) throw new StoreException.NotebookNotFound(id); + lock (_gate) + { + var rows = _conn.Execute("DELETE FROM notebooks WHERE id=$id", new { id }); + if (rows == 0) throw new StoreException.NotebookNotFound(id); + } } } diff --git a/windows/tests/AINotebook.Core.Tests/Rag/ChatEngineModelAndRetryTests.cs b/windows/tests/AINotebook.Core.Tests/Rag/ChatEngineModelAndRetryTests.cs new file mode 100644 index 0000000..d2f3950 --- /dev/null +++ b/windows/tests/AINotebook.Core.Tests/Rag/ChatEngineModelAndRetryTests.cs @@ -0,0 +1,179 @@ +using System.Runtime.CompilerServices; +using AINotebook.Core.Models; +using AINotebook.Core.Ollama; +using AINotebook.Core.Providers; +using AINotebook.Core.Rag; +using AINotebook.Core.Storage; +using AINotebook.Core.Tests.Helpers; +using Xunit; + +namespace AINotebook.Core.Tests.Rag; + +/// +/// Covers three ChatEngine defects found in review: the persisted model tag +/// ignored the per-call override, a 429's Retry-After was ignored, and the +/// caller was never told to discard a failed attempt's partial tokens. +/// +public class ChatEngineModelAndRetryTests : IDisposable +{ + private readonly NotebookStore _store; + private readonly long _nbId; + private readonly long _sessionId; + + public ChatEngineModelAndRetryTests() + { + _store = new NotebookStore(StorePath.InMemory); + var nb = _store.CreateNotebook("N", ""); + _nbId = nb.Id!.Value; + var src = _store.CreateSource(_nbId, SourceType.Text, "S", null, null); + _store.ReplaceChunks(src.Id!.Value, new[] { new ChunkDraft("the sky is blue", 1, null) }); + _store.StoreEmbedding(_store.Chunks(src.Id!.Value)[0].Id!.Value, "emb", new EmbeddingVector(new[] { 1f, 0f })); + _sessionId = _store.CreateChatSession(_nbId, "T").Id!.Value; + } + + public void Dispose() => _store.Dispose(); + + private ChatEngine Engine(IChatStreaming chat, int retries = 2, int backoffMs = 1) + { + var retriever = new Retriever(_store, new MockEmbeddingClient(_ => new[] { 1f, 0f }), "emb"); + return new ChatEngine(_store, retriever, chat, "default-model", + retryAttempts: retries, retryBackoffMillis: backoffMs); + } + + [Fact] + public async Task PersistsTheOverrideModelNotTheEngineDefault() + { + var engine = Engine(new MockChatClient("hi")); + + var stored = await engine.SendAsync(_sessionId, _nbId, "q", + model: "provider-guid:claude-x", onToken: _ => { }); + + // Used to record "default-model" — the value captured when the engine + // was built — even though the turn streamed via the override. + Assert.Equal("provider-guid:claude-x", stored.Model); + Assert.Equal("provider-guid:claude-x", _store.Messages(_sessionId).Last().Model); + } + + [Fact] + public async Task PersistsTheEngineDefaultWhenNoOverrideIsGiven() + { + var engine = Engine(new MockChatClient("hi")); + + var stored = await engine.SendAsync(_sessionId, _nbId, "q", onToken: _ => { }); + + Assert.Equal("default-model", stored.Model); + } + + [Fact] + public async Task StreamsWithTheOverrideModel() + { + var chat = new ModelCapturingChat("hi"); + var engine = Engine(chat); + + await engine.SendAsync(_sessionId, _nbId, "q", + model: "provider-guid:claude-x", onToken: _ => { }); + + Assert.Equal("provider-guid:claude-x", chat.LastModel); + } + + [Fact] + public async Task OnRetryFiresOncePerFailedAttempt() + { + // Fails twice with a retryable error, then succeeds. + var chat = new ErrorInjectingChat(failures: 2, () => new ProviderException("boom"), "ok"); + var engine = Engine(chat); + + var retries = 0; + await engine.SendAsync(_sessionId, _nbId, "q", + onToken: _ => { }, onRetry: () => retries++); + + Assert.Equal(3, chat.Attempts); + Assert.Equal(2, retries); + } + + [Fact] + public async Task OnRetryIsNotFiredWhenTheFirstAttemptSucceeds() + { + var engine = Engine(new MockChatClient("hi")); + + var retries = 0; + await engine.SendAsync(_sessionId, _nbId, "q", + onToken: _ => { }, onRetry: () => retries++); + + Assert.Equal(0, retries); + } + + [Fact] + public async Task RetryAfterOnA429IsHonoredOverTheDefaultBackoff() + { + var wait = TimeSpan.FromMilliseconds(300); + var chat = new ErrorInjectingChat(failures: 1, () => new ProviderRateLimitException("429", wait), "ok"); + // Backoff of 1ms: without honoring Retry-After this comes back almost + // immediately, so elapsed time is what distinguishes the two. + var engine = Engine(chat, retries: 2, backoffMs: 1); + + var started = Environment.TickCount64; + await engine.SendAsync(_sessionId, _nbId, "q", onToken: _ => { }); + var elapsed = Environment.TickCount64 - started; + + Assert.Equal(2, chat.Attempts); + Assert.True(elapsed >= 250, $"waited only {elapsed}ms — Retry-After was ignored"); + } + + [Fact] + public async Task A429WithoutRetryAfterFallsBackToExponentialBackoff() + { + var chat = new ErrorInjectingChat(failures: 1, () => new ProviderRateLimitException("429"), "ok"); + var engine = Engine(chat, retries: 2, backoffMs: 1); + + await engine.SendAsync(_sessionId, _nbId, "q", onToken: _ => { }); + + Assert.Equal(2, chat.Attempts); + } +} + +/// Throws the given error for the first N attempts, then streams. +public sealed class ErrorInjectingChat : IChatStreaming +{ + private readonly int _failures; + private readonly Func _error; + private readonly string[] _tokens; + public int Attempts { get; private set; } + + public ErrorInjectingChat(int failures, Func error, params string[] tokens) + { + _failures = failures; + _error = error; + _tokens = tokens; + } + +#pragma warning disable CS1998 // the failure path throws before any await + public async IAsyncEnumerable StreamAsync( + string model, IReadOnlyList messages, + [EnumeratorCancellation] CancellationToken ct = default) + { + Attempts++; + if (Attempts <= _failures) throw _error(); + foreach (var t in _tokens) yield return t; + } +#pragma warning restore CS1998 +} + +/// Records the model string the engine streamed with. +public sealed class ModelCapturingChat : IChatStreaming +{ + private readonly string[] _tokens; + public string? LastModel { get; private set; } + + public ModelCapturingChat(params string[] tokens) => _tokens = tokens; + +#pragma warning disable CS1998 + public async IAsyncEnumerable StreamAsync( + string model, IReadOnlyList messages, + [EnumeratorCancellation] CancellationToken ct = default) + { + LastModel = model; + foreach (var t in _tokens) yield return t; + } +#pragma warning restore CS1998 +} diff --git a/windows/tests/AINotebook.Core.Tests/Storage/AttachmentStoreTraversalTests.cs b/windows/tests/AINotebook.Core.Tests/Storage/AttachmentStoreTraversalTests.cs new file mode 100644 index 0000000..cecddec --- /dev/null +++ b/windows/tests/AINotebook.Core.Tests/Storage/AttachmentStoreTraversalTests.cs @@ -0,0 +1,65 @@ +using AINotebook.Core.Storage; +using Xunit; + +namespace AINotebook.Core.Tests.Storage; + +/// +/// The attachment filename has always been reduced to one path component, but +/// the note-folder segment beside it was joined onto the root raw. On the +/// serving side that value is `uri.Host` from the editor's attachment:// URL, +/// and .NET parses `attachment://../db.sqlite` into Host == "..", so a crafted +/// image URL in a note resolved one level above the attachments root — the +/// directory holding db.sqlite and settings.json. +/// +public class AttachmentStoreTraversalTests +{ + private static (AttachmentStore attachments, string root, NotebookStore store, long noteId) Setup() + { + var store = new NotebookStore(StorePath.InMemory); + var nb = store.CreateNotebook("N", ""); + // attachments.note_id is a real FK, so the row needs a real note. + var note = store.CreateNote(nb.Id!.Value, "t", "b"); + var root = Path.Combine(Path.GetTempPath(), "aino-attach-" + Guid.NewGuid().ToString("N")); + return (new AttachmentStore(store, root), root, store, note.Id!.Value); + } + + [Theory] + [InlineData("..")] + [InlineData(".")] + [InlineData("")] + [InlineData("../..")] // GetFileName reduces this to ".." too + [InlineData("foo/..")] + public void ReadRejectsTraversalNoteFolder(string noteUuid) + { + var (attachments, root, store, noteId) = Setup(); + using (store) + { + // A file one level above the attachments root — what the old code + // would happily hand back to the WebView. + var outside = Path.Combine(Directory.GetParent(root)!.FullName, "escaped.bin"); + + Assert.Throws(() => attachments.Read(noteUuid, "escaped.bin")); + Assert.Throws(() => attachments.DeleteFolder(noteUuid)); + Assert.Throws( + () => attachments.Save(noteId, noteUuid, "escaped.bin", "image/png", new byte[] { 1 })); + + Assert.False(File.Exists(outside), "traversal write escaped the attachments root"); + } + } + + [Fact] + public void RoundTripsInsideTheNoteFolder() + { + var (attachments, root, store, noteId) = Setup(); + using (store) + { + var uuid = Guid.NewGuid().ToString().ToLowerInvariant(); + var att = attachments.Save(noteId, uuid, "shot.png", "image/png", new byte[] { 1, 2, 3 }); + + Assert.Equal(new byte[] { 1, 2, 3 }, attachments.Read(uuid, att.Filename)); + Assert.True(File.Exists(Path.Combine(root, uuid, att.Filename))); + + Directory.Delete(root, recursive: true); + } + } +} diff --git a/windows/tests/AINotebook.Core.Tests/Storage/BackupTests.cs b/windows/tests/AINotebook.Core.Tests/Storage/BackupTests.cs new file mode 100644 index 0000000..4f6c9ec --- /dev/null +++ b/windows/tests/AINotebook.Core.Tests/Storage/BackupTests.cs @@ -0,0 +1,65 @@ +using AINotebook.Core.Storage; +using Xunit; + +namespace AINotebook.Core.Tests.Storage; + +/// +/// BackupTo used to run `PRAGMA wal_checkpoint(FULL)` then File.Copy the +/// database file. journal_mode is never set to WAL anywhere in this app, so the +/// checkpoint was a no-op, and copying a live database file can capture a torn +/// write. It also could not back up the in-memory store at all. SQLite's +/// online-backup API snapshots under a read transaction instead. +/// +/// These tests use the in-memory store deliberately: `VACUUM INTO`, the obvious +/// alternative, silently produces no file at all for an in-memory source. +/// +public class BackupTests +{ + [Fact] + public void BackupProducesAReadableCopyOfTheData() + { + var dest = Path.Combine(Path.GetTempPath(), $"aino-backup-{Guid.NewGuid():N}.sqlite"); + try + { + using (var store = new NotebookStore(StorePath.InMemory)) + { + store.CreateNotebook("Kept", "desc"); + store.BackupTo(dest); + } + + Assert.True(File.Exists(dest)); + + using var restored = new NotebookStore(new StorePath(dest)); + Assert.Contains(restored.Notebooks(), n => n.Name == "Kept"); + } + finally + { + if (File.Exists(dest)) File.Delete(dest); + } + } + + [Fact] + public void BackupOverwritesAnExistingDestination() + { + var dest = Path.Combine(Path.GetTempPath(), $"aino-backup-{Guid.NewGuid():N}.sqlite"); + try + { + // VACUUM INTO refuses a destination that already exists, so the + // file the picker hands us must be removed first. + File.WriteAllText(dest, "stale"); + + using (var store = new NotebookStore(StorePath.InMemory)) + { + store.CreateNotebook("Fresh", ""); + store.BackupTo(dest); + } + + using var restored = new NotebookStore(new StorePath(dest)); + Assert.Contains(restored.Notebooks(), n => n.Name == "Fresh"); + } + finally + { + if (File.Exists(dest)) File.Delete(dest); + } + } +} diff --git a/windows/tests/AINotebook.Core.Tests/Storage/NotebookStoreConcurrencyTests.cs b/windows/tests/AINotebook.Core.Tests/Storage/NotebookStoreConcurrencyTests.cs new file mode 100644 index 0000000..d19a082 --- /dev/null +++ b/windows/tests/AINotebook.Core.Tests/Storage/NotebookStoreConcurrencyTests.cs @@ -0,0 +1,69 @@ +using AINotebook.Core.Models; +using AINotebook.Core.Storage; +using Xunit; + +namespace AINotebook.Core.Tests.Storage; + +/// +/// The store keeps ONE SqliteConnection for its lifetime, and +/// Microsoft.Data.Sqlite's connection is not thread-safe — yet it is used +/// concurrently in production (EmbeddingWorker drains on a thread-pool thread, +/// FolderWatchService ingests on another, the ViewModels wrap most calls in +/// Task.Run, Retriever queries from async continuations). Before the store +/// serialized access these overlapped and faulted at random. +/// +/// macOS never had the bug: GRDB's DatabaseQueue serializes for it. +/// +public class NotebookStoreConcurrencyTests +{ + [Fact] + public async Task ParallelReadsAndWritesDoNotFault() + { + using var store = new NotebookStore(StorePath.InMemory); + var nb = store.CreateNotebook("N", ""); + var nbId = nb.Id!.Value; + var src = store.CreateSource(nbId, SourceType.Text, "S", null, null); + store.ReplaceChunks(src.Id!.Value, new[] { new ChunkDraft("body", 1, null) }); + var chunkId = store.Chunks(src.Id!.Value)[0].Id!.Value; + + // Mixed readers and writers, all off the calling thread — the shape + // that used to throw "InvalidOperationException: connection is busy". + var work = new List(); + for (var i = 0; i < 16; i++) + { + var n = i; + work.Add(Task.Run(() => store.Notebooks())); + work.Add(Task.Run(() => store.CreateNote(nbId, $"note {n}", "body"))); + work.Add(Task.Run(() => store.Notes(nbId))); + work.Add(Task.Run(() => store.StoreEmbedding(chunkId, $"m{n}", new EmbeddingVector(new[] { 1f, 0f })))); + work.Add(Task.Run(() => store.Embeddings(nbId, $"m{n}"))); + work.Add(Task.Run(() => store.UnembeddedCount("nope"))); + } + + await Task.WhenAll(work); + + // Every write landed exactly once. + Assert.Equal(16, store.Notes(nbId).Count); + } + + [Fact] + public async Task CompoundMethodStaysAtomicUnderContention() + { + using var store = new NotebookStore(StorePath.InMemory); + var nb = store.CreateNotebook("N", ""); + var nbId = nb.Id!.Value; + var note = store.CreateNote(nbId, "t", "b"); + var noteId = note.Id!.Value; + + // SnapshotNoteVersion reads the note, inserts a version, then trims to + // the cap — a read-modify-write that must not interleave with itself. + var work = Enumerable.Range(0, 32) + .Select(_ => Task.Run(() => store.SnapshotNoteVersion(noteId, NoteVersionReason.Autosave))) + .ToArray(); + await Task.WhenAll(work); + + var versions = store.NoteVersions(noteId); + Assert.True(versions.Count <= NotebookStore.NoteVersionCap, + $"cap breached: {versions.Count} > {NotebookStore.NoteVersionCap}"); + } +}