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:
till 2026-05-22 14:19:46 +02:00
parent 18e7b16cc1
commit 7bbde8ed1a
6 changed files with 274 additions and 1 deletions

View file

@ -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(

View file

@ -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.

View 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)
}
}

View 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)
}
}