import Foundation struct PodcastStatus: Decodable { let status: String let progress: Int let title: String? let error: String? var isDone: Bool { status == "done" } var isFailed: Bool { status == "error" } } struct ClaudeService: Sendable { // MARK: - Bookmark AI func enrich(bookmark: Bookmark) async throws -> (summary: String, tags: [String]) { struct Response: Decodable { let summary: String; let tags: [String] } let body: [String: Any] = [ "url": bookmark.url, "title": bookmark.displayTitle, "tags": bookmark.tagNames, ] let data = try await post("/v1/marks/enrich", body: body) let decoded = try JSONDecoder().decode(Response.self, from: data) return (decoded.summary, decoded.tags) } func semanticSearch(query: String, in bookmarks: [Bookmark]) async throws -> [Bookmark] { guard !bookmarks.isEmpty else { return [] } struct Response: Decodable { let indices: [Int] } let list: [[String: Any]] = bookmarks.map { b in var d: [String: Any] = ["id": b.id, "title": b.displayTitle, "domain": b.domain] if let s = b.aiSummary { d["summary"] = s } return d } let data = try await post("/v1/marks/search", body: ["query": query, "bookmarks": list]) let decoded = try JSONDecoder().decode(Response.self, from: data) return decoded.indices.compactMap { i in i < bookmarks.count ? bookmarks[i] : nil } } func generateCollections(from bookmarks: [Bookmark]) async throws -> [SmartCollection] { guard !bookmarks.isEmpty else { return [] } struct Item: Decodable { let name: String; let description: String; let bookmarkIds: [Int] } struct Response: Decodable { let collections: [Item] } let capped = Array(bookmarks.prefix(150)) let list: [[String: Any]] = capped.map { b in ["id": b.id, "title": b.displayTitle, "tags": b.tagNames] } let data = try await post("/v1/marks/collections", body: ["bookmarks": list]) let decoded = try JSONDecoder().decode(Response.self, from: data) return decoded.collections.map { SmartCollection(name: $0.name, description: $0.description, bookmarkIds: $0.bookmarkIds) } } // MARK: - Podcast func generatePodcast(url: String) async throws -> String { struct Response: Decodable { let job_id: String } let data = try await post("/v1/podcast/generate", body: ["url": url]) return try JSONDecoder().decode(Response.self, from: data).job_id } func podcastStatus(jobId: String) async throws -> PodcastStatus { let data = try await get("/v1/podcast/status/\(jobId)") return try JSONDecoder().decode(PodcastStatus.self, from: data) } func downloadPodcastAudio(jobId: String, articleUrl: String, title: String? = nil, parentBookmarkUrl: String? = nil) async throws -> URL { let dest = ClaudeService.cachedPodcastURL(for: articleUrl) try FileManager.default.createDirectory(at: dest.deletingLastPathComponent(), withIntermediateDirectories: true) let data = try await get("/v1/podcast/audio/\(jobId)") try data.write(to: dest, options: .atomic) PodcastIndex.upsert(articleUrl: articleUrl, filename: dest.lastPathComponent, title: title, parentBookmarkUrl: parentBookmarkUrl) return dest } static func cachedPodcastURL(for articleUrl: String) -> URL { // djb2 hash — stable, no CryptoKit needed let hash = articleUrl.utf8.reduce(UInt64(5381)) { ($0 &* 31) &+ UInt64($1) } let dir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] .appendingPathComponent("podcasts") return dir.appendingPathComponent("\(hash).mp3") } // MARK: - HTTP primitives private func post(_ path: String, body: [String: Any]) async throws -> Data { let token = try await MarksAuth.validToken() guard let url = URL(string: MarksAuth.baseURL + path) else { throw APIError.invalidUrl } var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = try JSONSerialization.data(withJSONObject: body) print("[AI] POST \(path)") let (data, response) = try await URLSession.shared.data(for: request) let status = (response as? HTTPURLResponse)?.statusCode ?? 0 print("[AI] POST \(path) → \(status)") guard (200...299).contains(status) else { print("[AI] error: \(String(data: data, encoding: .utf8)?.prefix(200) ?? "")") throw APIError.badStatus(status) } return data } private func get(_ path: String) async throws -> Data { let token = try await MarksAuth.validToken() guard let url = URL(string: MarksAuth.baseURL + path) else { throw APIError.invalidUrl } var request = URLRequest(url: url) request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") print("[AI] GET \(path)") let (data, response) = try await URLSession.shared.data(for: request) let status = (response as? HTTPURLResponse)?.statusCode ?? 0 print("[AI] GET \(path) → \(status)") guard (200...299).contains(status) else { throw APIError.badStatus(status) } return data } }