v0.2.0 — Lift LLMBackendPreferenceStore + LLMDownloadOverCellularStore aus memoro-native
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 <noreply@anthropic.com>
This commit is contained in:
parent
18e7b16cc1
commit
7bbde8ed1a
6 changed files with 274 additions and 1 deletions
57
CHANGELOG.md
Normal file
57
CHANGELOG.md
Normal file
|
|
@ -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).
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
62
Sources/ManaLLM/LLMBackendPreferenceStore.swift
Normal file
62
Sources/ManaLLM/LLMBackendPreferenceStore.swift
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
43
Sources/ManaLLM/LLMDownloadOverCellularStore.swift
Normal file
43
Sources/ManaLLM/LLMDownloadOverCellularStore.swift
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
98
Tests/ManaLLMTests/LLMPreferenceStoresTests.swift
Normal file
98
Tests/ManaLLMTests/LLMPreferenceStoresTests.swift
Normal file
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue