Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -131,4 +131,10 @@ iOSInjectionProject/
/*.gcno
**/xcshareddata/WorkspaceSettings.xcsettings

# End of https://www.toptal.com/developers/gitignore/api/xcode,swift,macos
# End of https://www.toptal.com/developers/gitignore/api/xcode,swift,macos

.env
opencode*
.kilocode
.swiftzilla
tmp
20 changes: 20 additions & 0 deletions TabNews/Models/ContentSummary.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
//
// ContentSummary.swift
// TabNews
//
// Created by Assistant on 09/12/25.
//

import Foundation

struct ContentSummary: Codable, Identifiable {
let id: String // Same as ContentResponse id
let summary: String
let createdAt: Date

init(id: String, summary: String) {
self.id = id
self.summary = summary
self.createdAt = Date()
}
}
103 changes: 103 additions & 0 deletions TabNews/Services/SummarizationService.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
//
// SummarizationService.swift
// TabNews
//
// Created by Assistant on 09/12/25.
//

import Foundation
import FoundationModels

@MainActor
class SummarizationService {
static let shared = SummarizationService()

private init() {}

/// Check if FoundationModels is available on the current device
func isAvailable() -> Bool {
// Check if the system language model is available
let model = SystemLanguageModel.default

switch model.availability {
case .available:
return true
case .unavailable:
return false
}
}

/// Get the availability status with detailed reason
func getAvailabilityStatus() -> SystemLanguageModel.Availability {
return SystemLanguageModel.default.availability
}

/// Summarize content from a ContentResponse (includes title, owner, date, body, and source)
func summarize(content: ContentResponse) async throws -> String {
guard isAvailable() else {
throw NSError(
domain: "SummarizationService",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "FoundationModels not available on this device"]
)
}

// Create a session with instructions for summarization
let instructions = """
Você é um assistente que resume conteúdos de forma concisa e objetiva.
Responda sempre em português.
Use apenas texto simples, SEM markdown, sem negrito, sem itálico, sem formatação.
Gere APENAS UM PARÁGRAFO único, sem quebras de linha ou espaços extras.
"""

let session = LanguageModelSession(instructions: instructions)

// Build comprehensive content string with all information that appears in PostDetailView
var contentToSummarize = """
Título: \(content.title)
Autor: \(content.ownerUsername)
"""

// Add publication date if available
if !content.publishedAt.isEmpty {
contentToSummarize += "\nPublicado: \(content.publishedAt)"
}

// Add body content
if let body = content.body, !body.isEmpty {
contentToSummarize += "\n\nConteúdo:\n\(body)"
}

// Add source URL if available
if let sourceURL = content.sourceURL, !sourceURL.isEmpty {
contentToSummarize += "\n\nFonte: \(sourceURL)"
}

// Add instructions for the model
contentToSummarize += """


Forneça um resumo conciso em texto simples (sem formatação) como um único parágrafo.
Não use quebras de linha. Resuma os pontos principais de forma objetiva.
"""

// Generate the summary
let prompt = Prompt(contentToSummarize)
let response = try await session.respond(to: prompt)

// Clean the response to ensure single paragraph
return cleanSummary(response.content)
}

/// Clean summary to ensure it's a single paragraph without newlines
private func cleanSummary(_ text: String) -> String {
// Replace newlines with spaces
let cleaned = text
.replacingOccurrences(of: "\n", with: " ")
.replacingOccurrences(of: "\r", with: " ")
.replacingOccurrences(of: "\t", with: " ")

// Remove multiple spaces
return cleaned.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression).trimmingCharacters(in: .whitespaces)
}
}
62 changes: 62 additions & 0 deletions TabNews/ViewModels/HomeViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,14 @@ final class HomeViewModel: ObservableObject {
@Published private(set) var isLoadingNextPage = false
@Published private(set) var isRefreshing = false

// Summary-related state
@Published var summaries: [String: ContentSummary] = [:]
@Published var summarizingIds: Set<String> = []
@Published var summaryErrors: [String: String] = [:]
@Published var isFoundationModelsAvailable: Bool = false

private let contentService = ContentService.shared
private let summarizationService = SummarizationService.shared
private let isoFormatter = ISO8601DateFormatter()
private let relativeFormatter: RelativeDateTimeFormatter = {
let formatter = RelativeDateTimeFormatter()
Expand All @@ -30,6 +37,10 @@ final class HomeViewModel: ObservableObject {
private let perPage = 20
private let strategy = "relevant"

init() {
checkFoundationModelsAvailability()
}

func loadContents(reset: Bool = false) async {
if isLoading || isLoadingNextPage { return }

Expand Down Expand Up @@ -77,6 +88,57 @@ final class HomeViewModel: ObservableObject {

return relativeFormatter.localizedString(for: date, relativeTo: Date())
}

// MARK: - Summary Methods

func checkFoundationModelsAvailability() {
isFoundationModelsAvailable = summarizationService.isAvailable()
}

func summarizeContent(_ content: ContentResponse) async {
// Check if already summarizing
if summarizingIds.contains(content.id) {
return
}

// Mark as summarizing
summarizingIds.insert(content.id)
summaryErrors.removeValue(forKey: content.id)

do {
// Summarize all content (title, owner, date, body, source URL)
let summaryText = try await summarizationService.summarize(content: content)

// Create and store summary
let summary = ContentSummary(id: content.id, summary: summaryText)
summaries[content.id] = summary

} catch {
// Store error message
summaryErrors[content.id] = error.localizedDescription
}

// Remove from summarizing set
summarizingIds.remove(content.id)
}

func getSummary(for contentId: String) -> ContentSummary? {
return summaries[contentId]
}

func isSummarizing(_ contentId: String) -> Bool {
return summarizingIds.contains(contentId)
}

func getSummaryError(for contentId: String) -> String? {
return summaryErrors[contentId]
}

func clearSummary(for contentId: String) {
summaries.removeValue(forKey: contentId)
summaryErrors.removeValue(forKey: contentId)
summarizingIds.remove(contentId)
}
}

private extension HomeViewModel {
Expand Down
Loading