From 7bbde8ed1a5b4eaecd3e05a63c7c743b2da39faf Mon Sep 17 00:00:00 2001 From: till Date: Fri, 22 May 2026 14:19:46 +0200 Subject: [PATCH] =?UTF-8?q?v0.2.0=20=E2=80=94=20Lift=20LLMBackendPreferenc?= =?UTF-8?q?eStore=20+=20LLMDownloadOverCellularStore=20aus=20memoro-native?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stores leben jetzt im Package mit App-übergreifenden Keys (mana.llm.backend, mana.llm.allowCellular). Auto-Migration aus memoro.* Legacy-Keys beim ersten Read (memoro.llmBackend, memoro.onDeviceLLMEnabled Bool-Toggle, memoro.llmDownloadOverCellular). Ermöglicht ManaLLMUI in mana-swift-ui 0.8.0 als geteilte Settings- Schicht für alle 4 Konsumenten (Memoro, Pageta, Comicello, Herbatrium). Außerdem: - LLMBackend.removeCachedModel() als Protocol-Methode mit Default- No-Op. GemmaBackend überschreibt (async throws statt throws). - 13 neue Tests in LLMPreferenceStoresTests (.serialized wegen UserDefaults.standard). Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 57 +++++++++++ Sources/ManaLLM/GemmaBackend.swift | 5 +- Sources/ManaLLM/LLMBackend.swift | 10 ++ .../ManaLLM/LLMBackendPreferenceStore.swift | 62 ++++++++++++ .../LLMDownloadOverCellularStore.swift | 43 ++++++++ .../LLMPreferenceStoresTests.swift | 98 +++++++++++++++++++ 6 files changed, 274 insertions(+), 1 deletion(-) create mode 100644 CHANGELOG.md create mode 100644 Sources/ManaLLM/LLMBackendPreferenceStore.swift create mode 100644 Sources/ManaLLM/LLMDownloadOverCellularStore.swift create mode 100644 Tests/ManaLLMTests/LLMPreferenceStoresTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..cfea56f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,57 @@ +# Changelog + +Alle Änderungen werden hier dokumentiert. Format orientiert an +[Keep a Changelog](https://keepachangelog.com), Versionierung nach +[Semver](https://semver.org). + +## [Unreleased] + +## [0.2.0] — 2026-05-22 + +Minor — **Preference-Stores nach `ManaLLM` geliftet** + UI-Konsoldierung +in `mana-swift-ui` möglich gemacht. + +### Hintergrund + +Vorher lebten `LLMBackendPreferenceStore` und +`LLMDownloadOverCellularStore` mit Memoro-spezifischen UserDefaults-Keys +(`memoro.llmBackend`, `memoro.llmDownloadOverCellular`) ausschließlich +in `memoro-native`. Damit die anderen ManaLLM-Konsumenten (Pageta, +Comicello, Herbatrium) ebenfalls eine Settings-UI bekommen können +(siehe `mana-swift-ui` 0.8.0 / `ManaLLMUI`), wandern die Stores hier +ins Package und nutzen App-übergreifende Keys (`mana.llm.*`). + +### Neu + +- `LLMBackendPreferenceStore` — UserDefaults-Store für die On-Device- + LLM-Wahl (`mana.llm.backend`). Default: `.appleFM`. +- `LLMDownloadOverCellularStore` — WiFi-only-Default für Modell- + Downloads (`mana.llm.allowCellular`). Default: `false`. +- Beide Stores migrieren beim ersten Read einmalig aus Legacy-Keys: + - `memoro.llmBackend` → `mana.llm.backend` + - `memoro.onDeviceLLMEnabled` (alter Bool-Toggle) → mapped zu + `.appleFM` / `.noOp`, dann `mana.llm.backend` + - `memoro.llmDownloadOverCellular` → `mana.llm.allowCellular` +- `LLMBackend.removeCachedModel()` — neue Protocol-Methode mit + Default-No-Op-Impl. `GemmaBackend` überschreibt (löscht + HF-Repo-Pfad im App-Group-Container). Apple FM und NoOp bleiben + No-Op. +- Test-Suite `LLMPreferenceStoresTests` (13 Tests, `.serialized` + wegen `UserDefaults.standard`). + +### Geändert + +- `GemmaBackend.removeCachedModel()`-Signatur: `throws` → `async throws` + (für Protocol-Konformität). Caller müssen `try await` schreiben statt + `try`. + +### Migration für Apps + +Apps die schon `ManaLLM` konsumieren brauchen nichts zu ändern — +Symbol-Namen bleiben identisch, Defaults bleiben identisch, alte +UserDefaults-Keys werden transparent migriert. Memoro hat seine +lokalen Store-Duplikate gelöscht; der Build bleibt grün, weil +Memoro `import ManaLLM` schon hatte. + +Apps die heute keine LLM-Settings-UI haben können jetzt +`ManaLLMUI` aus `mana-swift-ui` 0.8.0 einhängen (drop-in). diff --git a/Sources/ManaLLM/GemmaBackend.swift b/Sources/ManaLLM/GemmaBackend.swift index de6c9a7..b1180ac 100644 --- a/Sources/ManaLLM/GemmaBackend.swift +++ b/Sources/ManaLLM/GemmaBackend.swift @@ -234,7 +234,10 @@ public actor GemmaBackend: LLMBackend { /// Löscht das Modell aus dem HF-Cache. Achtung: in einem /// Shared-Container betrifft das ALLE teilnehmenden Apps. - public func removeCachedModel() throws { + /// + /// `async` aus Protocol-Konformität (Apple FM überschreibt nicht; + /// hier ist der Body synchron, aber der Aktor-Hop ist sowieso da). + public func removeCachedModel() async throws { container = nil try ManaSharedModels.removeModel(repo: variant.hfRepoID) LLMLog.backend.notice( diff --git a/Sources/ManaLLM/LLMBackend.swift b/Sources/ManaLLM/LLMBackend.swift index 1be4e2f..a5146a5 100644 --- a/Sources/ManaLLM/LLMBackend.swift +++ b/Sources/ManaLLM/LLMBackend.swift @@ -44,9 +44,19 @@ public protocol LLMBackend: Sendable { /// Standard-Summary-Prompt — Backends können das überschreiben /// für Modell-spezifische Optimierungen. func summarize(transcript: String) async -> LLMSummary? + + /// Entfernt ggf. gecachtes Modell von Disk. Default-Impl ist + /// No-Op (für Apple FM, NoOp — die haben keinen + /// Caller-kontrollierten Cache). Gemma überschreibt und löscht + /// den HF-Repo-Pfad im App-Group-Container. + func removeCachedModel() async throws } public extension LLMBackend { + /// Default: kein Cache zu löschen. Backends mit gemanagtem Disk- + /// Cache (Gemma) überschreiben. + func removeCachedModel() async throws {} + /// Default-Implementation für `summarize` auf Basis von /// `generate`. Backends mit optimiertem Summary-Pfad /// (z.B. AppleFMBackend mit FoundationModels-Schema) überschreiben. diff --git a/Sources/ManaLLM/LLMBackendPreferenceStore.swift b/Sources/ManaLLM/LLMBackendPreferenceStore.swift new file mode 100644 index 0000000..e8a4490 --- /dev/null +++ b/Sources/ManaLLM/LLMBackendPreferenceStore.swift @@ -0,0 +1,62 @@ +import Foundation + +/// Persistenter UserDefaults-Store für die User-Wahl des On-Device-LLM- +/// Backends. Geteilt von allen mana-e.V.-Apps (Memoro, Pageta, Comicello, +/// Herbatrium, …) — Wert lebt pro App in `UserDefaults.standard`, weil +/// verschiedene Apps verschiedene Use-Case-Präferenzen haben können +/// (z.B. Moodlit favorisiert Gemma E2B wegen Creative-Mood-Mapping, +/// Pageta favorisiert AppleFM wegen Latenz beim Artikel-Summary). +/// +/// **Default-Wert:** `.appleFM`. Auf Geräten ohne Apple Intelligence +/// fällt der Router zur Runtime über `availability()` auf den nächsten +/// selectable Backend zurück; der Default ist also "optimistisch" und +/// muss in der UI nicht selbst nachgepflegt werden. +/// +/// **Legacy-Migration:** Apps die früher eigene Keys hatten werden +/// einmalig migriert: +/// - `memoro.llmBackend` (Memoros bisheriger Key) → `mana.llm.backend` +/// - `memoro.onDeviceLLMEnabled` (Memoros älterer Bool-Toggle) → +/// `.appleFM` (true) bzw. `.noOp` (false), dann auf +/// `mana.llm.backend` +/// +/// Die Migration läuft auf jedem Read aus `current`. Erster Read +/// migriert + löscht den Legacy-Key; danach existiert nur noch der +/// neue Key. +public enum LLMBackendPreferenceStore { + private static let key = "mana.llm.backend" + private static let legacyMemoroKey = "memoro.llmBackend" + private static let legacyMemoroBoolKey = "memoro.onDeviceLLMEnabled" + + /// Aktuelle Backend-Wahl. Läuft die Migration aus Legacy-Keys + /// einmalig durch (siehe Doc-Kommentar oben). + public static var current: LLMBackendID { + // 1. Neuer Key + if let raw = UserDefaults.standard.string(forKey: key), + let value = LLMBackendID(rawValue: raw) + { + return value + } + // 2. Memoro-Legacy-String-Key + if let raw = UserDefaults.standard.string(forKey: legacyMemoroKey), + let value = LLMBackendID(rawValue: raw) + { + set(value) + UserDefaults.standard.removeObject(forKey: legacyMemoroKey) + return value + } + // 3. Memoros älterer Bool-Toggle + if UserDefaults.standard.object(forKey: legacyMemoroBoolKey) != nil { + let wasEnabled = UserDefaults.standard.bool(forKey: legacyMemoroBoolKey) + let migrated: LLMBackendID = wasEnabled ? .appleFM : .noOp + set(migrated) + UserDefaults.standard.removeObject(forKey: legacyMemoroBoolKey) + return migrated + } + // 4. Default + return .appleFM + } + + public static func set(_ value: LLMBackendID) { + UserDefaults.standard.set(value.rawValue, forKey: key) + } +} diff --git a/Sources/ManaLLM/LLMDownloadOverCellularStore.swift b/Sources/ManaLLM/LLMDownloadOverCellularStore.swift new file mode 100644 index 0000000..596041e --- /dev/null +++ b/Sources/ManaLLM/LLMDownloadOverCellularStore.swift @@ -0,0 +1,43 @@ +import Foundation + +/// WiFi-only-Default für große Modell-Downloads (Gemma 4 E2B ~1.3 GB, +/// E4B ~2.5 GB). User kann via Settings-Toggle auch Mobilfunk erlauben. +/// +/// Wert pro App in `UserDefaults.standard`. Default `false`. Apps +/// reichen den Wert beim Erzeugen eines `LLMRouter` als +/// `gemmaAllowsCellular`-Parameter weiter: +/// +/// ```swift +/// LLMRouter( +/// preferred: [LLMBackendPreferenceStore.current], +/// gemmaAllowsCellular: LLMDownloadOverCellularStore.isAllowed +/// ) +/// ``` +/// +/// **Legacy-Migration:** Memoros bisheriger Key +/// `memoro.llmDownloadOverCellular` wird beim ersten Read einmalig in +/// `mana.llm.allowCellular` überführt. +public enum LLMDownloadOverCellularStore { + private static let key = "mana.llm.allowCellular" + private static let legacyMemoroKey = "memoro.llmDownloadOverCellular" + + public static var isAllowed: Bool { + // 1. Neuer Key + if UserDefaults.standard.object(forKey: key) != nil { + return UserDefaults.standard.bool(forKey: key) + } + // 2. Memoro-Legacy + if UserDefaults.standard.object(forKey: legacyMemoroKey) != nil { + let migrated = UserDefaults.standard.bool(forKey: legacyMemoroKey) + set(migrated) + UserDefaults.standard.removeObject(forKey: legacyMemoroKey) + return migrated + } + // 3. Default: WiFi-only + return false + } + + public static func set(_ value: Bool) { + UserDefaults.standard.set(value, forKey: key) + } +} diff --git a/Tests/ManaLLMTests/LLMPreferenceStoresTests.swift b/Tests/ManaLLMTests/LLMPreferenceStoresTests.swift new file mode 100644 index 0000000..e860f07 --- /dev/null +++ b/Tests/ManaLLMTests/LLMPreferenceStoresTests.swift @@ -0,0 +1,98 @@ +import Foundation +import Testing +@testable import ManaLLM + +/// Tests für die UserDefaults-basierten Preference-Stores. +/// +/// **Serialized:** Die Stores greifen hart auf `UserDefaults.standard` +/// zu (bewusst — App-Wide). Tests müssen daher seriell laufen, sonst +/// rennen sie sich gegenseitig in den State. `.serialized` reicht hier, +/// weil die Tests trivial schnell sind (<20 ms total). +@Suite(.serialized) +struct LLMPreferenceStoresTests { + // MARK: - LLMBackendPreferenceStore + + @Test func returnsDefaultWhenNoKeySet() { + withClean(keys: ["mana.llm.backend", "memoro.llmBackend", "memoro.onDeviceLLMEnabled"]) { + #expect(LLMBackendPreferenceStore.current == .appleFM) + } + } + + @Test func roundTripsValue() { + withClean(keys: ["mana.llm.backend", "memoro.llmBackend", "memoro.onDeviceLLMEnabled"]) { + LLMBackendPreferenceStore.set(.gemmaE4B) + #expect(LLMBackendPreferenceStore.current == .gemmaE4B) + } + } + + @Test func migratesMemoroStringKey() { + withClean(keys: ["mana.llm.backend", "memoro.llmBackend", "memoro.onDeviceLLMEnabled"]) { + UserDefaults.standard.set("gemmaE2B", forKey: "memoro.llmBackend") + #expect(LLMBackendPreferenceStore.current == .gemmaE2B) + // Legacy-Key wurde gelöscht + #expect(UserDefaults.standard.object(forKey: "memoro.llmBackend") == nil) + // Neuer Key gesetzt + #expect(UserDefaults.standard.string(forKey: "mana.llm.backend") == "gemmaE2B") + } + } + + @Test func migratesMemoroBoolToggleTrueToAppleFM() { + withClean(keys: ["mana.llm.backend", "memoro.llmBackend", "memoro.onDeviceLLMEnabled"]) { + UserDefaults.standard.set(true, forKey: "memoro.onDeviceLLMEnabled") + #expect(LLMBackendPreferenceStore.current == .appleFM) + #expect(UserDefaults.standard.object(forKey: "memoro.onDeviceLLMEnabled") == nil) + } + } + + @Test func migratesMemoroBoolToggleFalseToNoOp() { + withClean(keys: ["mana.llm.backend", "memoro.llmBackend", "memoro.onDeviceLLMEnabled"]) { + UserDefaults.standard.set(false, forKey: "memoro.onDeviceLLMEnabled") + #expect(LLMBackendPreferenceStore.current == .noOp) + } + } + + @Test func newKeyTrumpsLegacy() { + withClean(keys: ["mana.llm.backend", "memoro.llmBackend", "memoro.onDeviceLLMEnabled"]) { + UserDefaults.standard.set("gemmaE4B", forKey: "mana.llm.backend") + UserDefaults.standard.set("appleFM", forKey: "memoro.llmBackend") + #expect(LLMBackendPreferenceStore.current == .gemmaE4B) + // Legacy bleibt unangetastet wenn neuer Key schon existiert + #expect(UserDefaults.standard.string(forKey: "memoro.llmBackend") == "appleFM") + } + } + + // MARK: - LLMDownloadOverCellularStore + + @Test func cellularDefaultsToFalse() { + withClean(keys: ["mana.llm.allowCellular", "memoro.llmDownloadOverCellular"]) { + #expect(LLMDownloadOverCellularStore.isAllowed == false) + } + } + + @Test func cellularRoundTripsTrue() { + withClean(keys: ["mana.llm.allowCellular", "memoro.llmDownloadOverCellular"]) { + LLMDownloadOverCellularStore.set(true) + #expect(LLMDownloadOverCellularStore.isAllowed == true) + } + } + + @Test func cellularMigratesMemoroKey() { + withClean(keys: ["mana.llm.allowCellular", "memoro.llmDownloadOverCellular"]) { + UserDefaults.standard.set(true, forKey: "memoro.llmDownloadOverCellular") + #expect(LLMDownloadOverCellularStore.isAllowed == true) + #expect(UserDefaults.standard.object(forKey: "memoro.llmDownloadOverCellular") == nil) + #expect(UserDefaults.standard.bool(forKey: "mana.llm.allowCellular") == true) + } + } + + // MARK: - Helpers + + /// Räumt vor und nach dem Block bestimmte Keys ab, damit Tests + /// reproduzierbar starten und kein Test-State über Tests hinweg + /// leakt. + private func withClean(keys: [String], _ block: () -> Void) { + for key in keys { UserDefaults.standard.removeObject(forKey: key) } + defer { for key in keys { UserDefaults.standard.removeObject(forKey: key) } } + block() + } +}