import Foundation // MARK: - Typed error for local model calls enum LocalChatError: Error { /// The server is not reachable (wrong URL, not running, network error). case serverUnreachable(String) /// The model was requested but is not installed on the server. case modelNotFound(String) /// The server replied with an error message. case serverError(String) var localizedDescription: String { switch self { case .serverUnreachable(let url): return "Cannot reach \(url). Is the server running?" case .modelNotFound(let model): return "Model '\(model)' is not installed." case .serverError(let msg): return msg } } } // MARK: - Local model helpers (Foundation only) enum LocalChat { // MARK: URL normalisation /// Removes trailing slashes and common documentation sub-paths (/api, /v1). static func normaliseURL(_ raw: String) -> String { var s = raw.trimmingCharacters(in: .whitespacesAndNewlines) while s.hasSuffix("/") { s = String(s.dropLast()) } for suffix in ["/api", "/v1"] { if s.hasSuffix(suffix) { s = String(s.dropLast(suffix.count)) } } return s } // MARK: SSE parsing /// Parses `delta.content` from one OpenAI-SSE event line. /// Returns nil for non-data lines, `[DONE]`, missing or null content. static func parseSSEDelta(_ line: String) -> String? { guard line.hasPrefix("data: ") else { return nil } let payload = String(line.dropFirst(6)) guard payload != "[DONE]" else { return nil } guard let data = payload.data(using: .utf8), let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let choices = json["choices"] as? [[String: Any]], let delta = choices.first?["delta"] as? [String: Any], let content = delta["content"] as? String else { return nil } return content } // MARK: Think-block filtering /// Removes completed `…` blocks (reasoning models like DeepSeek-R1). static func filterThinkingBlocks(_ text: String) -> String { let pattern = "[\\s\\S]*?" guard let regex = try? NSRegularExpression(pattern: pattern) else { return text } let range = NSRange(text.startIndex..., in: text) return regex.stringByReplacingMatches(in: text, options: [], range: range, withTemplate: "") .trimmingCharacters(in: .whitespacesAndNewlines) } /// For streaming: removes closed think blocks AND hides content inside an /// open (unclosed) `` block that hasn't received its closing tag yet. static func progressiveFilter(_ text: String) -> String { let cleaned = filterThinkingBlocks(text) if let range = cleaned.range(of: "") { return String(cleaned[.. Result<[(id: String, label: String)], LocalChatError> { guard let url = URL(string: "\(baseURL)/v1/models") else { return .failure(.serverUnreachable(baseURL)) } var req = URLRequest(url: url, timeoutInterval: 5) req.setValue("Bearer ollama", forHTTPHeaderField: "Authorization") do { let (data, response) = try await URLSession.shared.data(for: req) guard let http = response as? HTTPURLResponse, http.statusCode == 200, let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let items = json["data"] as? [[String: Any]] else { return .failure(.serverUnreachable(baseURL)) } let excluded = ["embed", "bge-", "all-minilm", "clip", "rerank"] let models = items.compactMap { item -> (id: String, label: String)? in guard let id = item["id"] as? String else { return nil } let lower = id.lowercased() guard !excluded.contains(where: { lower.contains($0) }) else { return nil } return (id: id, label: id) } return .success(models) } catch { return .failure(.serverUnreachable(baseURL)) } } /// Fetches models from a local OpenAI-compatible server (`GET /v1/models`). /// Filters out embedding and non-chat models (nomic-embed, bge, clip, rerank…). static func fetchModels(baseURL: String) async -> [(id: String, label: String)] { if case .success(let models) = await fetchModelsResult(baseURL: baseURL) { return models } return [] } // MARK: Streaming chat /// Sends a conversation to a local OpenAI-compatible server and streams the reply. /// /// - Parameters: /// - baseURL: Base URL of the server (e.g. `http://localhost:11434`). /// - model: Model identifier. /// - messages: OpenAI-format message array (`[{"role":…,"content":…}]`). /// - onToken: Called on the **main actor** with the current visible text after /// each new token. Think-block content is hidden while the block is open. /// - Returns: The complete final response with thinking blocks removed. /// - Throws: `LocalChatError` /// Convenience overload that accepts messages as `[[String: Any]]`. /// Serialises the body on the caller's actor before crossing the isolation boundary. static func streamChat( baseURL: String, model: String, messages: [[String: Any]], onToken: @MainActor @escaping (String) -> Void ) async throws -> String { let body: [String: Any] = [ "model": model, "messages": messages, "stream": true, "max_tokens": 4096, ] let bodyData = (try? JSONSerialization.data(withJSONObject: body)) ?? Data() return try await streamChat(baseURL: baseURL, encodedBody: bodyData, model: model, onToken: onToken) } static func streamChat( baseURL: String, encodedBody: Data, model: String, onToken: @MainActor @escaping (String) -> Void ) async throws -> String { guard let url = URL(string: "\(baseURL)/v1/chat/completions") else { throw LocalChatError.serverUnreachable(baseURL) } var req = URLRequest(url: url, timeoutInterval: 300) req.httpMethod = "POST" req.setValue("application/json", forHTTPHeaderField: "Content-Type") req.setValue("Bearer ollama", forHTTPHeaderField: "Authorization") req.httpBody = encodedBody let bytes: URLSession.AsyncBytes let response: URLResponse do { (bytes, response) = try await URLSession.shared.bytes(for: req) } catch { throw LocalChatError.serverUnreachable(baseURL) } let status = (response as? HTTPURLResponse)?.statusCode ?? 0 if status == 404 { throw LocalChatError.modelNotFound(model) } if status != 200 { var raw = "" do { for try await byte in bytes { raw.append(Character(UnicodeScalar(byte))); if raw.count > 4096 { break } } } catch {} if let data = raw.data(using: .utf8), let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let msg = ((json["error"] as? [String: Any])?["message"] as? String) { throw LocalChatError.serverError(msg) } throw LocalChatError.serverError("HTTP \(status)") } var accumulated = "" var lastUpdate = Date.distantPast let minInterval: TimeInterval = 1.0 / 15.0 do { for try await line in bytes.lines { guard let delta = parseSSEDelta(line) else { continue } accumulated += delta let now = Date() if now.timeIntervalSince(lastUpdate) >= minInterval { lastUpdate = now let visible = progressiveFilter(accumulated) await MainActor.run { onToken(visible) } } } } catch { throw LocalChatError.serverUnreachable(baseURL) } // Always send the final filtered state let visible = progressiveFilter(accumulated) await MainActor.run { onToken(visible) } return filterThinkingBlocks(accumulated) .trimmingCharacters(in: .whitespacesAndNewlines) } }