SPB Git forge

spb/ka-macos

Public
3commits 1branches 0releases
6.1 MBsize
maindefault branch
1 mo agolast push
Swift 98.3% Shell 1.7%
12.2 KB · 256 lines swift
Raw Blame History
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2// Services.swift — réseau : client JSON avec cache disque (mode hors ligne de3// consultation), service d'univers (listes/recherche), stats live, statut des4// 13 plateformes (HEAD + latence) et KA Agent (chat SSE vers api-ka).5// Repris de l'app iOS KA (~/Desktop/KA/KA/Core/Services.swift) + StatusService.6import Foundation78// MARK: - Client HTTP + cache disque910actor APIClient {11    static let shared = APIClient()12    private let session: URLSession13    private let cacheDir: URL1415    init() {16        let cfg = URLSessionConfiguration.default17        cfg.timeoutIntervalForRequest = 1518        cfg.waitsForConnectivity = false19        session = URLSession(configuration: cfg)20        cacheDir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]21            .appendingPathComponent("ka-json", isDirectory: true)22        try? FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true)23    }2425    private func cacheFile(for url: URL) -> URL {26        let name = url.absoluteString.data(using: .utf8)!.base64EncodedString()27            .replacingOccurrences(of: "/", with: "_")28        return cacheDir.appendingPathComponent(String(name.suffix(120)) + ".json")29    }3031    /// JSON brut ; en cas d'échec réseau, sert la dernière copie disque (hors ligne).32    func json(_ url: URL, ttl: TimeInterval = 120) async throws -> JSONValue {33        let file = cacheFile(for: url)34        if let attrs = try? FileManager.default.attributesOfItem(atPath: file.path),35           let date = attrs[.modificationDate] as? Date, Date().timeIntervalSince(date) < ttl,36           let data = try? Data(contentsOf: file),37           let cached = try? JSONDecoder().decode(JSONValue.self, from: data) {38            return cached39        }40        do {41            var req = URLRequest(url: url)42            req.setValue("KA-macOS/1.0 (+https://www.groupe-ka.com)", forHTTPHeaderField: "User-Agent")43            let (data, resp) = try await session.data(for: req)44            guard let http = resp as? HTTPURLResponse, http.statusCode == 200 else {45                throw URLError(.badServerResponse)46            }47            let value = try JSONDecoder().decode(JSONValue.self, from: data)48            try? data.write(to: file)49            return value50        } catch {51            if let data = try? Data(contentsOf: file),52               let cached = try? JSONDecoder().decode(JSONValue.self, from: data) {53                return cached // hors ligne : dernier contenu connu54            }55            throw error56        }57    }58}5960// MARK: - Univers : listes, recherche, stats6162enum UniverseService {63    static func listURL(_ u: Universe, query: String?, city: String? = nil, limit: Int = 30, params: [String: String] = [:]) -> URL? {64        guard let path = u.listPath else { return nil }65        var comps = URLComponents(url: u.baseURL.appendingPathComponent(""), resolvingAgainstBaseURL: false)!66        // listPath peut contenir déjà une query (ex. events?upcoming=true)67        let split = path.split(separator: "?", maxSplits: 1)68        comps.path = String(split[0])69        var items: [URLQueryItem] = split.count > 170            ? split[1].split(separator: "&").map {71                let kv = $0.split(separator: "=", maxSplits: 1)72                return URLQueryItem(name: String(kv[0]), value: kv.count > 1 ? String(kv[1]) : nil)73            } : []74        if let q = query, !q.isEmpty { items.append(.init(name: u.searchParam, value: q)) }75        if let c = city, !c.isEmpty { items.append(.init(name: "city", value: c)) }76        for (k, v) in params.sorted(by: { $0.key < $1.key }) where !v.isEmpty {77            items.append(.init(name: k, value: v))78        }79        items.append(.init(name: "limit", value: String(limit)))80        comps.queryItems = items81        return comps.url82    }8384    static func fetch(_ u: Universe, query: String? = nil, city: String? = nil, limit: Int = 30, params: [String: String] = [:]) async throws -> [KAItem] {85        guard let url = listURL(u, query: query, city: city, limit: limit, params: params), let map = u.map else { return [] }86        let root = try await APIClient.shared.json(url)87        let obj = root.object ?? [:]88        let raw = obj[u.itemsKey]?.array89            ?? obj["items"]?.array ?? obj["results"]?.array ?? obj["hits"]?.array90            ?? root.array ?? []91        return raw.compactMap { $0.object.flatMap(map) }92    }9394    /// Total d'une requête (champ `total` de la réponse liste) — pour les alertes.95    static func total(_ u: Universe, query: String? = nil, params: [String: String] = [:]) async -> Int? {96        guard let url = listURL(u, query: query, limit: 1, params: params),97              let root = try? await APIClient.shared.json(url, ttl: 60) else { return nil }98        return root.object?.num("total", "count").map(Int.init)99    }100101    /// Items GÉOLOCALISÉS pour une région de carte.102    /// lou-ka / immo-ka / job-ka : endpoint geojson avec bbox RÉEL (vérifié).103    /// Autres univers : liste standard filtrée client sur la région.104    static func mapItems(_ u: Universe, west: Double, south: Double, east: Double, north: Double,105                         limit: Int = 150) async -> [KAItem] {106        let bboxCapable = ["lou-ka": "/api/listings.geojson",107                           "immo-ka": "/api/listings.geojson",108                           "job-ka": "/api/jobs.geojson"]109        if let path = bboxCapable[u.id], let map = u.map {110            var comps = URLComponents(string: "https://\(u.domain)\(path)")!111            comps.queryItems = [112                .init(name: "bbox", value: "\(west),\(south),\(east),\(north)"),113                .init(name: "limit", value: String(limit)),114            ]115            guard let url = comps.url,116                  let root = try? await APIClient.shared.json(url, ttl: 90),117                  let features = root.object?["features"]?.array else { return [] }118            return features.compactMap { f -> KAItem? in119                guard let fo = f.object,120                      var props = fo["properties"]?.object,121                      let coords = fo["geometry"]?.object?["coordinates"]?.array,122                      coords.count >= 2, let lon = coords[0].number, let lat = coords[1].number123                else { return nil }124                props["lat"] = .number(lat)125                props["lng"] = .number(lon)126                return map(props)127            }128        }129        // repli : liste + filtre client130        let items = (try? await fetch(u, limit: limit)) ?? []131        return items.filter { it in132            guard let la = it.latitude, let lo = it.longitude else { return false }133            return la >= south && la <= north && lo >= west && lo <= east134        }135    }136137    /// Total « en direct » depuis /api/stats (clé selon l'univers, chemins a.b acceptés)138    static func liveTotal(_ u: Universe) async -> Int? {139        let path = u.id == "trouve-ka" ? "/api/status" : "/api/stats"140        guard let url = URL(string: "https://\(u.domain)\(path)"),141              let root = try? await APIClient.shared.json(url, ttl: 300) else { return nil }142        for key in u.statTotalKeys {143            var cur: JSONValue? = root144            for part in key.split(separator: ".") {145                cur = cur?.object?[String(part)]146            }147            if let n = cur?.number { return Int(n) }148        }149        return nil150    }151}152153// MARK: - Statut des 13 plateformes (HEAD sur https://www.<domaine>/)154155struct ServiceHealth: Identifiable, Equatable {156    let id: String          // domaine157    let name: String        // « Lou-KA », « Groupe-KA »…158    var up: Bool?           // nil = vérification en cours / inconnue159    var latencyMs: Int?160    var checkedAt: Date?161}162163enum StatusService {164    /// Les 13 services : le hub Groupe-KA + les 12 univers.165    static var allServices: [ServiceHealth] {166        [ServiceHealth(id: Ecosystem.hubDomain, name: "Groupe-KA", up: nil, latencyMs: nil, checkedAt: nil)]167        + Ecosystem.all.map { ServiceHealth(id: $0.domain, name: $0.name, up: nil, latencyMs: nil, checkedAt: nil) }168    }169170    /// HEAD sur la racine ; vert si 200–399 (ou 405 : le serveur répond mais171    /// refuse HEAD — les apps Next de l'écosystème), latence mesurée.172    static func check(domain: String) async -> (up: Bool, latencyMs: Int) {173        guard let url = URL(string: "https://\(domain)/") else { return (false, 0) }174        var req = URLRequest(url: url)175        req.httpMethod = "HEAD"176        req.timeoutInterval = 10177        req.setValue("KA-macOS/1.0 (+https://www.groupe-ka.com)", forHTTPHeaderField: "User-Agent")178        let start = Date()179        do {180            let (_, resp) = try await URLSession.shared.data(for: req)181            let ms = Int(Date().timeIntervalSince(start) * 1000)182            guard let http = resp as? HTTPURLResponse else { return (false, ms) }183            return ((200..<400).contains(http.statusCode) || http.statusCode == 405, ms)184        } catch {185            return (false, Int(Date().timeIntervalSince(start) * 1000))186        }187    }188}189190// MARK: - Menu de resto (Resto·Ka, /api/restaurants/{uid})191192struct RestoMenuSection: Identifiable {193    let id = UUID()194    let name: String195    let items: [(name: String, price: String?)]196}197198enum RestoMenuLoader {199    static func load(uid: String) async -> [RestoMenuSection] {200        guard let url = URL(string: "https://www.resto-ka.com/api/restaurants/\(uid)"),201              let root = try? await APIClient.shared.json(url, ttl: 600),202              let menus = root.object?["menus"]?.array else { return [] }203        var sections: [RestoMenuSection] = []204        for menu in menus.prefix(1) {205            for s in menu.object?["sections"]?.array ?? [] {206                guard let so = s.object, let name = so.str("name") else { continue }207                let items: [(String, String?)] = (so["items"]?.array ?? []).prefix(30).compactMap { i in208                    guard let io = i.object, let n = io.str("name") else { return nil }209                    return (n, io.num("price").flatMap { $0 > 0 ? $0.money2 : nil })210                }211                if !items.isEmpty { sections.append(RestoMenuSection(name: name, items: items)) }212            }213        }214        return sections215    }216}217218// MARK: - KA Agent (SSE)219220struct AgentEvent { let kind: Kind; enum Kind { case delta(String), tool(String), done, error(String) } }221222enum AgentService {223    static let endpoint = URL(string: "https://www.api-ka.com/api/agent/chat")!224225    static func stream(site: String, messages: [[String: String]]) -> AsyncThrowingStream<AgentEvent, Error> {226        AsyncThrowingStream { continuation in227            let task = Task {228                var req = URLRequest(url: endpoint)229                req.httpMethod = "POST"230                req.setValue("application/json", forHTTPHeaderField: "Content-Type")231                req.timeoutInterval = 90232                req.httpBody = try JSONSerialization.data(withJSONObject: ["site": site, "messages": messages])233                let (bytes, resp) = try await URLSession.shared.bytes(for: req)234                guard (resp as? HTTPURLResponse)?.statusCode == 200 else { throw URLError(.badServerResponse) }235                var event = ""236                for try await line in bytes.lines {237                    if line.hasPrefix("event: ") { event = String(line.dropFirst(7)) }238                    else if line.hasPrefix("data: ") {239                        let data = Data(line.dropFirst(6).utf8)240                        let obj = (try? JSONSerialization.jsonObject(with: data) as? [String: Any]) ?? [:]241                        switch event {242                        case "delta": if let t = obj["text"] as? String { continuation.yield(.init(kind: .delta(t))) }243                        case "tool": continuation.yield(.init(kind: .tool(obj["name"] as? String ?? "recherche")))244                        case "done": continuation.yield(.init(kind: .done)); continuation.finish(); return245                        case "error": continuation.yield(.init(kind: .error(obj["message"] as? String ?? "erreur")))246                        default: break247                        }248                    }249                }250                continuation.finish()251            }252            continuation.onTermination = { _ in task.cancel() }253        }254    }255}256