diff --git a/OllamaKit b/OllamaKit new file mode 160000 index 0000000..e0054c6 --- /dev/null +++ b/OllamaKit @@ -0,0 +1 @@ +Subproject commit e0054c65bba83d1151f10707666ba3eace0e1973 diff --git a/Sources/LocalLLM/LocalLLMManager.swift b/Sources/LocalLLM/LocalLLMManager.swift new file mode 100644 index 0000000..c03b393 --- /dev/null +++ b/Sources/LocalLLM/LocalLLMManager.swift @@ -0,0 +1,365 @@ +// +// LocalLLMManager.swift +// Invisibility +// +// Central manager for local LLM providers (Ollama, LM Studio) +// + +import Combine +import Foundation +import os + +/// Central manager for local LLM providers +@MainActor +public final class LocalLLMManager: ObservableObject { + // MARK: - Singleton + + public static let shared = LocalLLMManager() + + // MARK: - Properties + + private let logger = Logger(subsystem: "Invisibility", category: "LocalLLMManager") + + /// Ollama provider instance + @Published public private(set) var ollamaProvider: OllamaProvider + + /// LM Studio provider instance + @Published public private(set) var lmStudioProvider: LMStudioProvider + + /// Currently active provider type + @Published public var activeProviderType: LocalLLMProviderType? { + didSet { + saveSettings() + } + } + + /// Currently selected model ID + @Published public var selectedModelId: String? { + didSet { + saveSettings() + } + } + + /// All available models from enabled providers + @Published public private(set) var allAvailableModels: [LocalLLMModel] = [] + + /// Combined connection status for local LLMs + @Published public private(set) var isAnyProviderConnected: Bool = false + + private var cancellables = Set() + private let settingsKey = "LocalLLMSettings" + + // MARK: - Initialization + + private init() { + // Load saved settings + let settings = Self.loadSettings() + + // Initialize providers with saved configuration + ollamaProvider = OllamaProvider(configuration: settings.ollamaConfig) + lmStudioProvider = LMStudioProvider(configuration: settings.lmStudioConfig) + activeProviderType = settings.activeProviderType + selectedModelId = settings.selectedModelId + + setupObservers() + + // Check connections on init + Task { + await refreshConnections() + } + } + + // MARK: - Setup + + private func setupObservers() { + // Observe Ollama connection status + ollamaProvider.connectionStatusPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.updateCombinedStatus() + } + .store(in: &cancellables) + + // Observe LM Studio connection status + lmStudioProvider.connectionStatusPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.updateCombinedStatus() + } + .store(in: &cancellables) + + // Observe Ollama models + ollamaProvider.availableModelsPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.updateAllModels() + } + .store(in: &cancellables) + + // Observe LM Studio models + lmStudioProvider.availableModelsPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.updateAllModels() + } + .store(in: &cancellables) + } + + private func updateCombinedStatus() { + isAnyProviderConnected = + ollamaProvider.connectionStatus.isConnected || + lmStudioProvider.connectionStatus.isConnected + } + + private func updateAllModels() { + var models: [LocalLLMModel] = [] + + if ollamaProvider.configuration.isEnabled { + models.append(contentsOf: ollamaProvider.availableModels) + } + + if lmStudioProvider.configuration.isEnabled { + models.append(contentsOf: lmStudioProvider.availableModels) + } + + allAvailableModels = models + } + + // MARK: - Provider Access + + /// Get the currently active provider + public var activeProvider: LocalLLMProvider? { + guard let type = activeProviderType else { return nil } + return provider(for: type) + } + + /// Get provider for a specific type + public func provider(for type: LocalLLMProviderType) -> LocalLLMProvider { + switch type { + case .ollama: + return ollamaProvider + case .lmStudio: + return lmStudioProvider + } + } + + /// Get the currently selected model + public var selectedModel: LocalLLMModel? { + guard let modelId = selectedModelId else { return nil } + return allAvailableModels.first { $0.id == modelId } + } + + // MARK: - Connection Management + + /// Refresh connections for all enabled providers + public func refreshConnections() async { + logger.debug("Refreshing local LLM connections") + + async let ollamaTask: () = refreshOllamaConnection() + async let lmStudioTask: () = refreshLMStudioConnection() + + _ = await (ollamaTask, lmStudioTask) + } + + /// Refresh Ollama connection + public func refreshOllamaConnection() async { + guard ollamaProvider.configuration.isEnabled else { + logger.debug("Ollama is disabled, skipping connection check") + return + } + + let connected = await ollamaProvider.checkConnection() + logger.info("Ollama connection: \(connected ? "success" : "failed")") + } + + /// Refresh LM Studio connection + public func refreshLMStudioConnection() async { + guard lmStudioProvider.configuration.isEnabled else { + logger.debug("LM Studio is disabled, skipping connection check") + return + } + + let connected = await lmStudioProvider.checkConnection() + logger.info("LM Studio connection: \(connected ? "success" : "failed")") + } + + // MARK: - Configuration + + /// Update Ollama configuration + public func updateOllamaConfiguration(_ config: LocalLLMConfiguration) { + ollamaProvider.configuration = config + + if config.isEnabled { + Task { + await refreshOllamaConnection() + } + } + + updateAllModels() + saveSettings() + } + + /// Update LM Studio configuration + public func updateLMStudioConfiguration(_ config: LocalLLMConfiguration) { + lmStudioProvider.configuration = config + + if config.isEnabled { + Task { + await refreshLMStudioConnection() + } + } + + updateAllModels() + saveSettings() + } + + /// Enable/disable a provider + public func setProviderEnabled(_ type: LocalLLMProviderType, enabled: Bool) { + switch type { + case .ollama: + var config = ollamaProvider.configuration + config.isEnabled = enabled + updateOllamaConfiguration(config) + case .lmStudio: + var config = lmStudioProvider.configuration + config.isEnabled = enabled + updateLMStudioConfiguration(config) + } + } + + // MARK: - Chat + + /// Send a chat request using the active provider and selected model + public func chat( + messages: [LocalLLMMessage], + options: LocalLLMOptions = .default + ) -> AnyPublisher { + guard let provider = activeProvider else { + return Fail(error: LocalLLMError.notConnected).eraseToAnyPublisher() + } + + guard let modelId = selectedModelId else { + return Fail(error: LocalLLMError.modelNotFound("No model selected")).eraseToAnyPublisher() + } + + let request = LocalLLMChatRequest( + model: modelId, + messages: messages, + options: options, + stream: true + ) + + return provider.chat(request: request) + } + + /// Send a chat request with specific provider and model + public func chat( + provider type: LocalLLMProviderType, + model: String, + messages: [LocalLLMMessage], + options: LocalLLMOptions = .default, + stream: Bool = true + ) -> AnyPublisher { + let provider = provider(for: type) + + let request = LocalLLMChatRequest( + model: model, + messages: messages, + options: options, + stream: stream + ) + + return provider.chat(request: request) + } + + /// Cancel all ongoing requests + public func cancelAllRequests() { + ollamaProvider.cancelAllRequests() + lmStudioProvider.cancelAllRequests() + } + + // MARK: - Settings Persistence + + private struct LocalLLMSettings: Codable { + var ollamaConfig: LocalLLMConfiguration + var lmStudioConfig: LocalLLMConfiguration + var activeProviderType: LocalLLMProviderType? + var selectedModelId: String? + } + + private static func loadSettings() -> LocalLLMSettings { + guard let data = UserDefaults.standard.data(forKey: "LocalLLMSettings"), + let settings = try? JSONDecoder().decode(LocalLLMSettings.self, from: data) + else { + // Return default settings + return LocalLLMSettings( + ollamaConfig: LocalLLMConfiguration( + host: "localhost", + port: 11434, + isEnabled: false + ), + lmStudioConfig: LocalLLMConfiguration( + host: "localhost", + port: 1234, + isEnabled: false + ), + activeProviderType: nil, + selectedModelId: nil + ) + } + return settings + } + + private func saveSettings() { + let settings = LocalLLMSettings( + ollamaConfig: ollamaProvider.configuration, + lmStudioConfig: lmStudioProvider.configuration, + activeProviderType: activeProviderType, + selectedModelId: selectedModelId + ) + + if let data = try? JSONEncoder().encode(settings) { + UserDefaults.standard.set(data, forKey: settingsKey) + logger.debug("Saved local LLM settings") + } + } +} + +// MARK: - Convenience Extensions + +public extension LocalLLMManager { + /// Check if local LLM is currently usable + var isLocalLLMAvailable: Bool { + isAnyProviderConnected && selectedModelId != nil + } + + /// Get a display name for the current local LLM configuration + var displayName: String { + guard let model = selectedModel else { + return "Local LLM (Not configured)" + } + return "\(model.provider.rawValue): \(model.name)" + } + + /// Quick setup for Ollama with default settings + func enableOllama(host: String = "localhost", port: Int = 11434) { + let config = LocalLLMConfiguration( + host: host, + port: port, + isEnabled: true + ) + updateOllamaConfiguration(config) + activeProviderType = .ollama + } + + /// Quick setup for LM Studio with default settings + func enableLMStudio(host: String = "localhost", port: Int = 1234) { + let config = LocalLLMConfiguration( + host: host, + port: port, + isEnabled: true + ) + updateLMStudioConfiguration(config) + activeProviderType = .lmStudio + } +} diff --git a/Sources/LocalLLM/Models/LocalLLMModels.swift b/Sources/LocalLLM/Models/LocalLLMModels.swift new file mode 100644 index 0000000..ae3e1d8 --- /dev/null +++ b/Sources/LocalLLM/Models/LocalLLMModels.swift @@ -0,0 +1,325 @@ +// +// LocalLLMModels.swift +// Invisibility +// +// Created for local LLM support (Ollama & LM Studio) +// + +import Foundation + +// MARK: - Provider Types + +/// Supported local LLM providers +public enum LocalLLMProviderType: String, CaseIterable, Codable, Identifiable { + case ollama = "Ollama" + case lmStudio = "LM Studio" + + public var id: String { rawValue } + + /// Default port for each provider + public var defaultPort: Int { + switch self { + case .ollama: return 11434 + case .lmStudio: return 1234 + } + } + + /// Default host + public var defaultHost: String { + "localhost" + } + + /// Default base URL + public var defaultBaseURL: URL { + URL(string: "http://\(defaultHost):\(defaultPort)")! + } + + /// API path for chat completions + public var chatEndpoint: String { + switch self { + case .ollama: return "/api/chat" + case .lmStudio: return "/v1/chat/completions" + } + } + + /// API path for listing models + public var modelsEndpoint: String { + switch self { + case .ollama: return "/api/tags" + case .lmStudio: return "/v1/models" + } + } + + /// Icon name for UI + public var iconName: String { + switch self { + case .ollama: return "server.rack" + case .lmStudio: return "desktopcomputer" + } + } +} + +// MARK: - Local Model Info + +/// Information about a local LLM model +public struct LocalLLMModel: Identifiable, Codable, Hashable { + public let id: String + public let name: String + public let provider: LocalLLMProviderType + public let size: Int64? + public let modifiedAt: Date? + public let digest: String? + public let details: LocalLLMModelDetails? + + public init( + id: String, + name: String, + provider: LocalLLMProviderType, + size: Int64? = nil, + modifiedAt: Date? = nil, + digest: String? = nil, + details: LocalLLMModelDetails? = nil + ) { + self.id = id + self.name = name + self.provider = provider + self.size = size + self.modifiedAt = modifiedAt + self.digest = digest + self.details = details + } + + /// Human-readable size string + public var sizeString: String { + guard let size = size else { return "Unknown" } + let formatter = ByteCountFormatter() + formatter.countStyle = .file + return formatter.string(fromByteCount: size) + } +} + +/// Details about a local model +public struct LocalLLMModelDetails: Codable, Hashable { + public let family: String? + public let families: [String]? + public let parameterSize: String? + public let quantizationLevel: String? + + public init( + family: String? = nil, + families: [String]? = nil, + parameterSize: String? = nil, + quantizationLevel: String? = nil + ) { + self.family = family + self.families = families + self.parameterSize = parameterSize + self.quantizationLevel = quantizationLevel + } +} + +// MARK: - Chat Messages + +/// Role for chat messages +public enum LocalLLMRole: String, Codable { + case system + case user + case assistant +} + +/// A chat message for local LLM +public struct LocalLLMMessage: Codable, Identifiable { + public let id: UUID + public let role: LocalLLMRole + public let content: String + public let images: [String]? // Base64 encoded images + + public init( + id: UUID = UUID(), + role: LocalLLMRole, + content: String, + images: [String]? = nil + ) { + self.id = id + self.role = role + self.content = content + self.images = images + } +} + +// MARK: - Chat Request/Response + +/// Options for LLM generation +public struct LocalLLMOptions: Codable { + public var temperature: Double? + public var topP: Double? + public var topK: Int? + public var maxTokens: Int? + public var repeatPenalty: Double? + public var seed: Int? + public var stop: [String]? + public var numCtx: Int? // Context window size + + public init( + temperature: Double? = 0.7, + topP: Double? = 0.9, + topK: Int? = 40, + maxTokens: Int? = nil, + repeatPenalty: Double? = 1.1, + seed: Int? = nil, + stop: [String]? = nil, + numCtx: Int? = 4096 + ) { + self.temperature = temperature + self.topP = topP + self.topK = topK + self.maxTokens = maxTokens + self.repeatPenalty = repeatPenalty + self.seed = seed + self.stop = stop + self.numCtx = numCtx + } + + public static let `default` = LocalLLMOptions() +} + +/// Request for chat completion +public struct LocalLLMChatRequest { + public let model: String + public let messages: [LocalLLMMessage] + public let options: LocalLLMOptions + public let stream: Bool + + public init( + model: String, + messages: [LocalLLMMessage], + options: LocalLLMOptions = .default, + stream: Bool = true + ) { + self.model = model + self.messages = messages + self.options = options + self.stream = stream + } +} + +/// Response chunk from streaming chat +public struct LocalLLMChatResponseChunk { + public let content: String + public let done: Bool + public let model: String? + public let totalDuration: Int? + public let evalCount: Int? + + public init( + content: String, + done: Bool, + model: String? = nil, + totalDuration: Int? = nil, + evalCount: Int? = nil + ) { + self.content = content + self.done = done + self.model = model + self.totalDuration = totalDuration + self.evalCount = evalCount + } +} + +// MARK: - Connection Status + +/// Connection status for a local LLM provider +public enum LocalLLMConnectionStatus: Equatable { + case disconnected + case connecting + case connected + case error(String) + + public var isConnected: Bool { + if case .connected = self { return true } + return false + } + + public var statusText: String { + switch self { + case .disconnected: + return "Disconnected" + case .connecting: + return "Connecting..." + case .connected: + return "Connected" + case .error(let message): + return "Error: \(message)" + } + } + + public var statusColor: String { + switch self { + case .disconnected: return "gray" + case .connecting: return "yellow" + case .connected: return "green" + case .error: return "red" + } + } +} + +// MARK: - Configuration + +/// Configuration for a local LLM provider +public struct LocalLLMConfiguration: Codable { + public var host: String + public var port: Int + public var isEnabled: Bool + public var selectedModel: String? + + public init( + host: String = "localhost", + port: Int, + isEnabled: Bool = false, + selectedModel: String? = nil + ) { + self.host = host + self.port = port + self.isEnabled = isEnabled + self.selectedModel = selectedModel + } + + public var baseURL: URL { + URL(string: "http://\(host):\(port)")! + } +} + +// MARK: - Errors + +/// Errors that can occur with local LLM providers +public enum LocalLLMError: LocalizedError { + case notConnected + case connectionFailed(String) + case modelNotFound(String) + case streamingError(String) + case invalidResponse + case serverError(Int, String?) + case timeout + case cancelled + + public var errorDescription: String? { + switch self { + case .notConnected: + return "Not connected to local LLM server" + case .connectionFailed(let reason): + return "Connection failed: \(reason)" + case .modelNotFound(let model): + return "Model '\(model)' not found" + case .streamingError(let reason): + return "Streaming error: \(reason)" + case .invalidResponse: + return "Invalid response from server" + case .serverError(let code, let message): + return "Server error (\(code)): \(message ?? "Unknown error")" + case .timeout: + return "Request timed out" + case .cancelled: + return "Request was cancelled" + } + } +} diff --git a/Sources/LocalLLM/Package.swift b/Sources/LocalLLM/Package.swift new file mode 100644 index 0000000..94058fe --- /dev/null +++ b/Sources/LocalLLM/Package.swift @@ -0,0 +1,34 @@ +// swift-tools-version:5.9 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "LocalLLM", + platforms: [ + .macOS(.v14) + ], + products: [ + .library( + name: "LocalLLM", + targets: ["LocalLLM"] + ) + ], + dependencies: [ + .package(url: "https://github.com/Alamofire/Alamofire.git", from: "5.8.0") + ], + targets: [ + .target( + name: "LocalLLM", + dependencies: ["Alamofire"], + path: ".", + exclude: ["Package.swift", "README.md"], + sources: [ + "Models", + "Providers", + "UI", + "LocalLLMManager.swift" + ] + ) + ] +) diff --git a/Sources/LocalLLM/Providers/LMStudioProvider.swift b/Sources/LocalLLM/Providers/LMStudioProvider.swift new file mode 100644 index 0000000..3ff65e8 --- /dev/null +++ b/Sources/LocalLLM/Providers/LMStudioProvider.swift @@ -0,0 +1,346 @@ +// +// LMStudioProvider.swift +// Invisibility +// +// LM Studio provider implementation using OpenAI-compatible API +// + +import Alamofire +import Combine +import Foundation +import os + +/// Provider for LM Studio - local LLM server with OpenAI-compatible API +public final class LMStudioProvider: BaseLocalLLMProvider { + // MARK: - Properties + + private let logger = Logger(subsystem: "Invisibility", category: "LMStudioProvider") + private let session: Session + private var streamRequest: DataStreamRequest? + + // MARK: - Initialization + + public init(configuration: LocalLLMConfiguration? = nil) { + let sessionConfig = URLSessionConfiguration.default + sessionConfig.timeoutIntervalForRequest = 300 // 5 minutes for long generations + sessionConfig.timeoutIntervalForResource = 600 + self.session = Session(configuration: sessionConfig) + + super.init(providerType: .lmStudio, configuration: configuration) + } + + // MARK: - Connection + + public override func checkConnection() async -> Bool { + updateConnectionStatus(.connecting) + + let url = configuration.baseURL.appendingPathComponent("v1/models") + logger.debug("Checking LM Studio connection at \(url.absoluteString)") + + do { + let request = session.request(url).validate() + let _ = try await request.serializingData().value + + updateConnectionStatus(.connected) + logger.info("LM Studio is reachable") + + // Fetch models on successful connection + Task { + _ = try? await fetchModels() + } + + return true + } catch { + logger.error("LM Studio connection check failed: \(error.localizedDescription)") + updateConnectionStatus(.error(error.localizedDescription)) + return false + } + } + + // MARK: - Models + + public override func fetchModels() async throws -> [LocalLLMModel] { + let url = configuration.baseURL.appendingPathComponent("v1/models") + logger.debug("Fetching LM Studio models from \(url.absoluteString)") + + do { + let request = session.request(url).validate() + let response = try await request.serializingDecodable(LMStudioModelsResponse.self).value + + let models = response.data.map { model in + LocalLLMModel( + id: model.id, + name: model.id, + provider: .lmStudio, + size: nil, + modifiedAt: model.created != nil ? Date(timeIntervalSince1970: TimeInterval(model.created!)) : nil, + digest: nil, + details: nil + ) + } + + updateAvailableModels(models) + logger.info("Fetched \(models.count) LM Studio models") + return models + } catch { + logger.error("Failed to fetch LM Studio models: \(error.localizedDescription)") + throw LocalLLMError.connectionFailed(error.localizedDescription) + } + } + + // MARK: - Chat + + public override func chat(request: LocalLLMChatRequest) -> AnyPublisher { + let subject = PassthroughSubject() + + let url = configuration.baseURL.appendingPathComponent("v1/chat/completions") + logger.debug("Starting LM Studio chat with model: \(request.model)") + + // Build OpenAI-compatible request body + let body = LMStudioChatRequest( + model: request.model, + messages: request.messages.map { msg in + LMStudioMessage( + role: msg.role.rawValue, + content: msg.content + ) + }, + temperature: request.options.temperature, + topP: request.options.topP, + maxTokens: request.options.maxTokens, + stream: request.stream, + stop: request.options.stop, + frequencyPenalty: nil, + presencePenalty: nil + ) + + do { + let encoder = JSONEncoder() + encoder.keyEncodingStrategy = .convertToSnakeCase + let bodyData = try encoder.encode(body) + + var urlRequest = URLRequest(url: url) + urlRequest.httpMethod = "POST" + urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") + urlRequest.httpBody = bodyData + urlRequest.timeoutInterval = 300 + + if request.stream { + // Streaming request (Server-Sent Events format) + streamRequest = session.streamRequest(urlRequest).validate() + + var buffer = "" + + streamRequest?.responseStream { [weak self] stream in + switch stream.event { + case let .stream(result): + switch result { + case let .success(data): + guard let text = String(data: data, encoding: .utf8) else { return } + buffer += text + + // Parse SSE events + let lines = buffer.components(separatedBy: "\n") + var processedUpTo = 0 + + for (index, line) in lines.enumerated() { + if line.hasPrefix("data: ") { + let jsonString = String(line.dropFirst(6)) + + // Check for stream end + if jsonString.trimmingCharacters(in: .whitespaces) == "[DONE]" { + let chunk = LocalLLMChatResponseChunk( + content: "", + done: true, + model: request.model + ) + subject.send(chunk) + self?.logger.debug("LM Studio stream completed") + processedUpTo = index + 1 + continue + } + + // Try to parse JSON + if let jsonData = jsonString.data(using: .utf8) { + do { + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + let response = try decoder.decode(LMStudioStreamResponse.self, from: jsonData) + + if let choice = response.choices.first, + let content = choice.delta?.content + { + let chunk = LocalLLMChatResponseChunk( + content: content, + done: choice.finishReason != nil, + model: response.model + ) + subject.send(chunk) + } + processedUpTo = index + 1 + } catch { + // Incomplete JSON, wait for more data + break + } + } + } else if line.isEmpty || line.hasPrefix(":") { + // Empty line or comment, skip + processedUpTo = index + 1 + } + } + + // Keep unprocessed data in buffer + if processedUpTo > 0 { + buffer = lines.dropFirst(processedUpTo).joined(separator: "\n") + } + + case let .failure(error): + self?.logger.error("LM Studio stream error: \(error.localizedDescription)") + subject.send(completion: .failure(LocalLLMError.streamingError(error.localizedDescription))) + } + + case .complete: + subject.send(completion: .finished) + } + } + } else { + // Non-streaming request + Task { + do { + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + + let dataRequest = self.session.request(urlRequest).validate() + let response = try await dataRequest.serializingDecodable( + LMStudioChatResponse.self, + decoder: decoder + ).value + + if let choice = response.choices.first { + let chunk = LocalLLMChatResponseChunk( + content: choice.message.content, + done: true, + model: response.model + ) + subject.send(chunk) + } + subject.send(completion: .finished) + } catch { + self.logger.error("LM Studio request failed: \(error.localizedDescription)") + subject.send(completion: .failure(error)) + } + } + } + } catch { + logger.error("Failed to encode LM Studio request: \(error.localizedDescription)") + subject.send(completion: .failure(error)) + } + + return subject.eraseToAnyPublisher() + } + + public override func cancelAllRequests() { + streamRequest?.cancel() + streamRequest = nil + super.cancelAllRequests() + } +} + +// MARK: - LM Studio API Models (OpenAI-compatible) + +/// Response from /v1/models endpoint +private struct LMStudioModelsResponse: Decodable { + let object: String + let data: [LMStudioModelInfo] +} + +private struct LMStudioModelInfo: Decodable { + let id: String + let object: String? + let created: Int? + let ownedBy: String? + + enum CodingKeys: String, CodingKey { + case id + case object + case created + case ownedBy = "owned_by" + } +} + +/// Request body for /v1/chat/completions +private struct LMStudioChatRequest: Encodable { + let model: String + let messages: [LMStudioMessage] + let temperature: Double? + let topP: Double? + let maxTokens: Int? + let stream: Bool + let stop: [String]? + let frequencyPenalty: Double? + let presencePenalty: Double? + + enum CodingKeys: String, CodingKey { + case model + case messages + case temperature + case topP = "top_p" + case maxTokens = "max_tokens" + case stream + case stop + case frequencyPenalty = "frequency_penalty" + case presencePenalty = "presence_penalty" + } +} + +private struct LMStudioMessage: Encodable { + let role: String + let content: String +} + +/// Non-streaming response from /v1/chat/completions +private struct LMStudioChatResponse: Decodable { + let id: String + let object: String + let created: Int + let model: String + let choices: [LMStudioChoice] + let usage: LMStudioUsage? +} + +private struct LMStudioChoice: Decodable { + let index: Int + let message: LMStudioResponseMessage + let finishReason: String? +} + +private struct LMStudioResponseMessage: Decodable { + let role: String + let content: String +} + +private struct LMStudioUsage: Decodable { + let promptTokens: Int? + let completionTokens: Int? + let totalTokens: Int? +} + +/// Streaming response chunk from /v1/chat/completions +private struct LMStudioStreamResponse: Decodable { + let id: String + let object: String + let created: Int + let model: String + let choices: [LMStudioStreamChoice] +} + +private struct LMStudioStreamChoice: Decodable { + let index: Int + let delta: LMStudioDelta? + let finishReason: String? +} + +private struct LMStudioDelta: Decodable { + let role: String? + let content: String? +} diff --git a/Sources/LocalLLM/Providers/LocalLLMProvider.swift b/Sources/LocalLLM/Providers/LocalLLMProvider.swift new file mode 100644 index 0000000..06251ab --- /dev/null +++ b/Sources/LocalLLM/Providers/LocalLLMProvider.swift @@ -0,0 +1,164 @@ +// +// LocalLLMProvider.swift +// Invisibility +// +// Protocol defining the interface for local LLM providers +// + +import Combine +import Foundation + +// MARK: - Provider Protocol + +/// Protocol defining the interface for local LLM providers (Ollama, LM Studio, etc.) +public protocol LocalLLMProvider: AnyObject { + /// The type of provider + var providerType: LocalLLMProviderType { get } + + /// Current configuration + var configuration: LocalLLMConfiguration { get set } + + /// Current connection status + var connectionStatus: LocalLLMConnectionStatus { get } + + /// Publisher for connection status changes + var connectionStatusPublisher: AnyPublisher { get } + + /// Available models + var availableModels: [LocalLLMModel] { get } + + /// Publisher for available models changes + var availableModelsPublisher: AnyPublisher<[LocalLLMModel], Never> { get } + + /// Check if the provider is reachable + func checkConnection() async -> Bool + + /// Fetch available models from the provider + func fetchModels() async throws -> [LocalLLMModel] + + /// Send a chat request and receive streaming responses + func chat(request: LocalLLMChatRequest) -> AnyPublisher + + /// Send a chat request and receive the complete response (non-streaming) + func chatComplete(request: LocalLLMChatRequest) async throws -> String + + /// Cancel any ongoing requests + func cancelAllRequests() +} + +// MARK: - Base Implementation + +/// Base class providing common functionality for local LLM providers +open class BaseLocalLLMProvider: LocalLLMProvider { + // MARK: - Properties + + public let providerType: LocalLLMProviderType + public var configuration: LocalLLMConfiguration + + @Published public private(set) var connectionStatus: LocalLLMConnectionStatus = .disconnected + public var connectionStatusPublisher: AnyPublisher { + $connectionStatus.eraseToAnyPublisher() + } + + @Published public private(set) var availableModels: [LocalLLMModel] = [] + public var availableModelsPublisher: AnyPublisher<[LocalLLMModel], Never> { + $availableModels.eraseToAnyPublisher() + } + + internal var cancellables = Set() + internal var currentTask: Task? + + // MARK: - Initialization + + public init(providerType: LocalLLMProviderType, configuration: LocalLLMConfiguration? = nil) { + self.providerType = providerType + self.configuration = configuration ?? LocalLLMConfiguration( + host: providerType.defaultHost, + port: providerType.defaultPort + ) + } + + // MARK: - Connection + + open func checkConnection() async -> Bool { + updateConnectionStatus(.connecting) + + let url = configuration.baseURL + var request = URLRequest(url: url) + request.timeoutInterval = 5.0 + + do { + let (_, response) = try await URLSession.shared.data(for: request) + if let httpResponse = response as? HTTPURLResponse, + (200 ... 299).contains(httpResponse.statusCode) + { + updateConnectionStatus(.connected) + return true + } + } catch { + updateConnectionStatus(.error(error.localizedDescription)) + } + + updateConnectionStatus(.disconnected) + return false + } + + internal func updateConnectionStatus(_ status: LocalLLMConnectionStatus) { + Task { @MainActor in + self.connectionStatus = status + } + } + + internal func updateAvailableModels(_ models: [LocalLLMModel]) { + Task { @MainActor in + self.availableModels = models + } + } + + // MARK: - Abstract Methods (to be overridden) + + open func fetchModels() async throws -> [LocalLLMModel] { + fatalError("Subclasses must implement fetchModels()") + } + + open func chat(request: LocalLLMChatRequest) -> AnyPublisher { + fatalError("Subclasses must implement chat(request:)") + } + + open func chatComplete(request: LocalLLMChatRequest) async throws -> String { + var result = "" + + return try await withCheckedThrowingContinuation { continuation in + var receivedCompletion = false + + chat(request: LocalLLMChatRequest( + model: request.model, + messages: request.messages, + options: request.options, + stream: false + )) + .sink( + receiveCompletion: { completion in + guard !receivedCompletion else { return } + receivedCompletion = true + + switch completion { + case .finished: + continuation.resume(returning: result) + case let .failure(error): + continuation.resume(throwing: error) + } + }, + receiveValue: { chunk in + result += chunk.content + } + ) + .store(in: &cancellables) + } + } + + open func cancelAllRequests() { + currentTask?.cancel() + cancellables.removeAll() + } +} diff --git a/Sources/LocalLLM/Providers/OllamaProvider.swift b/Sources/LocalLLM/Providers/OllamaProvider.swift new file mode 100644 index 0000000..ad6f8db --- /dev/null +++ b/Sources/LocalLLM/Providers/OllamaProvider.swift @@ -0,0 +1,374 @@ +// +// OllamaProvider.swift +// Invisibility +// +// Ollama provider implementation using OllamaKit +// + +import Alamofire +import Combine +import Foundation +import os + +// Note: Import OllamaKit when integrating with the main project +// import OllamaKit + +/// Provider for Ollama - local LLM server +public final class OllamaProvider: BaseLocalLLMProvider { + // MARK: - Properties + + private let logger = Logger(subsystem: "Invisibility", category: "OllamaProvider") + private let session: Session + private var streamRequest: DataStreamRequest? + + // MARK: - Initialization + + public init(configuration: LocalLLMConfiguration? = nil) { + let sessionConfig = URLSessionConfiguration.default + sessionConfig.timeoutIntervalForRequest = 300 // 5 minutes for long generations + sessionConfig.timeoutIntervalForResource = 600 + self.session = Session(configuration: sessionConfig) + + super.init(providerType: .ollama, configuration: configuration) + } + + // MARK: - Connection + + public override func checkConnection() async -> Bool { + updateConnectionStatus(.connecting) + + let url = configuration.baseURL + logger.debug("Checking Ollama connection at \(url.absoluteString)") + + do { + let request = session.request(url).validate() + let response = try await request.serializingData().value + _ = response + + updateConnectionStatus(.connected) + logger.info("Ollama is reachable") + + // Fetch models on successful connection + Task { + _ = try? await fetchModels() + } + + return true + } catch { + logger.error("Ollama connection check failed: \(error.localizedDescription)") + updateConnectionStatus(.error(error.localizedDescription)) + return false + } + } + + // MARK: - Models + + public override func fetchModels() async throws -> [LocalLLMModel] { + let url = configuration.baseURL.appendingPathComponent("api/tags") + logger.debug("Fetching Ollama models from \(url.absoluteString)") + + do { + let request = session.request(url).validate() + let response = try await request.serializingDecodable(OllamaModelsResponse.self).value + + let models = response.models.map { model in + LocalLLMModel( + id: model.name, + name: model.name, + provider: .ollama, + size: model.size, + modifiedAt: model.modifiedAt, + digest: model.digest, + details: LocalLLMModelDetails( + family: model.details?.family, + families: model.details?.families, + parameterSize: model.details?.parameterSize, + quantizationLevel: model.details?.quantizationLevel + ) + ) + } + + updateAvailableModels(models) + logger.info("Fetched \(models.count) Ollama models") + return models + } catch { + logger.error("Failed to fetch Ollama models: \(error.localizedDescription)") + throw LocalLLMError.connectionFailed(error.localizedDescription) + } + } + + // MARK: - Chat + + public override func chat(request: LocalLLMChatRequest) -> AnyPublisher { + let subject = PassthroughSubject() + + let url = configuration.baseURL.appendingPathComponent("api/chat") + logger.debug("Starting Ollama chat with model: \(request.model)") + + // Build request body + let body = OllamaChatRequest( + model: request.model, + messages: request.messages.map { msg in + OllamaChatMessage( + role: msg.role.rawValue, + content: msg.content, + images: msg.images + ) + }, + stream: request.stream, + options: OllamaOptions( + temperature: request.options.temperature, + topP: request.options.topP, + topK: request.options.topK, + numPredict: request.options.maxTokens, + repeatPenalty: request.options.repeatPenalty, + seed: request.options.seed, + stop: request.options.stop?.first, + numCtx: request.options.numCtx + ) + ) + + do { + let encoder = JSONEncoder() + let bodyData = try encoder.encode(body) + + var urlRequest = URLRequest(url: url) + urlRequest.httpMethod = "POST" + urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") + urlRequest.httpBody = bodyData + urlRequest.timeoutInterval = 300 + + if request.stream { + // Streaming request + streamRequest = session.streamRequest(urlRequest).validate() + + var buffer = Data() + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + + streamRequest?.responseStream { [weak self] stream in + switch stream.event { + case let .stream(result): + switch result { + case let .success(data): + buffer.append(data) + + // Parse JSON objects from buffer + while let jsonData = self?.extractNextJSON(from: &buffer) { + do { + let response = try decoder.decode(OllamaChatResponse.self, from: jsonData) + let chunk = LocalLLMChatResponseChunk( + content: response.message?.content ?? "", + done: response.done, + model: response.model, + totalDuration: response.totalDuration, + evalCount: response.evalCount + ) + subject.send(chunk) + + if response.done { + self?.logger.debug("Ollama stream completed") + } + } catch { + self?.logger.error("Failed to decode Ollama response: \(error.localizedDescription)") + } + } + + case let .failure(error): + self?.logger.error("Ollama stream error: \(error.localizedDescription)") + subject.send(completion: .failure(LocalLLMError.streamingError(error.localizedDescription))) + } + + case .complete: + subject.send(completion: .finished) + } + } + } else { + // Non-streaming request + Task { + do { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + + let dataRequest = self.session.request(urlRequest).validate() + let response = try await dataRequest.serializingDecodable( + OllamaChatResponse.self, + decoder: decoder + ).value + + let chunk = LocalLLMChatResponseChunk( + content: response.message?.content ?? "", + done: true, + model: response.model, + totalDuration: response.totalDuration, + evalCount: response.evalCount + ) + subject.send(chunk) + subject.send(completion: .finished) + } catch { + self.logger.error("Ollama request failed: \(error.localizedDescription)") + subject.send(completion: .failure(error)) + } + } + } + } catch { + logger.error("Failed to encode Ollama request: \(error.localizedDescription)") + subject.send(completion: .failure(error)) + } + + return subject.eraseToAnyPublisher() + } + + public override func cancelAllRequests() { + streamRequest?.cancel() + streamRequest = nil + super.cancelAllRequests() + } + + // MARK: - Helpers + + /// Extract next complete JSON object from buffer + private func extractNextJSON(from buffer: inout Data) -> Data? { + var depth = 0 + var isInsideString = false + var isEscape = false + var startIndex: Int? + + for (index, byte) in buffer.enumerated() { + let character = Character(UnicodeScalar(byte)) + + if isEscape { + isEscape = false + } else if character == "\\" { + isEscape = true + } else if character == "\"" { + isInsideString.toggle() + } else if !isInsideString { + switch character { + case "{": + if depth == 0 { + startIndex = index + } + depth += 1 + case "}": + depth -= 1 + if depth == 0, let start = startIndex { + let range = start ..< buffer.index(after: index) + let jsonData = buffer.subdata(in: range) + buffer.removeSubrange(range) + return jsonData + } + default: + break + } + } + } + + return nil + } +} + +// MARK: - Ollama API Models + +/// Response from Ollama /api/tags endpoint +private struct OllamaModelsResponse: Decodable { + let models: [OllamaModelInfo] +} + +private struct OllamaModelInfo: Decodable { + let name: String + let size: Int64? + let digest: String? + let modifiedAt: Date? + let details: OllamaModelDetails? + + enum CodingKeys: String, CodingKey { + case name + case size + case digest + case modifiedAt = "modified_at" + case details + } +} + +private struct OllamaModelDetails: Decodable { + let family: String? + let families: [String]? + let parameterSize: String? + let quantizationLevel: String? + + enum CodingKeys: String, CodingKey { + case family + case families + case parameterSize = "parameter_size" + case quantizationLevel = "quantization_level" + } +} + +/// Request body for Ollama /api/chat +private struct OllamaChatRequest: Encodable { + let model: String + let messages: [OllamaChatMessage] + let stream: Bool + let options: OllamaOptions? +} + +private struct OllamaChatMessage: Encodable { + let role: String + let content: String + let images: [String]? +} + +private struct OllamaOptions: Encodable { + let temperature: Double? + let topP: Double? + let topK: Int? + let numPredict: Int? + let repeatPenalty: Double? + let seed: Int? + let stop: String? + let numCtx: Int? + + enum CodingKeys: String, CodingKey { + case temperature + case topP = "top_p" + case topK = "top_k" + case numPredict = "num_predict" + case repeatPenalty = "repeat_penalty" + case seed + case stop + case numCtx = "num_ctx" + } +} + +/// Response from Ollama /api/chat +private struct OllamaChatResponse: Decodable { + let model: String + let createdAt: Date? + let message: OllamaChatResponseMessage? + let done: Bool + let totalDuration: Int? + let loadDuration: Int? + let promptEvalCount: Int? + let promptEvalDuration: Int? + let evalCount: Int? + let evalDuration: Int? + + enum CodingKeys: String, CodingKey { + case model + case createdAt = "created_at" + case message + case done + case totalDuration = "total_duration" + case loadDuration = "load_duration" + case promptEvalCount = "prompt_eval_count" + case promptEvalDuration = "prompt_eval_duration" + case evalCount = "eval_count" + case evalDuration = "eval_duration" + } +} + +private struct OllamaChatResponseMessage: Decodable { + let role: String + let content: String +} diff --git a/Sources/LocalLLM/README.md b/Sources/LocalLLM/README.md new file mode 100644 index 0000000..55de353 --- /dev/null +++ b/Sources/LocalLLM/README.md @@ -0,0 +1,341 @@ +# Local LLM Support for Invisibility + +This module adds support for local Large Language Models using **Ollama** and **LM Studio**. + +## Features + +- **Ollama Support**: Connect to Ollama running locally (default: `localhost:11434`) +- **LM Studio Support**: Connect to LM Studio's local server (default: `localhost:1234`) +- **Streaming Responses**: Real-time streaming of model responses +- **Model Discovery**: Automatically fetch available models from connected servers +- **Unified Interface**: Single API for both providers +- **Settings Persistence**: Configuration saved across app launches +- **SwiftUI Components**: Ready-to-use settings and chat UI + +## Architecture + +``` +Sources/LocalLLM/ +├── Models/ +│ └── LocalLLMModels.swift # Data models, enums, errors +├── Providers/ +│ ├── LocalLLMProvider.swift # Base protocol and abstract class +│ ├── OllamaProvider.swift # Ollama implementation +│ └── LMStudioProvider.swift # LM Studio implementation +├── UI/ +│ ├── LocalLLMSettingsView.swift # Settings UI +│ └── LocalLLMChatView.swift # Example chat UI +├── LocalLLMManager.swift # Central manager singleton +└── README.md # This file +``` + +## Quick Start + +### 1. Add Dependencies + +Add to your `Package.swift` or Xcode project: + +```swift +dependencies: [ + .package(url: "https://github.com/Alamofire/Alamofire.git", from: "5.0.0"), +] +``` + +### 2. Enable a Provider + +```swift +import LocalLLM + +// Enable Ollama (run `ollama serve` first) +LocalLLMManager.shared.enableOllama() + +// Or enable LM Studio (start the server in LM Studio first) +LocalLLMManager.shared.enableLMStudio() +``` + +### 3. Select a Model + +```swift +// After connecting, select a model +if let firstModel = LocalLLMManager.shared.allAvailableModels.first { + LocalLLMManager.shared.selectedModelId = firstModel.id +} +``` + +### 4. Send Chat Messages + +```swift +import Combine + +var cancellables = Set() + +let messages = [ + LocalLLMMessage(role: .user, content: "Hello! What is 2 + 2?") +] + +LocalLLMManager.shared.chat(messages: messages) + .sink( + receiveCompletion: { completion in + switch completion { + case .finished: + print("Stream completed") + case .failure(let error): + print("Error: \(error)") + } + }, + receiveValue: { chunk in + print(chunk.content, terminator: "") + } + ) + .store(in: &cancellables) +``` + +## Integration Guide + +### Adding to Settings + +Add the settings view to your existing settings: + +```swift +import SwiftUI + +struct SettingsView: View { + var body: some View { + TabView { + // ... other settings tabs + + LocalLLMSettingsView() + .tabItem { + Label("Local LLM", systemImage: "desktopcomputer") + } + } + } +} +``` + +### Adding Model Selector to Chat + +Use the compact model picker in your chat interface: + +```swift +struct ChatInputView: View { + var body: some View { + HStack { + // Model selector + LocalLLMModelPicker() + + // Your input field + TextField("Message...", text: $message) + + // Send button + Button("Send", action: sendMessage) + } + } +} +``` + +### Integrating with Existing Chat ViewModel + +```swift +class ChatViewModel: ObservableObject { + @Published var messages: [Message] = [] + @Published var isGenerating = false + + private var cancellables = Set() + private let localLLMManager = LocalLLMManager.shared + + func sendMessage(_ content: String) { + // Check if local LLM is available and selected + if localLLMManager.isLocalLLMAvailable { + sendToLocalLLM(content) + } else { + sendToCloudProvider(content) + } + } + + private func sendToLocalLLM(_ content: String) { + let llmMessages = messages.map { msg in + LocalLLMMessage( + role: msg.isUser ? .user : .assistant, + content: msg.content + ) + } + [LocalLLMMessage(role: .user, content: content)] + + isGenerating = true + var responseText = "" + + localLLMManager.chat(messages: llmMessages) + .receive(on: DispatchQueue.main) + .sink( + receiveCompletion: { [weak self] completion in + self?.isGenerating = false + if case .failure(let error) = completion { + // Handle error + } + }, + receiveValue: { [weak self] chunk in + responseText += chunk.content + // Update UI with streaming response + self?.updateAssistantMessage(responseText) + } + ) + .store(in: &cancellables) + } +} +``` + +## Provider Configuration + +### Ollama + +1. Install Ollama from [ollama.ai](https://ollama.ai) +2. Run `ollama serve` to start the server +3. Pull models: `ollama pull llama2` or `ollama pull mistral` +4. Default endpoint: `http://localhost:11434` + +**Supported features:** +- Chat completions with streaming +- Model listing +- Vision/image support (for multimodal models) +- Custom generation parameters + +### LM Studio + +1. Download LM Studio from [lmstudio.ai](https://lmstudio.ai) +2. Download a model in LM Studio +3. Start the local server (menu: Local Server → Start Server) +4. Default endpoint: `http://localhost:1234` + +**Supported features:** +- OpenAI-compatible chat completions +- Streaming responses +- Model listing + +## API Reference + +### LocalLLMManager + +The central singleton for managing local LLM providers. + +```swift +// Access shared instance +let manager = LocalLLMManager.shared + +// Properties +manager.ollamaProvider // OllamaProvider instance +manager.lmStudioProvider // LMStudioProvider instance +manager.activeProviderType // Currently active provider +manager.selectedModelId // Currently selected model +manager.allAvailableModels // All models from enabled providers +manager.isAnyProviderConnected // Connection status +manager.isLocalLLMAvailable // Ready to use + +// Methods +await manager.refreshConnections() +manager.updateOllamaConfiguration(config) +manager.updateLMStudioConfiguration(config) +manager.setProviderEnabled(.ollama, enabled: true) +manager.chat(messages: messages) +manager.cancelAllRequests() +``` + +### LocalLLMProvider Protocol + +Protocol implemented by all providers: + +```swift +protocol LocalLLMProvider { + var providerType: LocalLLMProviderType { get } + var configuration: LocalLLMConfiguration { get set } + var connectionStatus: LocalLLMConnectionStatus { get } + var availableModels: [LocalLLMModel] { get } + + func checkConnection() async -> Bool + func fetchModels() async throws -> [LocalLLMModel] + func chat(request: LocalLLMChatRequest) -> AnyPublisher + func chatComplete(request: LocalLLMChatRequest) async throws -> String + func cancelAllRequests() +} +``` + +### Data Models + +```swift +// Chat message +LocalLLMMessage( + role: .user, // .system, .user, .assistant + content: "Hello!", + images: nil // Optional base64 images for vision models +) + +// Generation options +LocalLLMOptions( + temperature: 0.7, + topP: 0.9, + topK: 40, + maxTokens: nil, + repeatPenalty: 1.1, + seed: nil, + stop: nil, + numCtx: 4096 +) + +// Configuration +LocalLLMConfiguration( + host: "localhost", + port: 11434, + isEnabled: true, + selectedModel: "llama2" +) +``` + +## Error Handling + +```swift +enum LocalLLMError: LocalizedError { + case notConnected // Provider not connected + case connectionFailed(String) + case modelNotFound(String) + case streamingError(String) + case invalidResponse + case serverError(Int, String?) + case timeout + case cancelled +} +``` + +## Best Practices + +1. **Check availability before sending**: Always check `isLocalLLMAvailable` before sending messages + +2. **Handle streaming properly**: Use Combine's `sink` to handle both completion and values + +3. **Cancel requests**: Call `cancelAllRequests()` when leaving a chat view + +4. **Refresh connections**: Call `refreshConnections()` when the app becomes active + +5. **Error handling**: Always handle errors gracefully and show user-friendly messages + +## Troubleshooting + +### "Not connected to local LLM server" +- Ensure Ollama or LM Studio is running +- Check the host and port settings +- Try refreshing connections + +### "Model not found" +- The selected model may have been removed +- Refresh models list and select a new model + +### Slow responses +- Local LLM performance depends on your hardware +- Try smaller models (e.g., 7B instead of 13B) +- Ensure you have enough RAM/VRAM + +### Connection timeout +- Increase timeout in provider configuration +- Check if the model is loaded (first request may be slow) + +## License + +This module is part of Invisibility and follows the same license terms. diff --git a/Sources/LocalLLM/UI/LocalLLMChatView.swift b/Sources/LocalLLM/UI/LocalLLMChatView.swift new file mode 100644 index 0000000..deeef02 --- /dev/null +++ b/Sources/LocalLLM/UI/LocalLLMChatView.swift @@ -0,0 +1,332 @@ +// +// LocalLLMChatView.swift +// Invisibility +// +// Example chat view demonstrating local LLM integration +// + +import Combine +import SwiftUI + +/// View model for local LLM chat +@MainActor +public final class LocalLLMChatViewModel: ObservableObject { + // MARK: - Properties + + @Published public var messages: [ChatDisplayMessage] = [] + @Published public var inputText: String = "" + @Published public var isGenerating: Bool = false + @Published public var errorMessage: String? + + private var cancellables = Set() + private var currentStreamCancellable: AnyCancellable? + + private let manager = LocalLLMManager.shared + + // MARK: - Initialization + + public init() {} + + // MARK: - Chat Actions + + /// Send a message and get a response from the local LLM + public func sendMessage() { + guard !inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } + guard !isGenerating else { return } + + let userMessage = inputText + inputText = "" + errorMessage = nil + + // Add user message to display + messages.append(ChatDisplayMessage(role: .user, content: userMessage)) + + // Prepare messages for LLM + let llmMessages = messages.map { msg in + LocalLLMMessage( + role: msg.role == .user ? .user : (msg.role == .assistant ? .assistant : .system), + content: msg.content + ) + } + + // Add placeholder for assistant response + let assistantIndex = messages.count + messages.append(ChatDisplayMessage(role: .assistant, content: "")) + + isGenerating = true + + // Start streaming response + currentStreamCancellable = manager.chat(messages: llmMessages) + .receive(on: DispatchQueue.main) + .sink( + receiveCompletion: { [weak self] completion in + self?.isGenerating = false + + switch completion { + case .finished: + break + case let .failure(error): + self?.errorMessage = error.localizedDescription + // Remove empty assistant message on error + if self?.messages[assistantIndex].content.isEmpty == true { + self?.messages.remove(at: assistantIndex) + } + } + }, + receiveValue: { [weak self] chunk in + guard let self = self else { return } + + // Append content to assistant message + if assistantIndex < self.messages.count { + self.messages[assistantIndex].content += chunk.content + } + } + ) + } + + /// Stop the current generation + public func stopGeneration() { + currentStreamCancellable?.cancel() + currentStreamCancellable = nil + manager.cancelAllRequests() + isGenerating = false + } + + /// Clear the chat history + public func clearChat() { + stopGeneration() + messages.removeAll() + errorMessage = nil + } + + /// Add a system message + public func setSystemPrompt(_ prompt: String) { + // Remove existing system message if any + messages.removeAll { $0.role == .system } + + // Add new system message at the beginning + if !prompt.isEmpty { + messages.insert(ChatDisplayMessage(role: .system, content: prompt), at: 0) + } + } +} + +/// Display message for chat UI +public struct ChatDisplayMessage: Identifiable { + public let id = UUID() + public var role: ChatRole + public var content: String + + public enum ChatRole { + case system + case user + case assistant + } +} + +// MARK: - Chat View + +/// Example chat view using local LLM +public struct LocalLLMChatView: View { + @StateObject private var viewModel = LocalLLMChatViewModel() + @StateObject private var manager = LocalLLMManager.shared + + public init() {} + + public var body: some View { + VStack(spacing: 0) { + // Header + headerView + + Divider() + + // Messages + ScrollViewReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: 12) { + ForEach(viewModel.messages) { message in + messageView(message) + } + + // Error message + if let error = viewModel.errorMessage { + Text(error) + .font(.caption) + .foregroundColor(.red) + .padding() + } + } + .padding() + } + .onChange(of: viewModel.messages.count) { _, _ in + if let lastMessage = viewModel.messages.last { + withAnimation { + proxy.scrollTo(lastMessage.id, anchor: .bottom) + } + } + } + } + + Divider() + + // Input area + inputView + } + } + + // MARK: - Header + + @ViewBuilder + private var headerView: some View { + HStack { + LocalLLMStatusBadge() + + Spacer() + + LocalLLMModelPicker() + + Spacer() + + Button(action: viewModel.clearChat) { + Image(systemName: "trash") + .font(.caption) + } + .buttonStyle(.borderless) + .help("Clear chat") + } + .padding(.horizontal) + .padding(.vertical, 8) + .background(Color(NSColor.windowBackgroundColor)) + } + + // MARK: - Message View + + @ViewBuilder + private func messageView(_ message: ChatDisplayMessage) -> some View { + HStack(alignment: .top, spacing: 8) { + // Avatar + Circle() + .fill(avatarColor(for: message.role)) + .frame(width: 24, height: 24) + .overlay( + Image(systemName: avatarIcon(for: message.role)) + .font(.caption) + .foregroundColor(.white) + ) + + // Content + VStack(alignment: .leading, spacing: 4) { + Text(roleName(for: message.role)) + .font(.caption) + .fontWeight(.semibold) + .foregroundColor(.secondary) + + if message.content.isEmpty && message.role == .assistant { + // Loading indicator + HStack(spacing: 4) { + ForEach(0 ..< 3) { i in + Circle() + .fill(Color.secondary) + .frame(width: 6, height: 6) + .opacity(0.5) + } + } + } else { + Text(message.content) + .textSelection(.enabled) + } + } + + Spacer() + } + .padding(8) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(backgroundColor(for: message.role)) + ) + .id(message.id) + } + + // MARK: - Input View + + @ViewBuilder + private var inputView: some View { + HStack(spacing: 8) { + TextField("Message...", text: $viewModel.inputText, axis: .vertical) + .textFieldStyle(.plain) + .lineLimit(1 ... 5) + .onSubmit { + if !viewModel.isGenerating { + viewModel.sendMessage() + } + } + + if viewModel.isGenerating { + Button(action: viewModel.stopGeneration) { + Image(systemName: "stop.fill") + .foregroundColor(.red) + } + .buttonStyle(.borderless) + .help("Stop generation") + } else { + Button(action: viewModel.sendMessage) { + Image(systemName: "arrow.up.circle.fill") + .font(.title2) + .foregroundColor( + viewModel.inputText.isEmpty ? .secondary : .accentColor + ) + } + .buttonStyle(.borderless) + .disabled(viewModel.inputText.isEmpty || !manager.isLocalLLMAvailable) + .help("Send message") + } + } + .padding() + .background(Color(NSColor.controlBackgroundColor)) + } + + // MARK: - Helpers + + private func avatarColor(for role: ChatDisplayMessage.ChatRole) -> Color { + switch role { + case .system: return .orange + case .user: return .blue + case .assistant: return .green + } + } + + private func avatarIcon(for role: ChatDisplayMessage.ChatRole) -> String { + switch role { + case .system: return "gearshape" + case .user: return "person" + case .assistant: return "cpu" + } + } + + private func roleName(for role: ChatDisplayMessage.ChatRole) -> String { + switch role { + case .system: return "System" + case .user: return "You" + case .assistant: return manager.selectedModel?.name ?? "Assistant" + } + } + + private func backgroundColor(for role: ChatDisplayMessage.ChatRole) -> Color { + switch role { + case .system: return Color.orange.opacity(0.1) + case .user: return Color.blue.opacity(0.1) + case .assistant: return Color.green.opacity(0.1) + } + } +} + +// MARK: - Preview + +#if DEBUG + struct LocalLLMChatView_Previews: PreviewProvider { + static var previews: some View { + LocalLLMChatView() + .frame(width: 400, height: 600) + } + } +#endif diff --git a/Sources/LocalLLM/UI/LocalLLMSettingsView.swift b/Sources/LocalLLM/UI/LocalLLMSettingsView.swift new file mode 100644 index 0000000..1b77c7d --- /dev/null +++ b/Sources/LocalLLM/UI/LocalLLMSettingsView.swift @@ -0,0 +1,409 @@ +// +// LocalLLMSettingsView.swift +// Invisibility +// +// SwiftUI settings view for local LLM configuration +// + +import SwiftUI + +/// Settings view for configuring local LLM providers +public struct LocalLLMSettingsView: View { + @StateObject private var manager = LocalLLMManager.shared + + @State private var ollamaHost: String = "localhost" + @State private var ollamaPort: String = "11434" + @State private var ollamaEnabled: Bool = false + + @State private var lmStudioHost: String = "localhost" + @State private var lmStudioPort: String = "1234" + @State private var lmStudioEnabled: Bool = false + + @State private var isRefreshing: Bool = false + + public init() {} + + public var body: some View { + Form { + // Header + Section { + VStack(alignment: .leading, spacing: 8) { + HStack { + Image(systemName: "desktopcomputer") + .font(.title2) + .foregroundColor(.accentColor) + Text("Local LLM") + .font(.headline) + } + + Text("Run AI models locally on your machine using Ollama or LM Studio. No internet connection or API keys required.") + .font(.caption) + .foregroundColor(.secondary) + } + .padding(.vertical, 4) + } + + // Ollama Section + Section { + ollamaSettingsView + } header: { + HStack { + Image(systemName: "server.rack") + Text("Ollama") + } + } footer: { + Text("Default: localhost:11434. Install Ollama from ollama.ai") + } + + // LM Studio Section + Section { + lmStudioSettingsView + } header: { + HStack { + Image(systemName: "desktopcomputer") + Text("LM Studio") + } + } footer: { + Text("Default: localhost:1234. Start the local server in LM Studio first.") + } + + // Model Selection + if !manager.allAvailableModels.isEmpty { + Section { + modelSelectionView + } header: { + Text("Model Selection") + } + } + + // Actions + Section { + Button(action: refreshConnections) { + HStack { + if isRefreshing { + ProgressView() + .scaleEffect(0.8) + } else { + Image(systemName: "arrow.clockwise") + } + Text("Refresh Connections") + } + } + .disabled(isRefreshing) + } + } + .formStyle(.grouped) + .onAppear { + loadCurrentSettings() + } + } + + // MARK: - Ollama Settings + + @ViewBuilder + private var ollamaSettingsView: some View { + Toggle(isOn: $ollamaEnabled) { + Text("Enable Ollama") + } + .onChange(of: ollamaEnabled) { _, newValue in + updateOllamaConfig() + } + + if ollamaEnabled { + HStack { + Text("Host") + .frame(width: 50, alignment: .leading) + TextField("localhost", text: $ollamaHost) + .textFieldStyle(.roundedBorder) + .onChange(of: ollamaHost) { _, _ in + updateOllamaConfig() + } + } + + HStack { + Text("Port") + .frame(width: 50, alignment: .leading) + TextField("11434", text: $ollamaPort) + .textFieldStyle(.roundedBorder) + .frame(width: 80) + .onChange(of: ollamaPort) { _, _ in + updateOllamaConfig() + } + Spacer() + } + + // Connection status + connectionStatusRow( + status: manager.ollamaProvider.connectionStatus, + modelCount: manager.ollamaProvider.availableModels.count + ) + } + } + + // MARK: - LM Studio Settings + + @ViewBuilder + private var lmStudioSettingsView: some View { + Toggle(isOn: $lmStudioEnabled) { + Text("Enable LM Studio") + } + .onChange(of: lmStudioEnabled) { _, newValue in + updateLMStudioConfig() + } + + if lmStudioEnabled { + HStack { + Text("Host") + .frame(width: 50, alignment: .leading) + TextField("localhost", text: $lmStudioHost) + .textFieldStyle(.roundedBorder) + .onChange(of: lmStudioHost) { _, _ in + updateLMStudioConfig() + } + } + + HStack { + Text("Port") + .frame(width: 50, alignment: .leading) + TextField("1234", text: $lmStudioPort) + .textFieldStyle(.roundedBorder) + .frame(width: 80) + .onChange(of: lmStudioPort) { _, _ in + updateLMStudioConfig() + } + Spacer() + } + + // Connection status + connectionStatusRow( + status: manager.lmStudioProvider.connectionStatus, + modelCount: manager.lmStudioProvider.availableModels.count + ) + } + } + + // MARK: - Model Selection + + @ViewBuilder + private var modelSelectionView: some View { + Picker("Active Model", selection: $manager.selectedModelId) { + Text("None").tag(nil as String?) + + if !manager.ollamaProvider.availableModels.isEmpty { + Section(header: Text("Ollama")) { + ForEach(manager.ollamaProvider.availableModels) { model in + modelRow(model).tag(model.id as String?) + } + } + } + + if !manager.lmStudioProvider.availableModels.isEmpty { + Section(header: Text("LM Studio")) { + ForEach(manager.lmStudioProvider.availableModels) { model in + modelRow(model).tag(model.id as String?) + } + } + } + } + .onChange(of: manager.selectedModelId) { _, newValue in + if let modelId = newValue, + let model = manager.allAvailableModels.first(where: { $0.id == modelId }) + { + manager.activeProviderType = model.provider + } + } + + if let model = manager.selectedModel { + VStack(alignment: .leading, spacing: 4) { + Text("Selected: \(model.name)") + .font(.caption) + .foregroundColor(.primary) + + if let details = model.details { + if let paramSize = details.parameterSize { + Text("Parameters: \(paramSize)") + .font(.caption2) + .foregroundColor(.secondary) + } + if let quant = details.quantizationLevel { + Text("Quantization: \(quant)") + .font(.caption2) + .foregroundColor(.secondary) + } + } + + Text("Size: \(model.sizeString)") + .font(.caption2) + .foregroundColor(.secondary) + } + .padding(.vertical, 4) + } + } + + // MARK: - Helper Views + + @ViewBuilder + private func connectionStatusRow(status: LocalLLMConnectionStatus, modelCount: Int) -> some View { + HStack { + Circle() + .fill(statusColor(for: status)) + .frame(width: 8, height: 8) + + Text(status.statusText) + .font(.caption) + .foregroundColor(.secondary) + + Spacer() + + if status.isConnected { + Text("\(modelCount) models") + .font(.caption) + .foregroundColor(.secondary) + } + } + } + + @ViewBuilder + private func modelRow(_ model: LocalLLMModel) -> some View { + HStack { + Text(model.name) + Spacer() + Text(model.sizeString) + .font(.caption) + .foregroundColor(.secondary) + } + } + + private func statusColor(for status: LocalLLMConnectionStatus) -> Color { + switch status { + case .disconnected: return .gray + case .connecting: return .yellow + case .connected: return .green + case .error: return .red + } + } + + // MARK: - Actions + + private func loadCurrentSettings() { + let ollamaConfig = manager.ollamaProvider.configuration + ollamaHost = ollamaConfig.host + ollamaPort = String(ollamaConfig.port) + ollamaEnabled = ollamaConfig.isEnabled + + let lmStudioConfig = manager.lmStudioProvider.configuration + lmStudioHost = lmStudioConfig.host + lmStudioPort = String(lmStudioConfig.port) + lmStudioEnabled = lmStudioConfig.isEnabled + } + + private func updateOllamaConfig() { + let config = LocalLLMConfiguration( + host: ollamaHost.isEmpty ? "localhost" : ollamaHost, + port: Int(ollamaPort) ?? 11434, + isEnabled: ollamaEnabled + ) + manager.updateOllamaConfiguration(config) + } + + private func updateLMStudioConfig() { + let config = LocalLLMConfiguration( + host: lmStudioHost.isEmpty ? "localhost" : lmStudioHost, + port: Int(lmStudioPort) ?? 1234, + isEnabled: lmStudioEnabled + ) + manager.updateLMStudioConfiguration(config) + } + + private func refreshConnections() { + isRefreshing = true + Task { + await manager.refreshConnections() + await MainActor.run { + isRefreshing = false + } + } + } +} + +// MARK: - Model Picker View + +/// A compact model picker for use in chat interface +public struct LocalLLMModelPicker: View { + @StateObject private var manager = LocalLLMManager.shared + + public init() {} + + public var body: some View { + if manager.isAnyProviderConnected { + Menu { + ForEach(manager.allAvailableModels) { model in + Button(action: { + manager.selectedModelId = model.id + manager.activeProviderType = model.provider + }) { + HStack { + Text(model.name) + if model.id == manager.selectedModelId { + Image(systemName: "checkmark") + } + } + } + } + } label: { + HStack(spacing: 4) { + Image(systemName: "desktopcomputer") + .font(.caption) + + if let model = manager.selectedModel { + Text(model.name) + .font(.caption) + .lineLimit(1) + } else { + Text("Local") + .font(.caption) + } + + Image(systemName: "chevron.down") + .font(.caption2) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.accentColor.opacity(0.1)) + .cornerRadius(6) + } + } + } +} + +// MARK: - Connection Status Badge + +/// A small badge showing local LLM connection status +public struct LocalLLMStatusBadge: View { + @StateObject private var manager = LocalLLMManager.shared + + public init() {} + + public var body: some View { + HStack(spacing: 4) { + Circle() + .fill(manager.isAnyProviderConnected ? Color.green : Color.gray) + .frame(width: 6, height: 6) + + Text(manager.isAnyProviderConnected ? "Local LLM" : "Offline") + .font(.caption2) + .foregroundColor(.secondary) + } + } +} + +// MARK: - Preview + +#if DEBUG + struct LocalLLMSettingsView_Previews: PreviewProvider { + static var previews: some View { + LocalLLMSettingsView() + .frame(width: 400, height: 600) + } + } +#endif