Swift 98.3%
Shell 1.7%
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