Drop-in-Settings-UI für die lokalen LLM-Backends aus mana-swift-llm. Pendant zu ManaAuthUI — vorher hatte nur Memoro die UI handgeschrieben, die drei anderen Konsumenten (pageta, comicello, herbatrium) gar nichts. Komponenten: - ManaLLMSettingsView(context:) — Convenience-Wrapper, drei Sections - ManaLLMBackendPickerSection — Picker + Availability + Empfohlen-Badge - ManaLLMPrepareSection — Download/Init-Card mit Progress, gated für Gemma - ManaLLMDownloadPolicySection — WiFi-only-Toggle - ManaLLMSettingsState (@Observable, @MainActor) — geteilter State, delegiert an Stores aus mana-swift-llm 0.2.0 - ManaLLMContext(useCaseShort:useCaseLong:) — app-spezifischer Section-Text; .generic als Fallback Test-Target ManaLLMUITests bewusst noch nicht angelegt (Linter hat es aus Package.swift entfernt, Comment markiert TODO). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
200 lines
7 KiB
Swift
200 lines
7 KiB
Swift
import ManaLLM
|
|
import SwiftUI
|
|
|
|
/// Status-Card mit Spinner/Check/Fail, "Modell laden" / "Modell
|
|
/// entfernen" und linearer Progress-Bar mit Byte-Anzeige. Nur sinnvoll
|
|
/// für Backends die einen Caller-Cache haben — heute Gemma. Apple FM
|
|
/// und NoOp brauchen die Section nicht (`shouldShow` ist `false`).
|
|
///
|
|
/// Apps die `ManaLLMSettingsView` benutzen kriegen die Section
|
|
/// automatisch (mit Sichtbarkeits-Gate). Wer die Section solo
|
|
/// einhängt, sollte den Gate selber abfragen:
|
|
///
|
|
/// ```swift
|
|
/// if ManaLLMPrepareSection.shouldShow(for: state) {
|
|
/// ManaLLMPrepareSection(state: state)
|
|
/// }
|
|
/// ```
|
|
public struct ManaLLMPrepareSection: View {
|
|
@Bindable private var state: ManaLLMSettingsState
|
|
|
|
public init(state: ManaLLMSettingsState) {
|
|
self.state = state
|
|
}
|
|
|
|
/// Sichtbarkeits-Gate. Heute: nur für Gemma-Backends Section
|
|
/// rendern. Apple FM hat kein Caller-Cache, NoOp trivialerweise
|
|
/// auch nicht.
|
|
public static func shouldShow(for state: ManaLLMSettingsState) -> Bool {
|
|
state.currentBackendNeedsPrepare
|
|
}
|
|
|
|
public var body: some View {
|
|
Section {
|
|
HStack(spacing: 12) {
|
|
statusIcon
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(statusTitle)
|
|
.font(.subheadline.weight(.medium))
|
|
Text(statusSubtitle)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
Spacer()
|
|
}
|
|
if state.prepareStatus == .preparing {
|
|
downloadProgress
|
|
}
|
|
if state.prepareStatus != .preparing {
|
|
Button {
|
|
Task { await state.prepare() }
|
|
} label: {
|
|
Label(
|
|
state.prepareStatus == .ready ? "Erneut prüfen" : "Modell laden",
|
|
systemImage: "arrow.down.circle"
|
|
)
|
|
}
|
|
if state.currentBackendIsCached {
|
|
Button(role: .destructive) {
|
|
Task { await state.removeCachedModel() }
|
|
} label: {
|
|
Label("Modell entfernen", systemImage: "trash")
|
|
}
|
|
}
|
|
}
|
|
if let error = state.prepareError {
|
|
Text(error)
|
|
.font(.caption)
|
|
.foregroundStyle(.red)
|
|
}
|
|
} header: {
|
|
Text(headerText)
|
|
} footer: {
|
|
// Bei App-Group-Cache: alle teilnehmenden Apps lesen
|
|
// dasselbe Modell. Das gehört in den User-sichtbaren
|
|
// Footer, weil 'Modell entfernen' eben auch das Modell
|
|
// für andere mana-Apps entfernt.
|
|
Text(
|
|
"Gemma-Modelle liegen im geteilten App-Group-Container "
|
|
+ "(group.ev.mana.models). Andere mana-Apps können dasselbe Modell "
|
|
+ "ohne zweiten Download nutzen — und 'Modell entfernen' "
|
|
+ "entfernt es auch dort."
|
|
)
|
|
.font(.caption2)
|
|
}
|
|
}
|
|
|
|
// MARK: - Header
|
|
|
|
private var headerText: String {
|
|
switch state.backend {
|
|
case .appleFM: "Apple Foundation Models"
|
|
case .gemmaE2B, .gemmaE4B: "Gemma-Modell"
|
|
case .noOp: "Modell"
|
|
}
|
|
}
|
|
|
|
// MARK: - Status
|
|
|
|
private var statusIcon: some View {
|
|
Group {
|
|
switch state.prepareStatus {
|
|
case .idle:
|
|
Image(systemName: "questionmark.circle").foregroundStyle(.secondary)
|
|
case .preparing:
|
|
ProgressView().controlSize(.small)
|
|
case .ready:
|
|
Image(systemName: "checkmark.circle.fill").foregroundStyle(.green)
|
|
case .failed:
|
|
Image(systemName: "exclamationmark.circle.fill").foregroundStyle(.red)
|
|
}
|
|
}
|
|
.font(.title3)
|
|
.frame(width: 28)
|
|
}
|
|
|
|
private var statusTitle: String {
|
|
switch state.prepareStatus {
|
|
case .idle:
|
|
switch state.availability[state.backend] ?? .unknown("") {
|
|
case .available: "Bereit"
|
|
case .requiresDownload: "Noch nicht geladen"
|
|
default: "Status prüfen"
|
|
}
|
|
case .preparing: "Wird vorbereitet …"
|
|
case .ready: "Bereit"
|
|
case .failed: "Fehler"
|
|
}
|
|
}
|
|
|
|
private var statusSubtitle: String {
|
|
switch state.backend {
|
|
case .appleFM:
|
|
"Apple verwaltet das Modell automatisch — kein manuelles Vorladen nötig."
|
|
case .gemmaE2B:
|
|
"Gemma 4 E2B 4-bit von mlx-community auf Hugging Face. ~1.3 GB."
|
|
case .gemmaE4B:
|
|
"Gemma 4 E4B 4-bit von mlx-community auf Hugging Face. ~2.5 GB."
|
|
case .noOp:
|
|
""
|
|
}
|
|
}
|
|
|
|
// MARK: - Progress
|
|
|
|
/// Drei Anzeigemodi je nach Datenlage:
|
|
/// 1. `fractionCompleted > 0` → linearer Balken + Prozent + Bytes
|
|
/// 2. `bytesCompleted > 0` (aber Fraction 0) → linearer Balken aus
|
|
/// Bytes-Verhältnis + Bytes-Text
|
|
/// 3. Sonst → indeterminierter Spinner + "Verbinde mit Hugging Face …"
|
|
/// Damit User nie ein totes "0 %" sieht, während der Download
|
|
/// in Wirklichkeit schon Metadaten zieht.
|
|
@ViewBuilder
|
|
private var downloadProgress: some View {
|
|
let byteFraction: Double? = {
|
|
guard let total = state.prepareBytesTotal, total > 1,
|
|
let done = state.prepareBytesDone, done > 0
|
|
else { return nil }
|
|
return Double(done) / Double(total)
|
|
}()
|
|
let effectiveFraction = state.prepareProgress > 0 ? state.prepareProgress : (byteFraction ?? 0)
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
if effectiveFraction > 0 {
|
|
ProgressView(value: min(effectiveFraction, 1.0))
|
|
.progressViewStyle(.linear)
|
|
} else {
|
|
ProgressView()
|
|
.progressViewStyle(.linear)
|
|
}
|
|
HStack {
|
|
Text(progressLabel)
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
Spacer()
|
|
Text(progressValue(effectiveFraction: effectiveFraction))
|
|
.font(.caption2.monospacedDigit())
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var progressLabel: String {
|
|
if let done = state.prepareBytesDone, done > 0 {
|
|
return "Lade von Hugging Face …"
|
|
}
|
|
return "Verbinde mit Hugging Face …"
|
|
}
|
|
|
|
private func progressValue(effectiveFraction: Double) -> String {
|
|
if let done = state.prepareBytesDone,
|
|
let total = state.prepareBytesTotal, total > 1
|
|
{
|
|
return "\(ByteFormatter.string(fromByteCount: done)) / "
|
|
+ "\(ByteFormatter.string(fromByteCount: total))"
|
|
}
|
|
if effectiveFraction > 0 {
|
|
return "\(Int(effectiveFraction * 100)) %"
|
|
}
|
|
return "…"
|
|
}
|
|
}
|