spb/prisme Public MIT
Navigateur iOS intelligent — chaque page comprise localement avant d'être affichée. SwiftUI · WebKit · Foundation Models, 100% on-device.
Swift 96.7%
JavaScript 3.3%
1//2// ReaderModel.swift3// Prisme4//5// Author: Simon-Pierre Boucher <contact@spboucher.ai>6//78import Foundation9import Observation1011/// Detail level of the semantic zoom (CLAUDE.md §5, the signature feature).12/// Pinching moves along this scale; spreading past `.full` returns to the13/// raw page — one gesture always brings the real page back (§7).14enum ReaderLevel: Int, CaseIterable, Comparable {15 case full = 016 case condensed17 case outline18 case gist1920 var label: String {21 switch self {22 case .full: "Texte"23 case .condensed: "Sections"24 case .outline: "Plan"25 case .gist: "Essentiel"26 }27 }2829 static func < (lhs: Self, rhs: Self) -> Bool { lhs.rawValue < rhs.rawValue }30}3132/// A run of blocks under one heading.33struct ReaderSection: Identifiable {34 let id: Int35 let title: String36 /// Heading depth (1–6); 0 for the leading section before any heading.37 let level: Int38 let blockRange: Range<Int>39 /// Deterministic summary (first sentence of the first paragraph) —40 /// tier `none`, used whenever the model has nothing better.41 let fallbackSummary: String42}4344/// State behind the reader. Extraction is deterministic and instant (no AI);45/// the digest enriches summaries and the gist when the local model delivers.46@MainActor47@Observable48final class ReaderModel {49 let tab: Tab50 let intelligence: IntelligenceCenter5152 private(set) var title: String = ""53 private(set) var blocks: [ContentBlock] = []54 private(set) var sections: [ReaderSection] = []55 private(set) var extractionFailed = false5657 var level: ReaderLevel = .full5859 init(tab: Tab, intelligence: IntelligenceCenter) {60 self.tab = tab61 self.intelligence = intelligence62 }6364 var digest: PageDigest? {65 if case .ready(let digest, _) = intelligence.digestState(for: tab) {66 return digest67 }68 return nil69 }7071 /// Blocks the digest was computed on (budgeted subset).72 private var digestBlocks: [ContentBlock]? {73 if case .ready(_, let kept) = intelligence.digestState(for: tab) {74 return kept75 }76 return nil77 }7879 func load() async {80 do {81 let content = try await tab.page.extractContent()82 title = content.title83 blocks = content.blocks84 sections = Self.groupSections(content.blocks, pageTitle: content.title)85 extractionFailed = content.blocks.isEmpty86 // Enrichment is optional and arrives when it arrives.87 intelligence.requestDigest(for: tab)88 } catch {89 extractionFailed = true90 }91 }9293 /// Summary for a section: the model's if it maps to this section94 /// (matched through DOM paths, never guessed), else the deterministic95 /// fallback. `generated` drives the distinct visual treatment (§7).96 func summary(for section: ReaderSection) -> (text: String, generated: Bool) {97 if let digest, let kept = digestBlocks {98 for digestSection in digest.outline {99 guard kept.indices.contains(digestSection.sourceBlock) else { continue }100 let path = kept[digestSection.sourceBlock].domPath101 if let index = blocks.firstIndex(where: { $0.domPath == path }),102 section.blockRange.contains(index) {103 return (digestSection.summary, true)104 }105 }106 }107 return (section.fallbackSummary, false)108 }109110 var gist: (text: String, generated: Bool) {111 if let digest {112 return (digest.gist, true)113 }114 if let first = sections.first, !first.fallbackSummary.isEmpty {115 return (first.fallbackSummary, false)116 }117 return (title, false)118 }119120 // MARK: - Grouping121122 private static func groupSections(_ blocks: [ContentBlock], pageTitle: String) -> [ReaderSection] {123 var sections: [ReaderSection] = []124 var start = 0125 var currentTitle = pageTitle126 var currentLevel = 0127128 func close(at end: Int) {129 guard end > start else { return }130 let range = start..<end131 sections.append(ReaderSection(132 id: start,133 title: currentTitle,134 level: currentLevel,135 blockRange: range,136 fallbackSummary: firstSentence(in: blocks[range])137 ))138 }139140 for (index, block) in blocks.enumerated() where block.kind == .heading {141 close(at: index)142 currentTitle = block.text143 currentLevel = block.level144 start = index145 }146 close(at: blocks.count)147 return sections148 }149150 private static func firstSentence(in slice: ArraySlice<ContentBlock>) -> String {151 guard let paragraph = slice.first(where: { $0.kind == .paragraph })?.text else {152 return ""153 }154 if let end = paragraph.firstIndex(where: { ".!?".contains($0) }) {155 return String(paragraph[...end])156 }157 return paragraph158 }159}160