Symmetrisch zur Web-speech.ts. Neue Modalität nach Vision; TTS liefert Binär-Audio, also ein eigener Adapter (kein SSE-Stream). - ByokCapability.speak + TTS-Metadaten auf ByokProviderID (speechModels/voices/defaults); speak nur für OpenAI + Gemini - ByokSpeech.swift: byokSynthesizeSpeech-Dispatcher. OpenAI /v1/audio/speech → fertiges Format; Gemini :generateContent responseModalities AUDIO → rohes 16-bit-PCM (audio/L16) → byokPCMToWav verpackt zu WAV, byokParsePCMRate liest Rate aus MIME - ManaLLM.synthesizeSpeech(text:voice:model:provider:format:) — Facade, greift nur bei BYOK + speak-fähigem Provider (sonst nil → Server/ mana-tts); provider als Wunsch an den Vault-Resolver - 8 Tests (WAV-Header + Samples unangetastet, parsePCMRate, speak- Registry, Facade-Gating inkl. preferred), netz-/keychain-frei. swift build + swift test 47/47 grün Offen: audioguide-native StopEditor-Konsument (Xcode, Tills Hand). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
362 lines
14 KiB
Swift
362 lines
14 KiB
Swift
import Foundation
|
|
import ManaLLMShared
|
|
|
|
/// High-Level-Facade für lokale LLM-Aufrufe in mana-Apps.
|
|
///
|
|
/// Apps konsumieren typischerweise nur diese drei statischen Methoden:
|
|
///
|
|
/// ```swift
|
|
/// import ManaLLM
|
|
///
|
|
/// // Im App-Boot (z.B. @main App init):
|
|
/// ManaLLM.configure()
|
|
///
|
|
/// // Irgendwo später:
|
|
/// let summary = await ManaLLM.summarize(longText)
|
|
/// let tags = await ManaLLM.classify(text, into: ["#sport", "#politik"])
|
|
/// let story = await ManaLLM.generate(
|
|
/// prompt: "Schreib eine kurze Reise-Story über Konstanz.",
|
|
/// level: .creative
|
|
/// )
|
|
/// ```
|
|
///
|
|
/// **Level-Mapping zu Backends:**
|
|
/// - `.fast` → AppleFM erst, dann Gemma E2B
|
|
/// - `.creative` → Gemma E2B erst, dann AppleFM
|
|
/// - `.deep` → Gemma E4B erst, dann Gemma E2B, dann AppleFM
|
|
///
|
|
/// Niemals throw — bei Fehler `nil` (oder leeres Set). Apps rendern
|
|
/// dann eine Fallback-Heuristik.
|
|
public enum ManaLLM {
|
|
/// Zentraler Router mit Default-Backend-Priority. Apps können
|
|
/// das vor dem ersten Call konfigurieren:
|
|
/// ```swift
|
|
/// await ManaLLM.router.setPreferred([.gemmaE2B, .appleFM])
|
|
/// ```
|
|
public static let router = LLMRouter.shared
|
|
|
|
/// Thread-sicherer Halter für den BYOK-Resolver. Solange kein
|
|
/// Resolver gesetzt ist (`configureByok` nicht gerufen), bleibt
|
|
/// `.byok` inert — die Facade routet rein lokal.
|
|
private static let byokBox = ByokFacadeBox()
|
|
|
|
/// Boot-Side-Effects: HF_HUB_CACHE auf den Shared-Container
|
|
/// setzen. Möglichst früh aufrufen (z.B. im `@main`-`init()`).
|
|
@discardableResult
|
|
public static func configure() -> URL? {
|
|
ManaSharedModels.configureHuggingFaceCacheEnv()
|
|
}
|
|
|
|
/// Verdrahtet BYOK für die Facade. **Optional** — ohne diesen Call
|
|
/// bleibt „Eigener Schlüssel (BYOK)" wirkungslos. Danach gilt: wenn
|
|
/// der Nutzer in den Settings BYOK wählt
|
|
/// (`LLMBackendPreferenceStore.current == .byok`) **und** ein
|
|
/// Schlüssel hinterlegt ist, gehen `generate`/`summarize`/`classify`
|
|
/// direkt vom Gerät zum gewählten Dritt-Anbieter — der Key berührt
|
|
/// mana nie. Ist kein Schlüssel da, fällt die Facade still auf die
|
|
/// lokale Backend-Priorität zurück.
|
|
///
|
|
/// Der Default-Vault (`ByokKeyVault()`) teilt die Keychain-Einträge
|
|
/// mit der Settings-UI (`ManaLLMSettingsState`), sodass ein dort
|
|
/// hinterlegter Schlüssel hier direkt greift.
|
|
///
|
|
/// ```swift
|
|
/// // Im App-Boot, direkt nach ManaLLM.configure():
|
|
/// ManaLLM.configureByok()
|
|
/// ```
|
|
public static func configureByok(
|
|
vault: ByokKeyVault = ByokKeyVault(),
|
|
defaultProvider: ByokProviderID? = nil
|
|
) {
|
|
byokBox.set(
|
|
resolver: vault.makeResolver(defaultProvider: defaultProvider),
|
|
defaultProvider: defaultProvider
|
|
)
|
|
}
|
|
|
|
/// Quality-Level für Routing.
|
|
public enum Level: Sendable {
|
|
case fast // AppleFM zuerst — Standard-Tasks
|
|
case creative // Gemma E2B zuerst — Story/Mood/Caption
|
|
case deep // Gemma E4B zuerst — Long-Context/Q&A
|
|
}
|
|
|
|
// MARK: - High-Level Operations
|
|
|
|
/// Freie Generation mit optionalem System-Prompt.
|
|
public static func generate(
|
|
prompt: String,
|
|
instructions: String? = nil,
|
|
level: Level = .fast,
|
|
maxTokens: Int = 500
|
|
) async -> String? {
|
|
// BYOK explizit gewählt + Schlüssel vorhanden → Gerät-direkt zum
|
|
// Anbieter. Bei nil (kein Key) still auf lokale Priorität zurück.
|
|
if let backend = await byokBox.selectedBackend(currentBackend: LLMBackendPreferenceStore.current),
|
|
let text = await backend.generate(
|
|
prompt: prompt,
|
|
instructions: instructions,
|
|
maxTokens: maxTokens
|
|
)
|
|
{
|
|
return text
|
|
}
|
|
let preferred = backendPriority(for: level)
|
|
let router = LLMRouter(preferred: preferred)
|
|
return await router.generate(
|
|
prompt: prompt,
|
|
instructions: instructions,
|
|
maxTokens: maxTokens
|
|
)
|
|
}
|
|
|
|
/// Memoro-Erbe: Headline + Intro für ein langes Transkript.
|
|
public static func summarize(
|
|
_ text: String,
|
|
level: Level = .fast
|
|
) async -> LLMSummary? {
|
|
if let backend = await byokBox.selectedBackend(currentBackend: LLMBackendPreferenceStore.current),
|
|
let summary = await backend.summarize(transcript: text)
|
|
{
|
|
return summary
|
|
}
|
|
let preferred = backendPriority(for: level)
|
|
let router = LLMRouter(preferred: preferred)
|
|
return await router.summarize(transcript: text)
|
|
}
|
|
|
|
/// Klassifikation in vordefinierte Labels. Returnt die Subset-
|
|
/// Labels, die laut LLM passen. Bei Parse-Fehler: leeres Set.
|
|
public static func classify(
|
|
_ text: String,
|
|
into labels: [String],
|
|
level: Level = .fast
|
|
) async -> Set<String> {
|
|
guard !labels.isEmpty else { return [] }
|
|
let labelList = labels.joined(separator: ", ")
|
|
let instructions = """
|
|
Du bist ein Klassifikator. Gegeben ein Text, wähle aus der Label-
|
|
Liste GENAU die Labels, die zum Text passen. Antworte
|
|
ausschließlich mit den passenden Labels, durch Komma getrennt,
|
|
ohne Erklärung, ohne Markdown.
|
|
|
|
Labels: \(labelList)
|
|
"""
|
|
guard let output = await generate(
|
|
prompt: "Text:\n\n\(text)",
|
|
instructions: instructions,
|
|
level: level,
|
|
maxTokens: 100
|
|
) else { return [] }
|
|
let valid = Set(labels)
|
|
let picked = output
|
|
.split(separator: ",")
|
|
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
|
.filter { valid.contains($0) }
|
|
return Set(picked)
|
|
}
|
|
|
|
// MARK: - Vision (Phase A)
|
|
|
|
/// Beschreibt/analysiert ein Bild via **BYOK** — Gerät-direkt zum
|
|
/// gewählten Anbieter (OpenAI / Anthropic / Gemini / Mistral). Der
|
|
/// Schlüssel berührt mana nie; das Bild geht direkt zum Provider.
|
|
///
|
|
/// Greift **nur**, wenn der Nutzer „Eigener Schlüssel (BYOK)" gewählt
|
|
/// hat (`LLMBackendPreferenceStore.current == .byok`), ein Schlüssel
|
|
/// hinterlegt ist und der Provider Vision kann. **Kein lokaler/Server-
|
|
/// Fallback hier** (On-Device-Vision ist noch experimentell) — bei
|
|
/// `nil` rendert die App ihren eigenen Server-/metered-Pfad.
|
|
///
|
|
/// `imageData` geht roh rein (JPEG/PNG, `mimeType` z.B. `"image/jpeg"`);
|
|
/// der Adapter base64-kodiert ins Provider-Schema. Datenschutz: Bilder
|
|
/// gehören zu den sensibleren Inhalten — die App sollte EXIF strippen
|
|
/// und die Kantenlänge cappen (COMPLIANCE.md §5).
|
|
public static func describeImage(
|
|
prompt: String,
|
|
imageData: Data,
|
|
mimeType: String,
|
|
instructions: String? = nil,
|
|
maxTokens: Int = 700
|
|
) async -> String? {
|
|
guard let sel = await byokBox.selectedVision(
|
|
currentBackend: LLMBackendPreferenceStore.current
|
|
) else { return nil }
|
|
|
|
var messages: [ByokMessage] = []
|
|
if let instructions, !instructions.isEmpty {
|
|
messages.append(ByokMessage(role: .system, content: instructions))
|
|
}
|
|
messages.append(ByokMessage(
|
|
role: .user,
|
|
parts: [.text(prompt), .imageData(imageData, mimeType: mimeType)]
|
|
))
|
|
|
|
let provider = ProviderResolution.builtin(sel.provider)
|
|
return try? await provider.call(
|
|
apiKey: sel.apiKey,
|
|
model: sel.model,
|
|
messages: messages,
|
|
temperature: 0.2,
|
|
maxTokens: maxTokens,
|
|
onToken: nil
|
|
)
|
|
}
|
|
|
|
/// Wie `describeImage`, aber nimmt **rohe** Bilddaten (z.B. aus
|
|
/// PhotosPicker / Kamera) und strippt EXIF/GPS + downscalet via
|
|
/// `ByokImagePrep` selbst — so kann der Native-Konsument den
|
|
/// Datenschutz-Schritt nicht vergessen (analog Web
|
|
/// `describeImageStructured`, das den EXIF-Strip ebenfalls kapselt).
|
|
/// `nil`, wenn das Bild nicht dekodierbar ist oder kein BYOK-Vision-
|
|
/// Schlüssel hinterlegt ist.
|
|
public static func describeImage(
|
|
rawImageData: Data,
|
|
prompt: String,
|
|
instructions: String? = nil,
|
|
maxTokens: Int = 700,
|
|
maxEdge: Int = 2048
|
|
) async -> String? {
|
|
guard let prepared = ByokImagePrep.prepareForVision(rawImageData, maxEdge: maxEdge) else {
|
|
return nil
|
|
}
|
|
return await describeImage(
|
|
prompt: prompt,
|
|
imageData: prepared.data,
|
|
mimeType: prepared.mimeType,
|
|
instructions: instructions,
|
|
maxTokens: maxTokens
|
|
)
|
|
}
|
|
|
|
/// Erzeugt **Audio** aus Text via **BYOK** — Gerät-direkt zum gewählten
|
|
/// Anbieter (heute OpenAI + Gemini). Der Schlüssel berührt mana nie.
|
|
///
|
|
/// Greift **nur**, wenn der Nutzer „Eigener Schlüssel (BYOK)" gewählt
|
|
/// hat, ein Schlüssel hinterlegt ist und der Provider TTS (`speak`)
|
|
/// kann. **Kein lokaler/Server-Fallback hier** — bei `nil` rendert die
|
|
/// App ihren eigenen Server-/mana-tts-Pfad.
|
|
///
|
|
/// `provider` ist ein optionaler Wunsch (z.B. aus einem App-Picker), der
|
|
/// an den Vault-Resolver durchgereicht wird; `voice`/`model` fallen auf
|
|
/// die Provider-Defaults zurück. Liefert Roh-Audio-Bytes + MIME (OpenAI
|
|
/// meist mp3, Gemini wav) — die App spielt sie ab oder lädt sie hoch.
|
|
public static func synthesizeSpeech(
|
|
text: String,
|
|
voice: String? = nil,
|
|
model: String? = nil,
|
|
provider: ByokProviderID? = nil,
|
|
format: String = "mp3"
|
|
) async -> ByokSpeechResult? {
|
|
guard let sel = await byokBox.selectedSpeak(
|
|
currentBackend: LLMBackendPreferenceStore.current,
|
|
preferred: provider
|
|
) else { return nil }
|
|
|
|
return try? await byokSynthesizeSpeech(
|
|
sel.provider,
|
|
apiKey: sel.apiKey,
|
|
model: model ?? sel.model,
|
|
text: text,
|
|
voice: voice ?? sel.provider.defaultVoice ?? "",
|
|
format: format
|
|
)
|
|
}
|
|
|
|
// MARK: - Internal
|
|
|
|
private static func backendPriority(for level: Level) -> [LLMBackendID] {
|
|
switch level {
|
|
case .fast:
|
|
return [.appleFM, .gemmaE2B, .gemmaE4B, .noOp]
|
|
case .creative:
|
|
return [.gemmaE2B, .appleFM, .gemmaE4B, .noOp]
|
|
case .deep:
|
|
return [.gemmaE4B, .gemmaE2B, .appleFM, .noOp]
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Thread-sicherer Halter für den BYOK-Resolver der `ManaLLM`-Facade.
|
|
///
|
|
/// `@unchecked Sendable`: der mutable State ist durch `NSLock` geschützt,
|
|
/// der gespeicherte Resolver ist `@Sendable`. Wir nutzen bewusst keinen
|
|
/// Actor — `selectedBackend()` soll synchron-billig sein (ein
|
|
/// Preference-Read + Lock), ohne Actor-Hop für jeden Facade-Call.
|
|
final class ByokFacadeBox: @unchecked Sendable {
|
|
private let lock = NSLock()
|
|
private var resolver: ByokKeyResolver?
|
|
private var defaultProvider: ByokProviderID?
|
|
|
|
func set(resolver: @escaping ByokKeyResolver, defaultProvider: ByokProviderID?) {
|
|
lock.lock()
|
|
defer { lock.unlock() }
|
|
self.resolver = resolver
|
|
self.defaultProvider = defaultProvider
|
|
}
|
|
|
|
/// Synchroner Snapshot — kapselt das Locking, damit `selectedBackend()`
|
|
/// (async) keinen `lock()`-Call im async-Kontext macht (Swift 6 verbietet
|
|
/// das).
|
|
private func snapshot() -> (resolver: ByokKeyResolver, defaultProvider: ByokProviderID?)? {
|
|
lock.lock()
|
|
defer { lock.unlock() }
|
|
guard let resolver else { return nil }
|
|
return (resolver, defaultProvider)
|
|
}
|
|
|
|
/// Liefert ein konfiguriertes `ByokBackend` **nur** wenn (1) der Nutzer
|
|
/// BYOK explizit gewählt hat (`currentBackend == .byok`), (2) ein
|
|
/// Resolver gesetzt ist und (3) ein Schlüssel hinterlegt ist
|
|
/// (`availability == .available`). Sonst `nil` → die Facade routet
|
|
/// lokal weiter.
|
|
///
|
|
/// `currentBackend` wird **übergeben** (statt hier global gelesen),
|
|
/// damit die Box rein + ohne globalen State testbar bleibt.
|
|
func selectedBackend(currentBackend: LLMBackendID) async -> ByokBackend? {
|
|
guard currentBackend == .byok else { return nil }
|
|
guard let snap = snapshot() else { return nil }
|
|
let backend = ByokBackend(resolver: snap.resolver, defaultProvider: snap.defaultProvider)
|
|
guard await backend.availability() == .available else { return nil }
|
|
return backend
|
|
}
|
|
|
|
/// BYOK-Auswahl für einen **Vision**-Task: nur wenn (1) der Nutzer
|
|
/// BYOK gewählt hat, (2) ein Resolver gesetzt ist, (3) ein Schlüssel
|
|
/// aufgelöst wird und (4) der Provider Vision kann. Substituiert das
|
|
/// **Vision-Modell** des Providers (das gespeicherte Chat-Modell kann
|
|
/// ein Nicht-Vision-Modell sein). Sonst `nil`.
|
|
func selectedVision(currentBackend: LLMBackendID) async -> ByokSelection? {
|
|
guard currentBackend == .byok else { return nil }
|
|
guard let snap = snapshot() else { return nil }
|
|
guard let sel = await snap.resolver(snap.defaultProvider) else { return nil }
|
|
guard sel.provider.supportedCapabilities.contains(.vision) else { return nil }
|
|
return ByokSelection(
|
|
provider: sel.provider,
|
|
apiKey: sel.apiKey,
|
|
model: sel.provider.defaultVisionModel
|
|
)
|
|
}
|
|
|
|
/// BYOK-Auswahl für einen **TTS**-Task: nur wenn (1) der Nutzer BYOK
|
|
/// gewählt hat, (2) ein Resolver gesetzt ist, (3) ein Schlüssel
|
|
/// aufgelöst wird und (4) der Provider `speak` kann. Substituiert das
|
|
/// **Speech-Modell** des Providers. `preferred` (z.B. aus einem
|
|
/// App-Picker) wird an den Resolver durchgereicht. Sonst `nil`.
|
|
func selectedSpeak(
|
|
currentBackend: LLMBackendID,
|
|
preferred: ByokProviderID? = nil
|
|
) async -> ByokSelection? {
|
|
guard currentBackend == .byok else { return nil }
|
|
guard let snap = snapshot() else { return nil }
|
|
guard let sel = await snap.resolver(preferred ?? snap.defaultProvider) else { return nil }
|
|
guard sel.provider.supportedCapabilities.contains(.speak),
|
|
let speechModel = sel.provider.defaultSpeechModel
|
|
else { return nil }
|
|
return ByokSelection(
|
|
provider: sel.provider,
|
|
apiKey: sel.apiKey,
|
|
model: speechModel
|
|
)
|
|
}
|
|
}
|