mana-swift-llm/Sources/ManaLLMShared/ManaSharedModels.swift
till 71ad295b73 mana statt »e.V.«: Schweizer Verein (kein Store-Release, nur Quelltext-Korrektur)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 20:32:29 +02:00

148 lines
5.3 KiB
Swift

import Foundation
/// Container für HuggingFace-Cache, geteilt über alle mana-
/// 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)
}
}