Bug-Fixes: - ArticleDetailView: ScrollView.contentMargins(.bottom, 60) — die iOS-18+-floating Tab-Bar verdeckte sonst die letzten Zeilen - Empty-State-Copy entstaubt (kein „kommt mit π-6" mehr) - Consent-Wall-Warning: ArticleExtractPreview decodet jetzt `warning`, persistiert via ArticleSavedPayload + ArticleState, Reader rendert Banner mit „Original im Browser öffnen"-Link wenn warning == "probable_consent_wall" - AsyncImage-Placeholder von paper-cream auf Color.secondary.opacity(0.15) — sah im Dark-Mode wie helles Quadrat aus - ArticleStatus.reading.label: „Lese" → „Liest" (Imperativ → Verb) Lese-Liste-Umbau (Tills Feedback): - Statt 4-Tab-Picker jetzt ein Single-Scroll mit Sections: Lesend → Ungelesen → Gelesen. Sticky-Header via List/Section. - Archiv ist eigene Seite (ArchivedListView), erreichbar via Toolbar-Icon rechts. Aus dem Haupt-Scroll raus, damit's nicht permanent stört. - Restore-Action im Archiv (Swipe → Wiederherstellen → status=unread) Feed-Polish: - FeedItemRow ist jetzt tappbar — Tap pusht in FeedItemReaderView (rendert FeedItem.content direkt aus Pool ohne Save-Roundtrip). Toolbar-Action „In Lese-Liste speichern" macht Save explizit. - Swipe-Action „In Lese-Liste" als Quick-Save direkt in der Row. ArticleSummarySection: Empty-State jetzt kompakter Pill-Trigger statt großer Card mit leerem Header. Volle Card entsteht erst nach Generieren (oder bei Error/Loading). Toolbar: ellipsis.circle → ellipsis (iOS 18+ rendert Toolbar-Items in Glass-Capsules, .circle ergab doppelten Kreis). iOS-Sim BUILD SUCCEEDED, 38/38 Tests grün. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
127 lines
3.4 KiB
Swift
127 lines
3.4 KiB
Swift
import ManaLLM
|
|
import SwiftUI
|
|
|
|
/// Cross-App-LLM-Pilot (π-AI-1): erzeugt eine kurze Artikel-Summary
|
|
/// via `ManaLLM.summarize(...)` — bevorzugt Foundation Models (iOS 26+
|
|
/// auf Apple-Intelligence-Geräten), fällt sonst auf Gemma E2B zurück
|
|
/// (falls bereits via Memoro im Shared-Container geladen) oder rendert
|
|
/// einen ehrlichen "nicht verfügbar"-State.
|
|
///
|
|
/// Eingehängt in `ArticleDetailView`. Eine eigene Sektion bewusst, weil:
|
|
/// - User soll kontrolliert anstoßen ("Zusammenfassen"), nicht
|
|
/// automatisch — Lokal-LLM-Calls kosten Akku/Compute.
|
|
/// - Result wird nicht in Article persistiert (Server-Wahrheit
|
|
/// bleibt). Re-Open = neuer Call. Caching kommt mit π-AI-2.
|
|
struct ArticleSummarySection: View {
|
|
let article: Article
|
|
|
|
@State private var summary: LLMSummary?
|
|
@State private var generating = false
|
|
@State private var error: String?
|
|
|
|
var body: some View {
|
|
// Empty-State ist kompakter Pill-Trigger — kein leerer Card-
|
|
// Block. Erst nach Generieren entsteht die volle Card.
|
|
if summary == nil, !generating, error == nil {
|
|
compactTrigger
|
|
} else {
|
|
fullCard
|
|
}
|
|
}
|
|
|
|
private var compactTrigger: some View {
|
|
Button {
|
|
Task { await generate() }
|
|
} label: {
|
|
HStack(spacing: 6) {
|
|
Image(systemName: "sparkles")
|
|
Text("Zusammenfassen")
|
|
.font(.subheadline.weight(.medium))
|
|
}
|
|
.padding(.horizontal, 14)
|
|
.padding(.vertical, 8)
|
|
.background(
|
|
Capsule().fill(PagetaTheme.primary.opacity(0.12))
|
|
)
|
|
.foregroundStyle(PagetaTheme.primary)
|
|
.overlay(
|
|
Capsule().stroke(PagetaTheme.primary.opacity(0.35), lineWidth: 1)
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
|
|
private var fullCard: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
header
|
|
content
|
|
}
|
|
.padding(16)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
|
.fill(PagetaTheme.surfaceHover)
|
|
)
|
|
}
|
|
|
|
private var header: some View {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: "sparkles")
|
|
.foregroundStyle(PagetaTheme.primary)
|
|
Text("KI-Zusammenfassung")
|
|
.font(.headline)
|
|
Spacer()
|
|
if !generating, summary != nil {
|
|
Button("Neu") { Task { await generate() } }
|
|
.font(.caption)
|
|
.buttonStyle(.borderless)
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var content: some View {
|
|
if generating {
|
|
HStack(spacing: 10) {
|
|
ProgressView()
|
|
Text("Lokale KI denkt nach …")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
} else if let summary {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text(summary.headline)
|
|
.font(.subheadline.weight(.semibold))
|
|
Text(summary.intro)
|
|
.font(.body)
|
|
.foregroundStyle(.primary)
|
|
}
|
|
} else if let error {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Label(error, systemImage: "exclamationmark.triangle")
|
|
.font(.caption)
|
|
.foregroundStyle(.orange)
|
|
Button("Erneut versuchen") { Task { await generate() } }
|
|
.font(.caption)
|
|
.buttonStyle(.borderless)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func generate() async {
|
|
generating = true
|
|
error = nil
|
|
defer { generating = false }
|
|
let text = article.content
|
|
guard !text.isEmpty else {
|
|
error = "Artikel hat keinen Inhalt."
|
|
return
|
|
}
|
|
// Pageta-Kontext liefert mehr Material als Memoro-Audio —
|
|
// `summarize` per ManaLLM funktioniert beidseitig.
|
|
if let result = await ManaLLM.summarize(text, level: .fast) {
|
|
summary = result
|
|
} else {
|
|
error = "Lokale KI nicht verfügbar oder unzureichend."
|
|
}
|
|
}
|
|
}
|