mana-swift-llm/Sources/ManaLLMShared/ManaSharedModels.swift
till fd376bbdce L-1+L-2+L-3: mana-swift-llm Initial — lift aus memoro-native
Neues Swift-Package mit lokalen LLM-Backends für alle nativen mana-
e.V.-Apps. Lift der bisher Memoro-eigenen Files in
`memoro-native/Sources/Core/AI/` plus zwei neue Layer:
ManaSharedModels (App-Group-Container-Helper) und ManaLLM-Facade.

Library-Products:
- ManaLLM — Backend-Abstraktion (FoundationModels, Gemma 4 E2B/E4B,
  NoOp), Router mit Priority-Liste, High-Level-Facade
  `ManaLLM.summarize/generate/classify` mit fast/creative/deep Level.
- ManaLLMShared — App-Group `group.ev.mana.models` Container,
  HF_HUB_CACHE-Setup, Legacy-Fallback wenn Group fehlt.

Lift-Anpassungen ggü. memoro:
- public-Marker auf protocol + types + actors
- generischer `generate(prompt:instructions:maxTokens:)` zu
  LLMBackend-Protocol hinzu; `summarize` als Default-Impl auf
  Basis von generate
- AppleFMBackend behält optimierten @Generable-Summary-Path
- GemmaBackend nutzt ManaSharedModels.effectiveCacheURL() statt
  eigenen Application-Support-Pfad; allowsCellular kommt jetzt
  als Initializer-Param statt App-Settings-Lookup
- LLMRouter: Memoro-spezifische User-Pref-Store-Logic durch
  Priority-Liste-API ersetzt
- LLMLog-Subsystem `ev.mana.llm` statt App-eigenes `Log.ai`

Build: `swift build` clean (76s, MLX-Toolchain-Resolution beim
ersten Lauf). 4/4 Parser-Tests grün.

Doku: ../mana/docs/MANA_LLM.md (Plattform-SOT), CLAUDE.md
(Konventionen + Lift-Tabelle).

Folge: L-4 Memoro auf ManaLLM umstellen, L-5 pageta-Pilot.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 22:55:32 +02:00

148 lines
5.3 KiB
Swift

