HuggingChat-Mac/Network/NetworkService.swift (277 lines of code) (raw):

// // NetworkService.swift // HuggingChat-Mac // // Created by Cyril Zakka on 8/23/24. // // swift-format-ignore-file import Combine import Foundation public enum DateDecodingStrategy { case formatted(DateFormatter) } final class NetworkService { private static let defaultBaseURL = "https://huggingface.co" fileprivate static var BASE_URL: String { get { UserDefaults.standard.string(forKey: "baseURL") ?? defaultBaseURL } set { UserDefaults.standard.set(newValue, forKey: "baseURL") } } static func resetToDefaultURL() { BASE_URL = defaultBaseURL } // Helper method to update BASE_URL static func updateBaseURL(_ newURL: String) { BASE_URL = newURL } // fileprivate static let BASE_URL: String = "http://192.168.1.111:5173" // fileprivate static let BASE_URL: String = "https://dc7a-83-83-23-99.ngrok-free.app" static func loginChat() -> AnyPublisher<LoginChat, HFError> { let endpoint = URL(string: "\(BASE_URL)/chat/login?callback=huggingchat%3A%2F%2Flogin%2Fcallback")! var request = URLRequest(url: endpoint) var headers: [String: String] = [:] headers["Content-Type"] = "application/x-www-form-urlencoded" headers["Accept"] = "*/*" headers["Referer"] = "\(BASE_URL)/chat/login" request.httpMethod = "POST" request.allHTTPHeaderFields = headers request.httpShouldHandleCookies = true request.httpBody = Data("".utf8) return resolveRequest(request) } static func validateSignIn(code: String, state: String) -> AnyPublisher<Void, HFError> { var headers: [String: String] = [:] headers["Accept"] = "*/*" headers["Referer"] = "\(BASE_URL)/" var request = URLRequest(url: URL(string: "\(BASE_URL)/chat/login/callback?code=\(code)&state=\(state)")!) request.allHTTPHeaderFields = headers return sendRequest(request) .map { s in Void() }.toNetworkError() } static func createConversation(base: BaseConversation) -> AnyPublisher<Conversation, HFError> { AnalyticsService.shared.createConversation(model: base.id) let endpoint = "\(BASE_URL)/chat/conversation" let headers = ["Content-Type": "application/json"] var request = URLRequest(url: URL(string: endpoint)!) request.httpMethod = "POST" request.allHTTPHeaderFields = headers do { let jsonData = try JSONEncoder().encode(base.toNewConversation()) request.httpBody = jsonData return resolveRequest(request, decoder: JSONDecoder.ISO8601()) .flatMap({ (newConversation: NewConversation) in return getConversation(id: newConversation.id).eraseToAnyPublisher() }).eraseToAnyPublisher() } catch { return Fail(outputType: Conversation.self, failure: HFError.encodeError(error)).eraseToAnyPublisher() } } static func getConversation(id: String) -> AnyPublisher<Conversation, HFError> { let endpoint = "\(BASE_URL)/chat/api/conversation/\(id)" let request = URLRequest(url: URL(string: endpoint)!) return resolveRequest(request, decoder: JSONDecoder.ISO8601Millisec()) } static func getMyAssistants() -> AnyPublisher<[Assistant], HFError> { let endpoint = "\(BASE_URL)/chat/api/user/assistants" let request = URLRequest(url: URL(string: endpoint)!) return resolveRequest(request, decoder: JSONDecoder.ISO8601Millisec()) } static func getAssistants(page: Int = 0) -> AnyPublisher<AssistantResponse, HFError> { let endpoint = "\(BASE_URL)/chat/api/assistants?p=\(page)" let request = URLRequest(url: URL(string: endpoint)!) return resolveRequest(request, decoder: JSONDecoder.ISO8601Millisec()) } static func getAssistant(id: String) -> AnyPublisher<Assistant, HFError> { let endpoint = "\(BASE_URL)/chat/api/assistant/\(id)" let request = URLRequest(url: URL(string: endpoint)!) return resolveRequest(request, decoder: JSONDecoder.ISO8601Millisec()) } static func deleteConversation(id: String) -> AnyPublisher<Void, HFError> { let endpoint = "\(BASE_URL)/chat/conversation/\(id)" var request = URLRequest(url: URL(string: endpoint)!) request.httpMethod = "DELETE" return sendRequest(request).map { _ in Void() }.eraseToAnyPublisher() } static func editConversationTitle(conversation: Conversation) -> AnyPublisher<Void, HFError> { let endpoint = "\(BASE_URL)/chat/conversation/\(conversation.id)" let headers = ["Content-Type": "application/json"] var request = URLRequest(url: URL(string: endpoint)!) request.httpMethod = "PATCH" request.allHTTPHeaderFields = headers do { let jsonData = try JSONEncoder().encode(conversation.toTitleEditionBody()) request.httpBody = jsonData return sendRequest(request).map { _ in Void() }.eraseToAnyPublisher() } catch { return Fail(outputType: Void.self, failure: HFError.encodeError(error)).eraseToAnyPublisher() } } static func getConversations() -> AnyPublisher<[Conversation], HFError> { let endpoint = "\(BASE_URL)/chat/api/conversations" let request = URLRequest(url: URL(string: endpoint)!) return resolveRequest(request, decoder: JSONDecoder.ISO8601Millisec()) } static func getModels() -> AnyPublisher<[LLMModel], HFError> { let endpoint = "\(BASE_URL)/chat/api/models" let request = URLRequest(url: URL(string: endpoint)!) return resolveRequest(request, decoder: JSONDecoder.ISO8601()) } static func shareConversation(id: String) -> AnyPublisher<SharedConversation, HFError> { let endpoint = "\(BASE_URL)/chat/conversation/\(id)/share" let headers = ["Content-Type": "application/json"] var request = URLRequest(url: URL(string: endpoint)!) request.httpMethod = "POST" request.allHTTPHeaderFields = headers return resolveRequest(request, decoder: JSONDecoder()) } static func getCurrentUser() -> AnyPublisher<HuggingChatUser, HFError> { guard let _ = HuggingChatSession.shared.hfChatToken else { return Fail(outputType: HuggingChatUser.self, failure: HFError.missingHFToken).eraseToAnyPublisher() } let endpoint = "\(BASE_URL)/chat/api/user" let request = URLRequest(url: URL(string: endpoint)!) return resolveRequest(request) } static func getDocumentsDirectory() -> URL { let paths = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask) return paths[0] } private static func resolveRequest<T: Decodable>(_ request: URLRequest, decoder: JSONDecoder = JSONDecoder()) -> AnyPublisher<T, HFError> { return sendRequest(request) .tryMap { data in guard let data = data else { throw HFError.unknown } do { let models = try decoder.decode(T.self, from: data) return models } catch { throw HFError.decodeError(error) } }.toNetworkError().eraseToAnyPublisher() } static func sendRequest(_ request: URLRequest) -> AnyPublisher<Data?, HFError> { var req = request req.setValue(UserAgentBuilder.userAgent, forHTTPHeaderField: "User-Agent") req.setValue(self.BASE_URL, forHTTPHeaderField: "Origin") let publisher = Deferred { Future<Data?, HFError> { promise in let task = URLSession.shared.dataTask(with: req) { (data, response, error) in if let error = error { promise(.failure(HFError.networkError(error))) return } guard let response = response else { promise(.failure(.noResponse)) return } guard let httpResponse = response as? HTTPURLResponse else { promise(.failure(.notHTTPResponse(response, data))) return } guard httpResponse.statusCode >= 200 && httpResponse.statusCode < 300 else { promise(.failure(.httpError(httpResponse.statusCode, data))) return } promise(.success(data)) } task.resume() } } return publisher.eraseToAnyPublisher() } } final class PostStream: NSObject, URLSessionDelegate, URLSessionDataDelegate { private let BASE_URL: String = NetworkService.BASE_URL private let sessionConfiguration: URLSessionConfiguration = URLSessionConfiguration.default..{ $0.requestCachePolicy = .reloadIgnoringLocalCacheData } private lazy var session: URLSession = URLSession(configuration: sessionConfiguration, delegate: self, delegateQueue: .main) private let encoder = JSONEncoder()..{ $0.keyEncodingStrategy = .convertToSnakeCase } private var _update: PassthroughSubject<Data, HFError> = PassthroughSubject<Data, HFError>() func postPrompt(reqBody: PromptRequestBody, conversationId: String) -> AnyPublisher<Data, HFError> { let endpoint = "\(BASE_URL)/chat/conversation/\(conversationId)" let boundary = UUID().uuidString var request = URLRequest(url: URL(string: endpoint)!) request.httpMethod = "POST" request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") request.setValue(UserAgentBuilder.userAgent, forHTTPHeaderField: "User-Agent") request.setValue("\(BASE_URL)", forHTTPHeaderField: "Origin") var data = Data() // Add the files. Add tools Document Parser if supported. // TODO: Limit to 10MB per file otherwise error out if let filePaths = reqBody.files { for (_, filePath) in filePaths.enumerated() { let fileURL = URL(fileURLWithPath: filePath) let filename = fileURL.lastPathComponent do { let fileData = try Data(contentsOf: fileURL) let base64String = fileData.base64EncodedString() data.append("--\(boundary)\r\n".data(using: .utf8)!) data.append("Content-Disposition: form-data; name=\"files\"; filename=\"base64;\(filename)\"\r\n".data(using: .utf8)!) data.append("Content-Type: \(mimeType(for: fileURL))\r\n\r\n".data(using: .utf8)!) data.append(base64String.data(using: .utf8)!) data.append("\r\n".data(using: .utf8)!) } catch { print("Error reading file: \(error)") } } } // Create a cleaned request body without files for JSON var cleanedReqBody = reqBody cleanedReqBody.files = nil // Add the JSON part do { let jsonData = try encoder.encode(cleanedReqBody) data.append("--\(boundary)\r\n".data(using: .utf8)!) data.append("Content-Disposition: form-data; name=\"data\"\r\n".data(using: .utf8)!) data.append("Content-Type: application/json\r\n\r\n".data(using: .utf8)!) data.append(jsonData) data.append("\r\n".data(using: .utf8)!) } catch { return Fail(outputType: Data.self, failure: HFError.encodeError(error)).eraseToAnyPublisher() } // Add the final boundary data.append("--\(boundary)--\r\n".data(using: .utf8)!) request.httpBody = data let task = self.session.dataTask(with: request) task.delegate = self return _update.eraseToAnyPublisher().handleEvents(receiveRequest: { _ in task.resume() }) .eraseToAnyPublisher() } func mimeType(for url: URL) -> String { let pathExtension = url.pathExtension switch pathExtension.lowercased() { case "jpg", "jpeg": return "image/jpeg" case "png": return "image/png" case "gif": return "image/gif" case "pdf": return "application/pdf" default: return "application/octet-stream" } } func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { _update.send(data) } func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { if let error = error { _update.send(completion: .failure(HFError.networkError(error))) return } guard let response = task.response else { _update.send(completion: .failure(.noResponse)) return } guard let httpResponse = response as? HTTPURLResponse else { _update.send(completion: .failure(.notHTTPResponse(response, nil))) return } if httpResponse.statusCode == 429 { _update.send(completion: .failure(.httpTooManyRequest)) return } guard httpResponse.statusCode >= 200 && httpResponse.statusCode < 300 else { _update.send(completion: .failure(.httpError(httpResponse.statusCode, nil))) return } _update.send(completion: .finished) } }