pageta-native/Sources/Features/SummarizeArticlesView.swift
Till JS 8d6fc1a666 i18n: pageta-native voll über PagetaStrings (Phase 4)
Alle UI-Strings der Feature-/App-Schicht ziehen aus mana/i18n
(Listen/Detail/Marker/Feed/Rückblick/Zusammenfassen/Settings/Account
+ Archiv/Bulk/Note-Sheet/Palette); Interpolation als generierte Funktionen.
Verwaisten-Check grün (0 hartkodierte UI-Strings). Onboarding bleibt
seine eigene SOT (mana/content/onboarding).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 16:06:55 +02:00

275 lines
7.5 KiB
Swift

import ManaCore
import ManaLLM
import SwiftUI
/// Gezielte KI-Zusammenfassung über mehrere Artikel — Pendant zur Web-
/// `SummarizeModal`. Aufgerufen aus der Lese-Liste mit der Auswahl ODER
/// allen Artikeln. Zwei Modi:
///
/// - **Synthese**: ein verbundener Text über alle übergebenen Artikel
/// (`Digest.buildArticlesSynthesisPrompt` + `ManaLLM.generate`).
/// - **Einzeln**: eine kurze Zusammenfassung je Artikel
/// (`ManaLLM.summarize` pro Artikel, sequenziell).
///
/// Lokal-first wie der Rückblick: läuft auf dem Gerät. Liefert das lokale
/// Modell nichts, bleibt ein ehrlicher Hinweis — kein stiller Cloud-Fallback
/// (Native hat keinen Server-Summarize-Pfad; Wahrheit bleibt lokal).
struct SummarizeArticlesView: View {
let articles: [Article]
let label: String
@Environment(\.dismiss) private var dismiss
enum Mode: String, CaseIterable, Identifiable {
case synthese
case einzeln
var id: String { rawValue }
var title: String {
self == .synthese ? PagetaStrings.summarizeModeSynthesis : PagetaStrings.summarizeModeIndividual
}
}
@State private var mode: Mode = .synthese
// Synthese
@State private var synthRunning = false
@State private var synthResult: String?
@State private var synthError: String?
@State private var synthInfo: String?
// Einzeln
struct EinzelLine: Identifiable {
let id: String
let title: String
let source: String?
var summary: LLMSummary?
var error: String?
}
@State private var einzelRunning = false
@State private var einzelDone = 0
@State private var einzelLines: [EinzelLine] = []
private func briefText(_ a: Article) -> String {
if let ex = a.excerpt?.trimmingCharacters(in: .whitespacesAndNewlines), !ex.isEmpty {
return ex
}
return String(a.content.prefix(280))
}
private var briefs: [DigestBrief] {
articles.map { a in
DigestBrief(
id: a.id,
title: a.displayTitle,
source: a.siteName,
url: a.id,
brief: briefText(a),
tags: a.tags
)
}
}
var body: some View {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
Picker("Modus", selection: $mode) {
ForEach(Mode.allCases) { m in
Text(m.title).tag(m)
}
}
.pickerStyle(.segmented)
Text(modeHint)
.font(.caption)
.foregroundStyle(.secondary)
if mode == .synthese {
syntheseContent
} else {
einzelnContent
}
}
.padding(.horizontal, 20)
.padding(.vertical, 16)
.macReadingWidth()
}
.navigationTitle(PagetaStrings.summarizeTitle)
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.paperBackground()
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button(PagetaStrings.actionDone) { dismiss() }
}
ToolbarItem(placement: .primaryAction) {
ShareLink(item: buildMarkdown()) {
Image(systemName: "square.and.arrow.up")
}
}
}
.task {
if synthResult == nil, !synthRunning { await runSynthese() }
}
.onChange(of: mode) { _, next in
if next == .synthese, synthResult == nil, !synthRunning {
Task { await runSynthese() }
} else if next == .einzeln, einzelLines.isEmpty, !einzelRunning {
Task { await runEinzel() }
}
}
}
}
private var modeHint: String {
mode == .synthese
? PagetaStrings.summarizeSynthesisDesc(articles.count)
: PagetaStrings.summarizeIndividualDesc(articles.count)
}
// MARK: - Synthese
@ViewBuilder
private var syntheseContent: some View {
if synthRunning {
progressLine(PagetaStrings.summarizeGeneratingHint)
} else if let synthError {
Text(synthError)
.font(.callout)
.foregroundStyle(.orange)
.fixedSize(horizontal: false, vertical: true)
} else if let synthResult {
Text(synthResult)
.font(.callout)
.foregroundStyle(PagetaTheme.foreground)
.fixedSize(horizontal: false, vertical: true)
if let synthInfo {
Text(synthInfo).font(.caption2).foregroundStyle(.secondary)
}
}
Button {
Task { await runSynthese() }
} label: {
Label(PagetaStrings.summarizeRegenerate, systemImage: "arrow.clockwise")
.font(.callout.weight(.medium))
}
.buttonStyle(.bordered)
.disabled(synthRunning)
.padding(.top, 4)
}
private func runSynthese() async {
guard !briefs.isEmpty, !synthRunning else { return }
synthRunning = true
synthResult = nil
synthError = nil
synthInfo = nil
let p = Digest.buildArticlesSynthesisPrompt(briefs: briefs)
let result = await ManaLLM.generate(
prompt: p.prompt,
instructions: p.instructions,
level: .creative,
maxTokens: 700
)
let clean = result?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if clean.count >= 20 {
synthResult = clean
if p.usedArticles < briefs.count {
synthInfo = PagetaStrings.summarizeTruncated(p.usedArticles, briefs.count)
}
} else {
synthError =
PagetaStrings.summarizeAiNoSynthesis
}
synthRunning = false
}
// MARK: - Einzeln
@ViewBuilder
private var einzelnContent: some View {
if einzelLines.isEmpty {
progressLine(PagetaStrings.summarizeNothingYet)
} else {
if einzelRunning {
Text("\(einzelDone) / \(einzelLines.count) …")
.font(.caption)
.foregroundStyle(.secondary)
}
ForEach(einzelLines) { line in
VStack(alignment: .leading, spacing: 4) {
Text(line.title).font(.subheadline.weight(.medium))
if let source = line.source {
Text(source).font(.caption).foregroundStyle(.secondary)
}
if let summary = line.summary {
Text(summary.headline)
.font(.callout.weight(.semibold))
.foregroundStyle(PagetaTheme.foreground)
Text(summary.intro)
.font(.callout)
.foregroundStyle(PagetaTheme.foreground)
.fixedSize(horizontal: false, vertical: true)
} else if let error = line.error {
Text(error).font(.caption).foregroundStyle(.orange)
} else {
Text("…").font(.caption).foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
private func runEinzel() async {
guard !einzelRunning else { return }
einzelRunning = true
einzelDone = 0
einzelLines = articles.map { a in
EinzelLine(id: a.id, title: a.displayTitle, source: a.siteName, summary: nil, error: nil)
}
for (index, a) in articles.enumerated() {
let text = a.content
if text.trimmingCharacters(in: .whitespacesAndNewlines).count < 50 {
einzelLines[index].error = PagetaStrings.summarizeTooLittleContent
} else if let result = await ManaLLM.summarize(text, level: .fast) {
einzelLines[index].summary = result
} else {
einzelLines[index].error = PagetaStrings.summarizeAiUnavailable
}
einzelDone = index + 1
}
einzelRunning = false
}
// MARK: - Hilfen
private func progressLine(_ text: String) -> some View {
HStack(spacing: 10) {
if synthRunning || einzelRunning { ProgressView() }
Text(text).font(.callout).foregroundStyle(.secondary)
}
}
private func buildMarkdown() -> String {
var lines: [String] = ["# Zusammenfassung — \(label)", ""]
if mode == .synthese {
if let synthResult { lines.append(synthResult); lines.append("") }
} else {
for line in einzelLines {
let src = line.source.map { " — \($0)" } ?? ""
lines.append("## \(line.title)\(src)")
if let summary = line.summary {
lines.append(summary.headline)
lines.append(summary.intro)
} else if let error = line.error {
lines.append("_(\(error))_")
}
lines.append("")
}
}
return lines.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines)
}
}