SPB Git

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%
5.0 KB · 160 lines swift
Raw Blame History
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