mana-swift-ui/Sources/ManaLLMUI/ManaAiActionMetaView.swift
Till JS 9abba74db1 feat: ManaAiActionMetaView — geteilte KI-Verbrauch-Zeile (ManaLLMUI)
Modell · Dienst · Dauer · Tokens · Mana · ≈ Strom (System-Styling). Pendant zur
Web-Komponente @mana/shared-ui <AiActionMeta>. Energie als Schätzung markiert.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 19:03:42 +02:00

88 lines
2.9 KiB
Swift

import ManaLLMShared
import SwiftUI
/// Eine kompakte, gedämpfte Zeile, die zeigt, was ein KI-Aufruf gekostet
/// hat: Modell · Dienst · Dauer · Tokens · Mana · ≈ Strom. Geteilte,
/// app-agnostische Anzeige (System-Styling) — Pendant zur Web-Komponente
/// `<AiActionMeta>` in `@mana/shared-ui`. Die Energie ist bewusst als
/// Schätzung markiert (≈). SOT: `mana/docs/AI_TRANSPARENCY.md`.
public struct ManaAiActionMetaView: View {
private let meta: ManaAiMeta?
public init(meta: ManaAiMeta?) {
self.meta = meta
}
public var body: some View {
if let meta {
Text(Self.line(meta))
.font(.caption2)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
.accessibilityLabel("KI-Verbrauch: \(Self.line(meta))")
}
}
/// Baut die einzeilige Verbrauchs-Zusammenfassung.
static func line(_ m: ManaAiMeta) -> String {
var parts: [String] = [modelName(m.modelResolved), service(m)]
if let ms = m.durationMs {
parts.append("\(decimal(ms / 1000, digits: 1)) s")
}
let tokens = (m.tokensIn ?? 0) + (m.tokensOut ?? 0)
if tokens > 0 {
parts.append("\(tokens) Tokens")
}
if let credits = m.creditsCharged {
parts.append("\(credits) Mana")
}
// Strom erscheint IMMER, damit „0 Mana" nie allein als einzige
// Kosten-Aussage steht (Compliance-Invariante, AI_TRANSPARENCY.md).
parts.append(energyText(m))
return parts.joined(separator: " · ")
}
/// Folgenkosten-Aussage zum Strom. `(geschätzt)` ist sichtbar (nicht nur
/// im Accessibility-Label), und es wird mit signifikanten Stellen
/// gerundet — eine grobe Schätzung darf keine Hundertstel vortäuschen.
static func energyText(_ m: ManaAiMeta) -> String {
if let wh = m.energyWhEst {
return "≈ \(formatWh(wh)) Wh (geschätzt)"
}
if m.energyBasis == "on-device" || m.source == "local" || m.source == "byok" {
return "Strom von deinem Gerät"
}
return "Strom nicht beziffert"
}
/// Wattstunden mit signifikanten Stellen: ≥ 1 Wh ganzzahlig, < 1 Wh
/// eine Nachkommastelle.
static func formatWh(_ wh: Double) -> String {
decimal(wh, digits: wh >= 1 ? 0 : 1)
}
/// `ollama/pixtral:12b` → `pixtral:12b`.
static func modelName(_ model: String) -> String {
guard let slash = model.firstIndex(of: "/") else { return model }
return String(model[model.index(after: slash)...])
}
/// Ehrliches Dienst-Label: self-hosted vs. welcher Anbieter, plus Tier.
static func service(_ m: ManaAiMeta) -> String {
let provider: String
switch m.provider {
case "ollama": provider = "eigene Hardware"
case "unknown", "": provider = "Server"
default: provider = m.provider
}
return m.source == "metered" ? "\(provider) · bezahlt" : provider
}
static func decimal(_ value: Double, digits: Int) -> String {
let f = NumberFormatter()
f.locale = Locale(identifier: "de_DE")
f.minimumFractionDigits = 0
f.maximumFractionDigits = digits
return f.string(from: NSNumber(value: value)) ?? String(value)
}
}