import Foundation
/// Container für HuggingFace-Cache, geteilt über alle mana-e.V.-
/// Apps via App-Group `group.ev.mana.models`. Apps mit dieser
/// Group im Entitlement lesen Modelle aus demselben Pfad kein
/// Doppel-Download.
///
/// **Setup pro App:**
///
/// 1. Apple-Dev-Portal: App ID öffnen App Groups Capability
/// `group.ev.mana.models` hinzufügen.
/// 2. `project.yml` Entitlement:
/// ```yaml
/// com.apple.security.application-groups:
/// - group.ev.mana.<app>
/// - group.ev.mana.models
/// ```
/// 3. App-Code beim Boot:
/// ```swift
/// ManaSharedModels.configureHuggingFaceCacheEnv()
/// ```
/// Damit zeigt MLX-Swift's HuggingFace-Hub-Client beim ersten
/// `Hub.snapshot(...)` automatisch in den Shared-Container.
///
/// SOT-Doku: `mana/docs/MANA_LLM.md`.
public enum ManaSharedModels {
/// Kanonische App-Group für gemeinsamen Modell-Container.
public static let appGroup = "group.ev.mana.models"
/// Pfad-Convention im Container, kompatibel zu HuggingFace-Hub-
/// Default-Layout (`~/.cache/huggingface/hub/...`).
public static let hubSubdirectory = "huggingface/hub"
/// URL des HuggingFace-Cache-Roots im Shared-Container.
///
/// Returns `nil`, wenn:
/// - die App das `group.ev.mana.models`-Entitlement nicht hat,
/// - die App in einem Modus läuft, in dem App-Group-Container
/// nicht zugänglich sind (z.B. bestimmte Extension-Kontexte).
///
/// Caller fallen dann auf App-eigenen `Application Support`
/// zurück `legacyCacheURL()` liefert das.
public static func cacheURL() -> URL? {
guard let container = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: appGroup
) else {
return nil
}
let hub = container.appending(path: hubSubdirectory)
// Verzeichnis anlegen + von iCloud-Backup ausschließen.
try? FileManager.default.createDirectory(at: hub, withIntermediateDirectories: true)
var hubVar = hub
var values = URLResourceValues()
values.isExcludedFromBackup = true
try? hubVar.setResourceValues(values)
return hub
}
/// Fallback wenn `cacheURL()` `nil` ist: App-eigener
/// `Application Support/huggingface/hub`. Pendant zum bisherigen
/// memoro-spezifischen Pfad kein Sharing, aber funktional.
public static func legacyCacheURL() -> URL? {
guard let appSupport = try? FileManager.default.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
) else {
return nil
}
let hub = appSupport.appending(path: hubSubdirectory)
try? FileManager.default.createDirectory(at: hub, withIntermediateDirectories: true)
var hubVar = hub
var values = URLResourceValues()
values.isExcludedFromBackup = true
try? hubVar.setResourceValues(values)
return hub
}
/// Bevorzugter Cache-Pfad: shared-Container falls verfügbar,
/// sonst Legacy-App-eigener Application Support.
public static func effectiveCacheURL() -> URL? {
cacheURL() ?? legacyCacheURL()
}
/// Setzt die `HF_HUB_CACHE`-Environment-Variable auf den shared
/// Container. MLX-Swift's `HubClient` und swift-huggingface's
/// `Hub.snapshot(...)` lesen diese Variable beim Boot.
///
/// Idempotent. Wenn der Shared-Container nicht zugänglich ist,
/// wird die Variable auf den Legacy-Pfad gesetzt Apps müssen
/// keinen Fallback-Code schreiben.
///
/// **Wichtig:** Diese Funktion möglichst früh im App-Boot
/// aufrufen (vor dem ersten LLM-Call), z.B. im
/// `init()` der `@main`-App-Struct.
@discardableResult
public static func configureHuggingFaceCacheEnv() -> URL? {
guard let url = effectiveCacheURL() else { return nil }
setenv("HF_HUB_CACHE", url.path, 1)
return url
}
/// Liefert URL eines konkreten Modell-Repo-Verzeichnisses im
/// Cache. Konvenientes Pendant zum HuggingFace-Pfad-Schema:
/// `<hub>/models--<owner>--<name>`.
///
/// Beispiel:
/// ```swift
/// ManaSharedModels.modelDirURL(repo: "mlx-community/gemma-4-e2b-it-4bit")
/// // <hub>/models--mlx-community--gemma-4-e2b-it-4bit
/// ```
public static func modelDirURL(repo: String) -> URL? {
guard let hub = effectiveCacheURL() else { return nil }
let dirName = "models--" + repo.replacingOccurrences(of: "/", with: "--")
return hub.appending(path: dirName)
}
/// Best-effort-Größenschätzung des Cache-Inhalts in Bytes.
/// Settings-Views können das nutzen, um "Lokale Modelle: 3.8 GB"
/// anzuzeigen.
public static func cacheSizeBytes() -> Int64 {
guard let hub = effectiveCacheURL() else { return 0 }
guard let enumerator = FileManager.default.enumerator(
at: hub,
includingPropertiesForKeys: [.totalFileAllocatedSizeKey, .isRegularFileKey]
) else {
return 0
}
var total: Int64 = 0
for case let url as URL in enumerator {
let values = try? url.resourceValues(forKeys: [.totalFileAllocatedSizeKey, .isRegularFileKey])
if values?.isRegularFile == true, let size = values?.totalFileAllocatedSize {
total += Int64(size)
}
}
return total
}
/// Löscht ein konkretes Modell-Repo aus dem Shared-Cache. Achtung:
/// betrifft alle teilnehmenden Apps. UIs sollten das explizit
/// kommunizieren ("Modelle für alle mana-Apps entfernen").
public static func removeModel(repo: String) throws {
guard let dir = modelDirURL(repo: repo) else { return }
guard FileManager.default.fileExists(atPath: dir.path()) else { return }
try FileManager.default.removeItem(at: dir)
}
}