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>
275 lines
7.5 KiB
Swift
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)
|
|
}
|
|
}
|