import Foundation struct ClaudeService: Sendable { let apiKey: String static let defaultsKey = "openRouterApiKey" // Cheap, fast model — change to any OpenRouter model slug private static let model = "google/gemini-2.0-flash-lite-001" static func load() -> ClaudeService? { guard let key = UserDefaults.standard.string(forKey: defaultsKey), !key.isEmpty else { return nil } return ClaudeService(apiKey: key) } static func save(apiKey: String) { UserDefaults.standard.set(apiKey, forKey: defaultsKey) } func enrich(bookmark: Bookmark) async throws -> (summary: String, tags: [String]) { let prompt = """ Analyze this bookmark: URL: \(bookmark.url) Title: \(bookmark.displayTitle) Existing tags: \(bookmark.tagNames.joined(separator: ", ")) Respond with ONLY valid JSON, no markdown: {"summary": "One or two sentence summary of what this page is about.", "tags": ["tag1", "tag2", "tag3"]} Rules: summary under 120 chars, 3-5 tags, lowercase, no # symbol, no existing tags duplicated. """ let text = try await callModel(prompt: prompt, maxTokens: 256) return parseEnrichResponse(text) } func semanticSearch(query: String, in bookmarks: [Bookmark]) async throws -> [Bookmark] { guard !bookmarks.isEmpty else { return [] } let list = bookmarks.enumerated().map { i, b in "[\(i)] \(b.displayTitle) — \(b.domain)\(b.aiSummary.map { " — \($0)" } ?? "")" }.joined(separator: "\n") let prompt = """ Search query: "\(query)" Find relevant bookmarks from the list. Return ONLY a JSON array of indices in relevance order. Example: [3, 0, 7] Return [] if nothing matches. Bookmarks: \(list) """ let text = try await callModel(prompt: prompt, maxTokens: 256) let indices = parseIntArray(text) return indices.compactMap { i in i < bookmarks.count ? bookmarks[i] : nil } } func generateCollections(from bookmarks: [Bookmark]) async throws -> [SmartCollection] { guard !bookmarks.isEmpty else { return [] } let list = bookmarks.prefix(150).enumerated().map { i, b in "[\(i)] \(b.displayTitle) [\(b.tagNames.joined(separator: ","))]" }.joined(separator: "\n") let prompt = """ Group these bookmarks into 3-6 meaningful themed collections. Respond with ONLY valid JSON, no markdown: [{"name": "Collection Name", "description": "Brief description", "indices": [0, 1, 2]}] Bookmarks: \(list) """ let text = try await callModel(prompt: prompt, maxTokens: 1024) return parseCollections(text, bookmarks: Array(bookmarks.prefix(150))) } private func callModel(prompt: String, maxTokens: Int) async throws -> String { let url = URL(string: "https://openrouter.ai/api/v1/chat/completions")! var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("https://marks.app", forHTTPHeaderField: "HTTP-Referer") request.setValue("Marks", forHTTPHeaderField: "X-Title") let body: [String: Any] = [ "model": Self.model, "max_tokens": maxTokens, "messages": [["role": "user", "content": prompt]] ] request.httpBody = try JSONSerialization.data(withJSONObject: body) print("[AI] callModel: POST openrouter model=\(Self.model) maxTokens=\(maxTokens)") let (data, response) = try await URLSession.shared.data(for: request) let status = (response as? HTTPURLResponse)?.statusCode ?? 0 print("[AI] callModel: status=\(status) bytes=\(data.count)") guard status == 200 else { let body = String(data: data, encoding: .utf8) ?? "" print("[AI] callModel: error body=\(body.prefix(200))") throw APIError.badStatus(status) } struct Choice: Codable { struct Message: Codable { let content: String } let message: Message } struct Response: Codable { let choices: [Choice] } let content = (try JSONDecoder().decode(Response.self, from: data)).choices.first?.message.content ?? "" print("[AI] callModel: response=\(content.prefix(100))") return content } private func parseEnrichResponse(_ text: String) -> (summary: String, tags: [String]) { let cleaned = extractJSON(from: text) guard let data = cleaned.data(using: .utf8), let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let summary = json["summary"] as? String, let tags = json["tags"] as? [String] else { return (text, []) } return (summary, tags) } private func parseIntArray(_ text: String) -> [Int] { let cleaned = extractJSON(from: text) guard let data = cleaned.data(using: .utf8), let arr = try? JSONSerialization.jsonObject(with: data) as? [Int] else { return [] } return arr } private func parseCollections(_ text: String, bookmarks: [Bookmark]) -> [SmartCollection] { let cleaned = extractJSON(from: text) guard let data = cleaned.data(using: .utf8), let arr = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { return [] } return arr.compactMap { dict in guard let name = dict["name"] as? String, let indices = dict["indices"] as? [Int] else { return nil } let desc = dict["description"] as? String ?? "" let ids = indices.compactMap { i -> Int? in i < bookmarks.count ? bookmarks[i].id : nil } return SmartCollection(name: name, description: desc, bookmarkIds: ids) } } private func extractJSON(from text: String) -> String { if let start = text.range(of: "```json\n"), let end = text.range(of: "\n```") { return String(text[start.upperBound..