diff --git a/10x-macos/Config.swift b/10x-macos/Config.swift index 271a26e..3b9844b 100644 --- a/10x-macos/Config.swift +++ b/10x-macos/Config.swift @@ -59,6 +59,20 @@ enum Config { ?? "https://downloads.example.invalid/appcast.xml" } + static var sparklePublicEdKey: String { + ProcessInfo.processInfo.environment["SPARKLE_PUBLIC_ED_KEY"] + ?? Bundle.main.infoDictionary?["SUPublicEDKey"] as? String + ?? "" + } + + static var sparkleUpdatesConfigured: Bool { + let feed = sparkleFeedURL.trimmingCharacters(in: .whitespacesAndNewlines) + let key = sparklePublicEdKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !isPlaceholderValue(feed), !isPlaceholderValue(key) else { return false } + guard let feedURL = URL(string: feed), feedURL.host?.contains("example.invalid") != true else { return false } + return !key.isEmpty + } + static var defaultUpdateChannel: AppUpdateChannel { AppUpdateChannel.defaultChannel() } @@ -86,6 +100,14 @@ enum Config { ?? "sb_publishable_your_key" } + static var supabaseConfigured: Bool { + let url = supabaseURL.trimmingCharacters(in: .whitespacesAndNewlines) + let key = supabaseAnonKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !isPlaceholderValue(url), !isPlaceholderValue(key) else { return false } + guard let parsed = URL(string: url), parsed.host?.contains("your-project-ref") != true else { return false } + return key != "sb_publishable_your_key" + } + private static func boolValue(for key: String, defaultValue: Bool) -> Bool { let fallback = defaultValue ? "true" : "false" let rawValue = ProcessInfo.processInfo.environment[key] @@ -109,4 +131,13 @@ enum Config { } return normalized } + + private static func isPlaceholderValue(_ value: String) -> Bool { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty + || (trimmed.hasPrefix("$(") && trimmed.hasSuffix(")")) + || trimmed.contains("your-project-ref") + || trimmed.contains("example.invalid") + || trimmed.contains("your_key") + } } diff --git a/10x-macos/ContentView.swift b/10x-macos/ContentView.swift index 0f055fb..bc04d20 100644 --- a/10x-macos/ContentView.swift +++ b/10x-macos/ContentView.swift @@ -190,7 +190,7 @@ struct ContentView: View { .ignoresSafeArea(.all, edges: .top) .background(TrafficLightPositioner(barHeight: TabBarPalette.segmentHeight)) .task(id: auth.accessToken) { - guard let token = await auth.validAccessToken() else { + guard let token = await auth.generationAccessToken() else { syncSessionAccessTokens(nil) billingViewModel.clear() tabs = [] @@ -201,6 +201,7 @@ struct ContentView: View { } syncSessionAccessTokens(token) + let isLocalDirectMode = token == LLMConnectionService.localDirectAccessToken Task.detached(priority: .utility) { await SimulatorPreviewService.shared.prewarmOnAppLaunchIfNeeded() @@ -217,6 +218,11 @@ struct ContentView: View { restoreTabs(accessToken: token) } + guard !isLocalDirectMode else { + billingViewModel.clear() + return + } + if let pendingBillingURL = BillingDeepLinkStore.shared.consume() { openAccountTab(select: .billing) await billingViewModel.handleDeepLink(pendingBillingURL, accessToken: token) @@ -262,6 +268,7 @@ struct ContentView: View { vm.projects = homeViewModel.projects vm.archivedProjects = homeViewModel.archivedProjects vm.billingRefreshHandler = { [billingViewModel, auth] captureDelta in + guard !LLMConnectionService.shared.hasActiveDirectConnection || !auth.isGuestMode else { return } guard let token = await auth.validAccessToken() else { return } await billingViewModel.refresh(accessToken: token, captureDelta: captureDelta) } @@ -523,7 +530,7 @@ struct ContentView: View { preloadPreview(for: tab, project: project, viewModel: vm) Task { @MainActor in - guard let token = await auth.validAccessToken() else { return } + guard let token = await auth.generationAccessToken() else { return } vm.selectProject(project, accessToken: token) if let message = initialMessage, !message.isEmpty || !attachments.isEmpty { diff --git a/10x-macos/Models/LLMConnection.swift b/10x-macos/Models/LLMConnection.swift new file mode 100644 index 0000000..1ea27ed --- /dev/null +++ b/10x-macos/Models/LLMConnection.swift @@ -0,0 +1,179 @@ +import Foundation + +enum LLMProvider: String, CaseIterable, Codable, Identifiable, Sendable { + case openai = "OpenAI" + case claude = "Claude" + case ollama = "Ollama" + case openrouter = "OpenRouter" + + var id: String { rawValue } + + var displayName: String { rawValue } + + var defaultBaseURL: String { + switch self { + case .openai: + return "https://api.openai.com" + case .claude: + return "https://api.anthropic.com" + case .ollama: + return "http://localhost:11434" + case .openrouter: + return "https://openrouter.ai/api" + } + } + + var apiKeyLabel: String { + switch self { + case .openai: + return "OpenAI API Key" + case .claude: + return "Claude API Key" + case .ollama: + return "API Key (optional)" + case .openrouter: + return "OpenRouter API Key" + } + } + + var requiresAPIKey: Bool { + switch self { + case .ollama: + return false + case .openai, .claude, .openrouter: + return true + } + } + + var modelPlaceholder: String { + switch self { + case .openai: + return "gpt-4o" + case .claude: + return "claude-sonnet-4-20250514" + case .ollama: + return "llama3.2" + case .openrouter: + return "anthropic/claude-sonnet-4" + } + } + + var setupHint: String { + switch self { + case .openai: + return "Uses OpenAI's chat completions endpoint. Custom OpenAI-compatible gateways also work when the base URL points at the API root." + case .claude: + return "Uses Anthropic's Messages API and expects Anthropic-format model IDs." + case .ollama: + return "Runs against a local Ollama server. Start Ollama first and pull the model you enter here." + case .openrouter: + return "Uses OpenRouter's OpenAI-compatible endpoint. Model IDs usually include a provider prefix." + } + } + + var baseURLLabel: String { + switch self { + case .ollama: + return "Ollama Host URL" + default: + return "Base URL (optional)" + } + } + + var supportsCustomModels: Bool { + switch self { + case .openai, .ollama, .openrouter: + return true + case .claude: + return false + } + } + + var defaultModels: [String] { + switch self { + case .openai: + return [ + "gpt-4o", + "gpt-4o-mini", + "gpt-4-turbo", + "o3-mini", + "o1" + ] + case .claude: + return [ + "claude-sonnet-4-20250514", + "claude-opus-4-20250514", + "claude-3-7-sonnet-20250219", + "claude-3-5-sonnet-20241022" + ] + case .ollama: + return [ + "llama3.2", + "llama3.1", + "qwen2.5-coder", + "mistral", + "codellama" + ] + case .openrouter: + return [ + "anthropic/claude-sonnet-4", + "anthropic/claude-opus-4", + "openai/gpt-4o", + "deepseek/deepseek-chat", + "google/gemini-2.5-pro-preview-03-25" + ] + } + } + + var iconName: String { + switch self { + case .openai: "network" + case .claude: "sparkles" + case .ollama: "desktopcomputer" + case .openrouter: "arrow.left.arrow.right" + } + } +} + +struct LLMConnection: Identifiable, Codable, Sendable, Equatable { + let id: UUID + var name: String + var provider: LLMProvider + var apiKey: String + var baseURL: String + var model: String + var isEnabled: Bool + + init( + id: UUID = UUID(), + name: String = "", + provider: LLMProvider = .openai, + apiKey: String = "", + baseURL: String = "", + model: String = "", + isEnabled: Bool = true + ) { + self.id = id + self.name = name + self.provider = provider + self.apiKey = apiKey + self.baseURL = baseURL + self.model = model + self.isEnabled = isEnabled + } + + var displayName: String { + name.isEmpty ? "\(provider.displayName) — \(model)" : name + } + + var effectiveBaseURL: String { + let trimmed = baseURL.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? provider.defaultBaseURL : trimmed + } + + var isValid: Bool { + let hasModel = !model.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + let hasKey = !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + return hasModel && (!provider.requiresAPIKey || hasKey) + } +} diff --git a/10x-macos/Services/Builder/BuilderContextManager.swift b/10x-macos/Services/Builder/BuilderContextManager.swift index 9b45137..528bc2a 100644 --- a/10x-macos/Services/Builder/BuilderContextManager.swift +++ b/10x-macos/Services/Builder/BuilderContextManager.swift @@ -966,6 +966,10 @@ final class BuilderContextManager { payload["cache_control"] = cacheControl } + guard accessToken != LLMConnectionService.localDirectAccessToken else { + return nil + } + do { let response: TokenCountResponse = try await api.post( APIClient.builder("claude/count-tokens"), diff --git a/10x-macos/Services/Builder/BuilderGenerationRequestPlanner.swift b/10x-macos/Services/Builder/BuilderGenerationRequestPlanner.swift index 5f3df18..7ec773e 100644 --- a/10x-macos/Services/Builder/BuilderGenerationRequestPlanner.swift +++ b/10x-macos/Services/Builder/BuilderGenerationRequestPlanner.swift @@ -72,13 +72,23 @@ enum BuilderGenerationRequestPlanner { static func toolsForGeneration( requestType: BuilderGenerationRequestType, mode: ProjectMode, - integrationAvailability: BuilderIntegrationToolAvailability = .none + integrationAvailability: BuilderIntegrationToolAvailability = .none, + allowsHostedBackendTools: Bool = true ) -> [[String: Any]] { _ = requestType - return BuilderToolDefinitions.tools( + let tools = BuilderToolDefinitions.tools( for: mode, integrationAvailability: integrationAvailability ) + + guard allowsHostedBackendTools else { + return tools.filter { tool in + guard let name = tool["name"] as? String else { return true } + return !["web_search", "scrape_url"].contains(name) + } + } + + return tools } static func requestOptionsForGeneration( diff --git a/10x-macos/Services/Builder/BuilderPrompts.swift b/10x-macos/Services/Builder/BuilderPrompts.swift index ab8632a..3ad8650 100644 --- a/10x-macos/Services/Builder/BuilderPrompts.swift +++ b/10x-macos/Services/Builder/BuilderPrompts.swift @@ -960,7 +960,7 @@ enum BuilderPrompts { ## Your Approach 1. **Scope check first** — Compare the user's request to the shared Delivery Scope Contract. If any partial-support or unsupported capability applies, call `update_project_status` immediately with the warning, then tell the user what is out of scope or limited and why before broader planning. 2. **Load relevant skills** — Follow the shared Skills section before domain-specific planning work. - 3. **Ask questions** — Use `ask_user` to understand the user's rough product vision first: the core use case, target audience, the main user outcome, must-have features, and design direction. Ask 2-4 focused questions. Prefer multiple-choice questions with concrete options, and use multi-select when asking which features belong in v1. In the first batch, most questions should shape the product, not the setup. Follow the shared Project Naming rules, but keep naming secondary to understanding the product. Always ask whether the user wants an onboarding flow in their app (e.g., a welcome carousel, first-run tutorial, or sign-up screen). Do not ask Supabase, Superwall, backend, or auth setup questions in the initial batch. Default the first version to mock/local data unless the user explicitly asks for real integrations now. + 3. **Ask questions** — Use `ask_user` to understand the user's rough product vision first: the core use case, target audience, the main user outcome, must-have features, and design direction. Do not write the questionnaire in assistant text; call `ask_user` directly so the app can show answer buttons. Ask 2-4 focused questions. Prefer multiple-choice questions with concrete options, and use multi-select when asking which features belong in v1. In the first batch, most questions should shape the product, not the setup. Follow the shared Project Naming rules, but keep naming secondary to understanding the product. Always ask whether the user wants an onboarding flow in their app (e.g., a welcome carousel, first-run tutorial, or sign-up screen). Do not ask Supabase, Superwall, backend, or auth setup questions in the initial batch. Default the first version to mock/local data unless the user explicitly asks for real integrations now. 4. **Research early** — Use `web_search` for 2-3 similar apps, UI patterns, Apple HIG, and needed docs. Use `scrape_url` when a specific page matters. 5. **Plan** — Use `update_project_status` (with the `plan` field) to create a structured project plan including: - App concept and value proposition diff --git a/10x-macos/Services/Builder/BuilderToolDefinitions.swift b/10x-macos/Services/Builder/BuilderToolDefinitions.swift index cc1a328..fdbc6df 100644 --- a/10x-macos/Services/Builder/BuilderToolDefinitions.swift +++ b/10x-macos/Services/Builder/BuilderToolDefinitions.swift @@ -219,6 +219,8 @@ enum BuilderToolDefinitions { target audience, or choices between alternatives. You can batch related questions \ into a single call to save time, or ask one at a time for deeper exploration. \ Prefer multiple-choice questions with concrete options, and do not ask fully open-ended questions. \ + Call this tool directly without writing the same questions in assistant text first; \ + the app renders this tool as an answerable questionnaire. \ The user's responses will be returned as the tool result. """, schema: objectSchema( diff --git a/10x-macos/Services/Builder/GenerationService.swift b/10x-macos/Services/Builder/GenerationService.swift index b2fc557..4c24c10 100644 --- a/10x-macos/Services/Builder/GenerationService.swift +++ b/10x-macos/Services/Builder/GenerationService.swift @@ -456,61 +456,10 @@ actor GenerationService { let input: [String: Any] } - /// Call the Claude proxy and parse the NDJSON stream into text + tool_use blocks. - private func callClaudeProxy( - system: String, - messages: [[String: Any]], - tools: [[String: Any]], - requestOptions: RequestOptions, - maxTokens: Int, - accessToken: String, - projectId: String?, - sessionId: String?, - billingGroupId: String, - billingMessagePreview: String?, + private func parseClaudeStream( + rawLines: AsyncThrowingStream, onEvent: @MainActor @Sendable @escaping (GenerationEvent) async -> Void ) async throws -> StreamResult { - var body: [String: Any] = [ - "system": system, - "messages": messages, - "tools": tools, - "max_tokens": maxTokens, - "idempotency_key": UUID().uuidString, - "billing_group_id": billingGroupId, - ] - if let billingMessagePreview, !billingMessagePreview.isEmpty { - body["billing_message_preview"] = billingMessagePreview - } - if let projectId { - body["project_id"] = projectId - } - if let sessionId { - body["session_id"] = sessionId - } - if let toolChoice = requestOptions.toolChoice { - body["tool_choice"] = toolChoice - } - if let thinking = requestOptions.thinking { - body["thinking"] = thinking - } - if let outputConfig = requestOptions.outputConfig { - body["output_config"] = outputConfig - } - if let cacheControl = requestOptions.cacheControl { - body["cache_control"] = cacheControl - } - - print( - "[billing-debug] generation.proxy.request billingGroupId=\(billingGroupId) sessionId=\(sessionId ?? "nil") projectId=\(projectId ?? "nil") messageCount=\(messages.count) toolCount=\(tools.count) maxTokens=\(maxTokens)" - ) - - let rawLines = try await api.stream( - APIClient.builder("claude/stream"), - method: "POST", - json: body, - accessToken: accessToken - ) - var result = StreamResult() var currentBlockType: String? var currentToolId = "" @@ -584,6 +533,81 @@ actor GenerationService { } } + return result + } + + /// Call the Claude proxy and parse the NDJSON stream into text + tool_use blocks. + private func callClaudeProxy( + system: String, + messages: [[String: Any]], + tools: [[String: Any]], + requestOptions: RequestOptions, + maxTokens: Int, + accessToken: String, + projectId: String?, + sessionId: String?, + billingGroupId: String, + billingMessagePreview: String?, + onEvent: @MainActor @Sendable @escaping (GenerationEvent) async -> Void + ) async throws -> StreamResult { + // Check for a user-configured direct LLM connection + if let connection = await LLMConnectionService.shared.activeConnection { + print("[10x] Using direct LLM connection: \(connection.provider.displayName) \(connection.model)") + let client = DirectLLMClientFactory.client(for: connection) + let rawLines = try await client.stream( + system: system, + messages: messages, + tools: tools, + model: connection.model, + maxTokens: maxTokens, + onEvent: onEvent + ) + return try await parseClaudeStream(rawLines: rawLines, onEvent: onEvent) + } + + var body: [String: Any] = [ + "system": system, + "messages": messages, + "tools": tools, + "max_tokens": maxTokens, + "idempotency_key": UUID().uuidString, + "billing_group_id": billingGroupId, + ] + if let billingMessagePreview, !billingMessagePreview.isEmpty { + body["billing_message_preview"] = billingMessagePreview + } + if let projectId { + body["project_id"] = projectId + } + if let sessionId { + body["session_id"] = sessionId + } + if let toolChoice = requestOptions.toolChoice { + body["tool_choice"] = toolChoice + } + if let thinking = requestOptions.thinking { + body["thinking"] = thinking + } + if let outputConfig = requestOptions.outputConfig { + body["output_config"] = outputConfig + } + if let cacheControl = requestOptions.cacheControl { + body["cache_control"] = cacheControl + } + + print( + "[billing-debug] generation.proxy.request billingGroupId=\(billingGroupId) sessionId=\(sessionId ?? "nil") projectId=\(projectId ?? "nil") messageCount=\(messages.count) toolCount=\(tools.count) maxTokens=\(maxTokens)" + ) + + let rawLines = try await api.stream( + APIClient.builder("claude/stream"), + method: "POST", + json: body, + accessToken: accessToken + ) + + let result = try await parseClaudeStream(rawLines: rawLines, onEvent: onEvent) + print( "[billing-debug] generation.proxy.response billingGroupId=\(billingGroupId) textChars=\(result.text.count) toolUses=\(result.toolUses.count)" ) diff --git a/10x-macos/Services/Builder/SkillsManager.swift b/10x-macos/Services/Builder/SkillsManager.swift index dbf54b8..670f0d1 100644 --- a/10x-macos/Services/Builder/SkillsManager.swift +++ b/10x-macos/Services/Builder/SkillsManager.swift @@ -13,6 +13,11 @@ actor SkillsManager { /// Fetch the skill registry from the API (or return cached). func fetchRegistry(accessToken: String) async -> [SkillRegistryEntry] { + if accessToken == LLMConnectionService.localDirectAccessToken { + registryLoaded = true + return BundledSkillsCatalog.registry + } + if !registryLoaded { do { let response: SkillsListResponse = try await api.get( diff --git a/10x-macos/Services/DirectLLM/ClaudeDirectClient.swift b/10x-macos/Services/DirectLLM/ClaudeDirectClient.swift new file mode 100644 index 0000000..7d35f1a --- /dev/null +++ b/10x-macos/Services/DirectLLM/ClaudeDirectClient.swift @@ -0,0 +1,76 @@ +import Foundation + +actor ClaudeDirectClient: DirectLLMClient { + private let connection: LLMConnection + private let apiKey: String + private let baseURL: URL + + init(connection: LLMConnection, apiKey: String) { + self.connection = connection + self.apiKey = apiKey + self.baseURL = URL(string: connection.effectiveBaseURL)! + } + + func stream( + system: String, + messages: [[String: Any]], + tools: [[String: Any]], + model: String, + maxTokens: Int, + onEvent: @escaping @MainActor @Sendable (GenerationEvent) async -> Void + ) async throws -> AsyncThrowingStream { + var body: [String: Any] = [ + "model": model, + "max_tokens": maxTokens, + "system": system, + "messages": messages, + "stream": true + ] + if !tools.isEmpty { + body["tools"] = tools + } + + let url = baseURL.appendingPathComponent("v1/messages") + let request = try DirectLLMHelpers.buildRequest( + url: url, + headers: [ + "x-api-key": apiKey, + "anthropic-version": "2023-06-01" + ], + body: body + ) + + let (bytes, response) = try await DirectLLMHelpers.anthropicStreamSession().bytes(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + throw GenerationError.apiError("Invalid response") + } + if httpResponse.statusCode == 401 { + throw GenerationError.apiError("Invalid API key") + } + guard httpResponse.statusCode == 200 else { + var bodyText = "" + for try await line in bytes.lines { + bodyText += line + } + throw GenerationError.apiError("HTTP \(httpResponse.statusCode): \(bodyText)") + } + + return AsyncThrowingStream { continuation in + Task { + do { + for try await line in bytes.lines { + if Task.isCancelled { break } + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.hasPrefix("data: ") else { continue } + let dataContent = String(trimmed.dropFirst(6)) + if dataContent == "[DONE]" { continue } + continuation.yield(dataContent) + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + } + } +} diff --git a/10x-macos/Services/DirectLLM/OllamaDirectClient.swift b/10x-macos/Services/DirectLLM/OllamaDirectClient.swift new file mode 100644 index 0000000..7da2321 --- /dev/null +++ b/10x-macos/Services/DirectLLM/OllamaDirectClient.swift @@ -0,0 +1,150 @@ +import Foundation + +/// Ollama client using the OpenAI-compatible /v1/chat/completions endpoint. +/// This endpoint supports tools and streaming in the same format as OpenAI. +actor OllamaDirectClient: DirectLLMClient { + private let connection: LLMConnection + private let apiKey: String + private let baseURL: URL + + init(connection: LLMConnection, apiKey: String) { + self.connection = connection + self.apiKey = apiKey + self.baseURL = URL(string: connection.effectiveBaseURL)! + } + + func stream( + system: String, + messages: [[String: Any]], + tools: [[String: Any]], + model: String, + maxTokens: Int, + onEvent: @escaping @MainActor @Sendable (GenerationEvent) async -> Void + ) async throws -> AsyncThrowingStream { + var body: [String: Any] = [ + "model": model, + "messages": DirectLLMHelpers.openAIMessages(system: system, messages: messages), + "stream": true + ] + if maxTokens > 0 { + body["max_tokens"] = maxTokens + } + if !tools.isEmpty { + body["tools"] = DirectLLMHelpers.openAITools(from: tools) + body["tool_choice"] = "auto" + } + + let url = baseURL.appendingPathComponent("v1/chat/completions") + var headers: [String: String] = [:] + if !apiKey.isEmpty { + headers["Authorization"] = "Bearer \(apiKey)" + } + + let request = try DirectLLMHelpers.buildRequest(url: url, headers: headers, body: body) + let (bytes, response) = try await DirectLLMHelpers.anthropicStreamSession().bytes(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw GenerationError.apiError("Invalid response") + } + guard httpResponse.statusCode == 200 else { + var bodyText = "" + for try await line in bytes.lines { bodyText += line } + throw GenerationError.apiError("HTTP \(httpResponse.statusCode): \(bodyText)") + } + + return AsyncThrowingStream { continuation in + Task { + struct InFlightTool: Sendable { + var id: String = "" + var name: String = "" + var args: String = "" + var hasStarted: Bool = false + } + var inFlightTools: [Int: InFlightTool] = [:] + var hasTextBlock = false + + do { + for try await line in bytes.lines { + if Task.isCancelled { break } + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.hasPrefix("data: ") else { continue } + let dataContent = String(trimmed.dropFirst(6)) + if dataContent == "[DONE]" { continue } + + guard let data = dataContent.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let choices = json["choices"] as? [[String: Any]], + let first = choices.first else { continue } + + let delta = first["delta"] as? [String: Any] ?? [:] + + if let toolCalls = delta["tool_calls"] as? [[String: Any]] { + for tc in toolCalls { + let index = tc["index"] as? Int ?? 0 + if inFlightTools[index] == nil { + inFlightTools[index] = InFlightTool() + } + var tool = inFlightTools[index]! + if let id = tc["id"] as? String, !id.isEmpty { + tool.id = id + } + if let fn = tc["function"] as? [String: Any] { + if let name = fn["name"] as? String, !name.isEmpty { + tool.name = name + } + if let args = fn["arguments"] as? String { + if !tool.hasStarted { + tool.hasStarted = true + continuation.yield(DirectLLMHelpers.contentBlockStart( + type: "tool_use", + id: tool.id, + name: tool.name + )) + } + tool.args += args + continuation.yield(DirectLLMHelpers.inputJsonDeltaLine(args)) + } + } + inFlightTools[index] = tool + } + } + + if let content = delta["content"] as? String, !content.isEmpty { + if !hasTextBlock { + hasTextBlock = true + continuation.yield(DirectLLMHelpers.contentBlockStart(type: "text")) + } + continuation.yield(DirectLLMHelpers.textDeltaLine(content)) + } + + let finishReason = first["finish_reason"] as? String + if finishReason != nil { + if hasTextBlock { + continuation.yield(DirectLLMHelpers.contentBlockStopLine()) + hasTextBlock = false + } + for index in inFlightTools.keys.sorted() { + if let tool = inFlightTools[index], tool.hasStarted { + continuation.yield(DirectLLMHelpers.contentBlockStopLine()) + } + } + inFlightTools.removeAll() + } + } + if hasTextBlock { + continuation.yield(DirectLLMHelpers.contentBlockStopLine()) + } + for index in inFlightTools.keys.sorted() { + if let tool = inFlightTools[index], tool.hasStarted { + continuation.yield(DirectLLMHelpers.contentBlockStopLine()) + } + } + continuation.yield(DirectLLMHelpers.messageStopLine()) + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + } + } +} diff --git a/10x-macos/Services/DirectLLM/OpenAIDirectClient.swift b/10x-macos/Services/DirectLLM/OpenAIDirectClient.swift new file mode 100644 index 0000000..f5942e1 --- /dev/null +++ b/10x-macos/Services/DirectLLM/OpenAIDirectClient.swift @@ -0,0 +1,151 @@ +import Foundation + +actor OpenAIDirectClient: DirectLLMClient { + private let connection: LLMConnection + private let apiKey: String + private let baseURL: URL + + init(connection: LLMConnection, apiKey: String) { + self.connection = connection + self.apiKey = apiKey + self.baseURL = URL(string: connection.effectiveBaseURL)! + } + + func stream( + system: String, + messages: [[String: Any]], + tools: [[String: Any]], + model: String, + maxTokens: Int, + onEvent: @escaping @MainActor @Sendable (GenerationEvent) async -> Void + ) async throws -> AsyncThrowingStream { + var body: [String: Any] = [ + "model": model, + "messages": DirectLLMHelpers.openAIMessages(system: system, messages: messages), + "stream": true, + "stream_options": ["include_usage": true] + ] + if maxTokens > 0 { + body["max_tokens"] = maxTokens + } + if !tools.isEmpty { + body["tools"] = DirectLLMHelpers.openAITools(from: tools) + body["tool_choice"] = "auto" + } + + let url = baseURL.appendingPathComponent("v1/chat/completions") + let request = try DirectLLMHelpers.buildRequest( + url: url, + headers: ["Authorization": "Bearer \(apiKey)"], + body: body + ) + + let (bytes, response) = try await DirectLLMHelpers.anthropicStreamSession().bytes(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + throw GenerationError.apiError("Invalid response") + } + if httpResponse.statusCode == 401 { + throw GenerationError.apiError("Invalid API key") + } + guard httpResponse.statusCode == 200 else { + var bodyText = "" + for try await line in bytes.lines { bodyText += line } + throw GenerationError.apiError("HTTP \(httpResponse.statusCode): \(bodyText)") + } + + return AsyncThrowingStream { continuation in + Task { + struct InFlightTool: Sendable { + var id: String = "" + var name: String = "" + var args: String = "" + var hasStarted: Bool = false + } + var inFlightTools: [Int: InFlightTool] = [:] + var hasTextBlock = false + + do { + for try await line in bytes.lines { + if Task.isCancelled { break } + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.hasPrefix("data: ") else { continue } + let dataContent = String(trimmed.dropFirst(6)) + if dataContent == "[DONE]" { continue } + + guard let data = dataContent.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let choices = json["choices"] as? [[String: Any]], + let first = choices.first else { continue } + + let delta = first["delta"] as? [String: Any] ?? [:] + + if let toolCalls = delta["tool_calls"] as? [[String: Any]] { + for tc in toolCalls { + let index = tc["index"] as? Int ?? 0 + if inFlightTools[index] == nil { + inFlightTools[index] = InFlightTool() + } + var tool = inFlightTools[index]! + if let id = tc["id"] as? String, !id.isEmpty { + tool.id = id + } + if let fn = tc["function"] as? [String: Any] { + if let name = fn["name"] as? String, !name.isEmpty { + tool.name = name + } + if let args = fn["arguments"] as? String { + if !tool.hasStarted { + tool.hasStarted = true + continuation.yield(DirectLLMHelpers.contentBlockStart( + type: "tool_use", + id: tool.id, + name: tool.name + )) + } + tool.args += args + continuation.yield(DirectLLMHelpers.inputJsonDeltaLine(args)) + } + } + inFlightTools[index] = tool + } + } + + if let content = delta["content"] as? String, !content.isEmpty { + if !hasTextBlock { + hasTextBlock = true + continuation.yield(DirectLLMHelpers.contentBlockStart(type: "text")) + } + continuation.yield(DirectLLMHelpers.textDeltaLine(content)) + } + + let finishReason = first["finish_reason"] as? String + if finishReason != nil { + if hasTextBlock { + continuation.yield(DirectLLMHelpers.contentBlockStopLine()) + hasTextBlock = false + } + for index in inFlightTools.keys.sorted() { + if let tool = inFlightTools[index], tool.hasStarted { + continuation.yield(DirectLLMHelpers.contentBlockStopLine()) + } + } + inFlightTools.removeAll() + } + } + if hasTextBlock { + continuation.yield(DirectLLMHelpers.contentBlockStopLine()) + } + for index in inFlightTools.keys.sorted() { + if let tool = inFlightTools[index], tool.hasStarted { + continuation.yield(DirectLLMHelpers.contentBlockStopLine()) + } + } + continuation.yield(DirectLLMHelpers.messageStopLine()) + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + } + } +} diff --git a/10x-macos/Services/DirectLLM/OpenRouterDirectClient.swift b/10x-macos/Services/DirectLLM/OpenRouterDirectClient.swift new file mode 100644 index 0000000..0100b0f --- /dev/null +++ b/10x-macos/Services/DirectLLM/OpenRouterDirectClient.swift @@ -0,0 +1,154 @@ +import Foundation + +actor OpenRouterDirectClient: DirectLLMClient { + private let connection: LLMConnection + private let apiKey: String + private let baseURL: URL + + init(connection: LLMConnection, apiKey: String) { + self.connection = connection + self.apiKey = apiKey + self.baseURL = URL(string: connection.effectiveBaseURL)! + } + + func stream( + system: String, + messages: [[String: Any]], + tools: [[String: Any]], + model: String, + maxTokens: Int, + onEvent: @escaping @MainActor @Sendable (GenerationEvent) async -> Void + ) async throws -> AsyncThrowingStream { + var body: [String: Any] = [ + "model": model, + "messages": DirectLLMHelpers.openAIMessages(system: system, messages: messages), + "stream": true + ] + if maxTokens > 0 { + body["max_tokens"] = maxTokens + } + if !tools.isEmpty { + body["tools"] = DirectLLMHelpers.openAITools(from: tools) + body["tool_choice"] = "auto" + } + + let url = baseURL.appendingPathComponent("v1/chat/completions") + let request = try DirectLLMHelpers.buildRequest( + url: url, + headers: [ + "Authorization": "Bearer \(apiKey)", + "HTTP-Referer": "https://10x.dev", + "X-Title": "10x" + ], + body: body + ) + + let (bytes, response) = try await DirectLLMHelpers.anthropicStreamSession().bytes(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + throw GenerationError.apiError("Invalid response") + } + if httpResponse.statusCode == 401 { + throw GenerationError.apiError("Invalid API key") + } + guard httpResponse.statusCode == 200 else { + var bodyText = "" + for try await line in bytes.lines { bodyText += line } + throw GenerationError.apiError("HTTP \(httpResponse.statusCode): \(bodyText)") + } + + return AsyncThrowingStream { continuation in + Task { + struct InFlightTool: Sendable { + var id: String = "" + var name: String = "" + var args: String = "" + var hasStarted: Bool = false + } + var inFlightTools: [Int: InFlightTool] = [:] + var hasTextBlock = false + + do { + for try await line in bytes.lines { + if Task.isCancelled { break } + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.hasPrefix("data: ") else { continue } + let dataContent = String(trimmed.dropFirst(6)) + if dataContent == "[DONE]" { continue } + + guard let data = dataContent.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let choices = json["choices"] as? [[String: Any]], + let first = choices.first else { continue } + + let delta = first["delta"] as? [String: Any] ?? [:] + + if let toolCalls = delta["tool_calls"] as? [[String: Any]] { + for tc in toolCalls { + let index = tc["index"] as? Int ?? 0 + if inFlightTools[index] == nil { + inFlightTools[index] = InFlightTool() + } + var tool = inFlightTools[index]! + if let id = tc["id"] as? String, !id.isEmpty { + tool.id = id + } + if let fn = tc["function"] as? [String: Any] { + if let name = fn["name"] as? String, !name.isEmpty { + tool.name = name + } + if let args = fn["arguments"] as? String { + if !tool.hasStarted { + tool.hasStarted = true + continuation.yield(DirectLLMHelpers.contentBlockStart( + type: "tool_use", + id: tool.id, + name: tool.name + )) + } + tool.args += args + continuation.yield(DirectLLMHelpers.inputJsonDeltaLine(args)) + } + } + inFlightTools[index] = tool + } + } + + if let content = delta["content"] as? String, !content.isEmpty { + if !hasTextBlock { + hasTextBlock = true + continuation.yield(DirectLLMHelpers.contentBlockStart(type: "text")) + } + continuation.yield(DirectLLMHelpers.textDeltaLine(content)) + } + + let finishReason = first["finish_reason"] as? String + if finishReason != nil { + if hasTextBlock { + continuation.yield(DirectLLMHelpers.contentBlockStopLine()) + hasTextBlock = false + } + for index in inFlightTools.keys.sorted() { + if let tool = inFlightTools[index], tool.hasStarted { + continuation.yield(DirectLLMHelpers.contentBlockStopLine()) + } + } + inFlightTools.removeAll() + } + } + if hasTextBlock { + continuation.yield(DirectLLMHelpers.contentBlockStopLine()) + } + for index in inFlightTools.keys.sorted() { + if let tool = inFlightTools[index], tool.hasStarted { + continuation.yield(DirectLLMHelpers.contentBlockStopLine()) + } + } + continuation.yield(DirectLLMHelpers.messageStopLine()) + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + } + } +} diff --git a/10x-macos/Services/DirectLLMClient.swift b/10x-macos/Services/DirectLLMClient.swift new file mode 100644 index 0000000..572cca0 --- /dev/null +++ b/10x-macos/Services/DirectLLMClient.swift @@ -0,0 +1,252 @@ +import Foundation + +// MARK: - Protocol + +protocol DirectLLMClient: Sendable { + func stream( + system: String, + messages: [[String: Any]], + tools: [[String: Any]], + model: String, + maxTokens: Int, + onEvent: @escaping @MainActor @Sendable (GenerationEvent) async -> Void + ) async throws -> AsyncThrowingStream +} + +// MARK: - Factory + +enum DirectLLMClientFactory { + static func client(for connection: LLMConnection) -> DirectLLMClient { + let apiKey = LLMConnectionService.apiKey(for: connection.id) + switch connection.provider { + case .claude: + return ClaudeDirectClient(connection: connection, apiKey: apiKey) + case .openai: + return OpenAIDirectClient(connection: connection, apiKey: apiKey) + case .ollama: + return OllamaDirectClient(connection: connection, apiKey: apiKey) + case .openrouter: + return OpenRouterDirectClient(connection: connection, apiKey: apiKey) + } + } +} + +// MARK: - Shared Helpers + +enum DirectLLMHelpers { + static func anthropicStreamSession() -> URLSession { + let config = URLSessionConfiguration.default + config.timeoutIntervalForRequest = 900 + config.timeoutIntervalForResource = 1800 + return URLSession(configuration: config) + } + + static func buildRequest( + url: URL, + method: String = "POST", + headers: [String: String] = [:], + body: [String: Any]? = nil + ) throws -> URLRequest { + var request = URLRequest(url: url) + request.httpMethod = method + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + headers.forEach { request.setValue($1, forHTTPHeaderField: $0) } + if let body { + request.httpBody = try JSONSerialization.data(withJSONObject: body) + } + return request + } + + static func makeBaseURL(_ string: String) throws -> URL { + guard let url = URL(string: string) else { + throw GenerationError.apiError("Invalid base URL: \(string)") + } + return url + } + + static func openAITools(from tools: [[String: Any]]) -> [[String: Any]] { + tools.compactMap { tool in + if (tool["type"] as? String) == "function", + tool["function"] is [String: Any] { + return tool + } + + guard let name = tool["name"] as? String else { + return nil + } + + var function: [String: Any] = [ + "name": name, + "parameters": tool["input_schema"] as? [String: Any] ?? [ + "type": "object", + "properties": [:] + ] + ] + + if let description = tool["description"] as? String { + function["description"] = description + } + + return [ + "type": "function", + "function": function + ] + } + } + + static func openAIMessages(system: String, messages: [[String: Any]]) -> [[String: Any]] { + var converted: [[String: Any]] = [ + ["role": "system", "content": system] + ] + + for message in messages { + let role = message["role"] as? String ?? "user" + guard let content = message["content"] else { + converted.append(message) + continue + } + + if let text = content as? String { + converted.append(["role": role, "content": text]) + continue + } + + guard let blocks = content as? [[String: Any]] else { + converted.append(message) + continue + } + + switch role { + case "assistant": + let text = textContent(from: blocks) + let toolCalls = blocks.compactMap(openAIToolCall(from:)) + var assistantMessage: [String: Any] = ["role": "assistant"] + assistantMessage["content"] = text.isEmpty ? NSNull() : text + if !toolCalls.isEmpty { + assistantMessage["tool_calls"] = toolCalls + } + converted.append(assistantMessage) + + case "user": + let textBlocks = blocks.filter { ($0["type"] as? String) != "tool_result" } + let text = textContent(from: textBlocks) + if !text.isEmpty { + converted.append(["role": "user", "content": text]) + } + + for block in blocks where (block["type"] as? String) == "tool_result" { + converted.append(openAIToolResultMessage(from: block)) + } + + default: + let text = textContent(from: blocks) + converted.append(["role": role, "content": text]) + } + } + + return converted + } + + private static func openAIToolCall(from block: [String: Any]) -> [String: Any]? { + guard (block["type"] as? String) == "tool_use", + let name = block["name"] as? String else { + return nil + } + + let id = (block["id"] as? String)?.isEmpty == false ? block["id"] as! String : "call_\(UUID().uuidString)" + let arguments = jsonString(block["input"] ?? [:]) + + return [ + "id": id, + "type": "function", + "function": [ + "name": name, + "arguments": arguments + ] + ] + } + + private static func openAIToolResultMessage(from block: [String: Any]) -> [String: Any] { + let id = block["tool_use_id"] as? String ?? "" + return [ + "role": "tool", + "tool_call_id": id, + "content": toolResultContent(from: block["content"]) + ] + } + + private static func textContent(from blocks: [[String: Any]]) -> String { + blocks.compactMap { block in + guard (block["type"] as? String) == "text" else { return nil } + return block["text"] as? String + } + .joined(separator: "\n") + } + + private static func toolResultContent(from content: Any?) -> String { + if let text = content as? String { + return text + } + if let blocks = content as? [[String: Any]] { + let text = textContent(from: blocks) + return text.isEmpty ? jsonString(blocks) : text + } + if let content { + return jsonString(content) + } + return "" + } + + private static func jsonString(_ value: Any) -> String { + guard JSONSerialization.isValidJSONObject(value), + let data = try? JSONSerialization.data(withJSONObject: value, options: [.sortedKeys]), + let string = String(data: data, encoding: .utf8) else { + return "{}" + } + return string + } + + // MARK: - Anthropic-format NDJSON lines + + static func messageStartLine() -> String { + jsonLine(["type": "message_start"]) + } + + static func contentBlockStart(type: String, id: String? = nil, name: String? = nil) -> String { + var block: [String: Any] = ["type": type] + if let id { block["id"] = id } + if let name { block["name"] = name } + return jsonLine(["type": "content_block_start", "content_block": block]) + } + + static func textDeltaLine(_ text: String) -> String { + jsonLine([ + "type": "content_block_delta", + "delta": ["type": "text_delta", "text": text] + ]) + } + + static func inputJsonDeltaLine(_ partial: String) -> String { + jsonLine([ + "type": "content_block_delta", + "delta": ["type": "input_json_delta", "partial_json": partial] + ]) + } + + static func contentBlockStopLine() -> String { + jsonLine(["type": "content_block_stop"]) + } + + static func messageStopLine() -> String { + jsonLine(["type": "message_stop"]) + } + + static func errorLine(_ message: String) -> String { + jsonLine(["type": "error", "message": message]) + } + + private static func jsonLine(_ object: [String: Any]) -> String { + guard let data = try? JSONSerialization.data(withJSONObject: object) else { return "" } + return String(data: data, encoding: .utf8) ?? "" + } +} diff --git a/10x-macos/Services/LLMConnectionService.swift b/10x-macos/Services/LLMConnectionService.swift new file mode 100644 index 0000000..c784949 --- /dev/null +++ b/10x-macos/Services/LLMConnectionService.swift @@ -0,0 +1,300 @@ +import Foundation +import Security + +struct LLMConnectionTestResult: Sendable { + let isSuccess: Bool + let message: String +} + +struct LLMModelFetchResult: Sendable { + let models: [String] + let message: String? +} + +@Observable +@MainActor +final class LLMConnectionService { + static let shared = LLMConnectionService() + nonisolated static let localDirectAccessToken = "__10x_direct_llm_local__" + private nonisolated static let connectionsKey = "tenx.llm.connections" + private nonisolated static let activeConnectionIDKey = "tenx.llm.activeConnectionID" + private nonisolated static let keychainService = "app.10x.macos.llm-keys" + + var connections: [LLMConnection] = [] + var activeConnectionID: UUID? { + didSet { + if let id = activeConnectionID { + UserDefaults.standard.set(id.uuidString, forKey: Self.activeConnectionIDKey) + } else { + UserDefaults.standard.removeObject(forKey: Self.activeConnectionIDKey) + } + } + } + + var activeConnection: LLMConnection? { + guard let id = activeConnectionID else { return nil } + return connections.first { $0.id == id && isUsable($0) } + } + + var hasActiveDirectConnection: Bool { + activeConnection != nil + } + + init() { + loadConnections() + } + + // MARK: - CRUD + + func add(_ connection: LLMConnection) { + var mutable = connection + if !mutable.apiKey.isEmpty { + Self.storeAPIKey(mutable.apiKey, for: mutable.id) + mutable.apiKey = "" + } + connections.append(mutable) + persist() + } + + func update(_ connection: LLMConnection) { + guard let index = connections.firstIndex(where: { $0.id == connection.id }) else { return } + var mutable = connection + if !mutable.apiKey.isEmpty { + Self.storeAPIKey(mutable.apiKey, for: mutable.id) + mutable.apiKey = "" + } + connections[index] = mutable + persist() + } + + func delete(_ connection: LLMConnection) { + connections.removeAll { $0.id == connection.id } + Self.removeAPIKey(for: connection.id) + if activeConnectionID == connection.id { + activeConnectionID = nil + } + persist() + } + + func setActive(_ connection: LLMConnection?) { + activeConnectionID = connection?.id + } + + func isUsable(_ connection: LLMConnection) -> Bool { + guard connection.isEnabled else { return false } + guard !connection.model.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return false } + guard URL(string: connection.effectiveBaseURL) != nil else { return false } + return !connection.provider.requiresAPIKey || hasAPIKey(for: connection) + } + + func hasAPIKey(for connection: LLMConnection) -> Bool { + if !connection.apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return true + } + return !Self.apiKey(for: connection.id).isEmpty + } + + func validationMessage(for connection: LLMConnection) -> String? { + if !connection.isEnabled { + return "Connection is disabled." + } + if connection.model.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return "Choose a model." + } + if URL(string: connection.effectiveBaseURL) == nil { + return "Enter a valid base URL." + } + if connection.provider.requiresAPIKey && !hasAPIKey(for: connection) { + return "Add an API key." + } + return nil + } + + func test(_ connection: LLMConnection) async -> LLMConnectionTestResult { + if let validation = validationMessage(for: connection) { + return LLMConnectionTestResult(isSuccess: false, message: validation) + } + + guard let url = testURL(for: connection) else { + return LLMConnectionTestResult(isSuccess: false, message: "Enter a valid base URL.") + } + + var request = URLRequest(url: url) + request.timeoutInterval = 20 + switch connection.provider { + case .openai, .openrouter: + request.setValue("Bearer \(apiKeyValue(for: connection))", forHTTPHeaderField: "Authorization") + case .claude: + request.setValue(apiKeyValue(for: connection), forHTTPHeaderField: "x-api-key") + request.setValue("2023-06-01", forHTTPHeaderField: "anthropic-version") + case .ollama: + let apiKey = apiKeyValue(for: connection) + if !apiKey.isEmpty { + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + } + } + + do { + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse else { + return LLMConnectionTestResult(isSuccess: false, message: "Invalid response from provider.") + } + guard (200..<300).contains(http.statusCode) else { + let body = String(data: data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) + let suffix = body?.isEmpty == false ? ": \(body!)" : "" + return LLMConnectionTestResult(isSuccess: false, message: "HTTP \(http.statusCode)\(suffix)") + } + return LLMConnectionTestResult(isSuccess: true, message: "Connection works.") + } catch { + return LLMConnectionTestResult(isSuccess: false, message: error.localizedDescription) + } + } + + func fetchModels(for connection: LLMConnection) async -> LLMModelFetchResult { + guard connection.provider == .ollama else { + return LLMModelFetchResult(models: connection.provider.defaultModels, message: nil) + } + + guard let url = testURL(for: connection) else { + return LLMModelFetchResult(models: [], message: "Enter a valid Ollama host URL.") + } + + var request = URLRequest(url: url) + request.timeoutInterval = 12 + let apiKey = apiKeyValue(for: connection) + if !apiKey.isEmpty { + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + } + + do { + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse else { + return LLMModelFetchResult(models: [], message: "Invalid response from Ollama.") + } + guard (200..<300).contains(http.statusCode) else { + return LLMModelFetchResult(models: [], message: "Ollama returned HTTP \(http.statusCode).") + } + let models = Self.parseOllamaModels(from: data) + if models.isEmpty { + return LLMModelFetchResult(models: [], message: "No local Ollama models found. Pull one with `ollama pull `.") + } + return LLMModelFetchResult(models: models, message: nil) + } catch { + return LLMModelFetchResult(models: [], message: error.localizedDescription) + } + } + + nonisolated static func apiKey(for connectionID: UUID) -> String { + Self.loadAPIKey(for: connectionID) ?? "" + } + + // MARK: - Persistence + + private func persist() { + let sanitized = connections.map { conn -> LLMConnection in + var mutable = conn + mutable.apiKey = "" + return mutable + } + if let data = try? JSONEncoder().encode(sanitized) { + UserDefaults.standard.set(data, forKey: Self.connectionsKey) + } + } + + private func loadConnections() { + guard let data = UserDefaults.standard.data(forKey: Self.connectionsKey), + let decoded = try? JSONDecoder().decode([LLMConnection].self, from: data) else { + return + } + connections = decoded + if let activeIDString = UserDefaults.standard.string(forKey: Self.activeConnectionIDKey), + let activeID = UUID(uuidString: activeIDString) { + activeConnectionID = activeID + } + } + + private func testURL(for connection: LLMConnection) -> URL? { + guard let base = URL(string: connection.effectiveBaseURL) else { return nil } + switch connection.provider { + case .openai, .openrouter: + return base.appendingPathComponent("v1/models") + case .claude: + return base.appendingPathComponent("v1/models") + case .ollama: + return base.appendingPathComponent("api/tags") + } + } + + private func apiKeyValue(for connection: LLMConnection) -> String { + let inlineKey = connection.apiKey.trimmingCharacters(in: .whitespacesAndNewlines) + return inlineKey.isEmpty ? Self.apiKey(for: connection.id) : inlineKey + } + + private nonisolated static func parseOllamaModels(from data: Data) -> [String] { + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let rawModels = json["models"] as? [[String: Any]] else { + return [] + } + + let names = rawModels.compactMap { model in + (model["name"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + } + .filter { !$0.isEmpty } + + return Array(Set(names)).sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending } + } + + // MARK: - Keychain + + private nonisolated static func storeAPIKey(_ key: String, for connectionID: UUID) { + guard !key.isEmpty else { + Self.removeAPIKey(for: connectionID) + return + } + let account = connectionID.uuidString + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: Self.keychainService, + kSecAttrAccount as String: account, + ] + let attributes: [String: Any] = [ + kSecValueData as String: key.data(using: .utf8)!, + kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked, + ] + let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) + if status == errSecItemNotFound { + var addQuery = query + addQuery[kSecValueData as String] = key.data(using: .utf8)! + addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlocked + SecItemAdd(addQuery as CFDictionary, nil) + } + } + + private nonisolated static func loadAPIKey(for connectionID: UUID) -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: Self.keychainService, + kSecAttrAccount as String: connectionID.uuidString, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + guard status == errSecSuccess, + let data = result as? Data, + let string = String(data: data, encoding: .utf8) else { + return nil + } + return string + } + + private nonisolated static func removeAPIKey(for connectionID: UUID) { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: Self.keychainService, + kSecAttrAccount as String: connectionID.uuidString, + ] + SecItemDelete(query as CFDictionary) + } +} diff --git a/10x-macos/Services/LocalProjectStore.swift b/10x-macos/Services/LocalProjectStore.swift index c2fc069..078d52f 100644 --- a/10x-macos/Services/LocalProjectStore.swift +++ b/10x-macos/Services/LocalProjectStore.swift @@ -131,6 +131,77 @@ actor LocalProjectStore { Self.projectRootDirectory(projectName: projectName, projectId: projectId) } + func loadLocalProjects() -> [BuilderProject] { + let fileManager = FileManager.default + guard let projectDirs = try? fileManager.contentsOfDirectory( + at: Self.baseDirectory, + includingPropertiesForKeys: [.contentModificationDateKey], + options: [.skipsHiddenFiles] + ) else { + return [] + } + + let projects = projectDirs.compactMap { rootDir -> (BuilderProject, Date)? in + let metadataURL = rootDir + .appendingPathComponent("tenx", isDirectory: true) + .appendingPathComponent("project.json") + guard let data = try? Data(contentsOf: metadataURL), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let name = json["name"] as? String, + let projectId = json["projectId"] as? String, + !name.isEmpty, + !projectId.isEmpty + else { + return nil + } + + let updatedAt = (json["lastUpdated"] as? String) ?? ISO8601DateFormatter().string(from: Date()) + let modified = (try? rootDir.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? Date.distantPast + let project = BuilderProject( + id: projectId, + userId: "local-direct", + name: name, + description: nil, + slug: (json["slug"] as? String) ?? XcodePreviewService.safeName(from: name), + platform: (json["platform"] as? String) ?? "ios", + status: (json["status"] as? String) ?? "draft", + currentVersionId: nil, + settings: nil, + createdAt: updatedAt, + updatedAt: updatedAt + ) + return (project, modified) + } + + return projects + .sorted { $0.1 > $1.1 } + .map { $0.0 } + } + + func saveProjectMetadata(_ project: BuilderProject) { + let tenxDirectory = tenxDir(projectName: project.name, projectId: project.id) + do { + try ensureDir(tenxDirectory) + let metadata: [String: Any] = [ + "name": project.name, + "slug": project.slug, + "targetName": XcodePreviewService.targetName(from: project.name), + "bundleId": XcodePreviewService.bundleId(from: project.name), + "projectId": project.id, + "platform": project.platform, + "status": project.status, + "lastUpdated": project.updatedAt, + ] + let data = try JSONSerialization.data( + withJSONObject: metadata, + options: [.prettyPrinted, .sortedKeys] + ) + try data.write(to: tenxDirectory.appendingPathComponent("project.json"), options: [.atomic]) + } catch { + print("Failed to save local project metadata: \(error)") + } + } + nonisolated static func projectRootDirectory(projectName: String, projectId: String) -> URL { let safeName = XcodePreviewService.safeName(from: projectName) let dirName = "\(safeName)-\(projectId.prefix(8))" diff --git a/10x-macos/Services/SupabaseService.swift b/10x-macos/Services/SupabaseService.swift index 64a4793..7a4188c 100644 --- a/10x-macos/Services/SupabaseService.swift +++ b/10x-macos/Services/SupabaseService.swift @@ -67,12 +67,12 @@ actor SupabaseService { private init() { client = SupabaseClient( - supabaseURL: URL(string: Config.supabaseURL)!, - supabaseKey: Config.supabaseAnonKey, + supabaseURL: URL(string: Config.supabaseConfigured ? Config.supabaseURL : "http://127.0.0.1")!, + supabaseKey: Config.supabaseConfigured ? Config.supabaseAnonKey : "disabled", options: SupabaseClientOptions( auth: SupabaseClientOptions.AuthOptions( storage: VolatileAuthLocalStorage(), - emitLocalSessionAsInitialSession: false + emitLocalSessionAsInitialSession: true ) ) ) diff --git a/10x-macos/SparkleUpdater.swift b/10x-macos/SparkleUpdater.swift index 8868966..d2b36e4 100644 --- a/10x-macos/SparkleUpdater.swift +++ b/10x-macos/SparkleUpdater.swift @@ -88,6 +88,7 @@ final class SparkleUpdaterCoordinator: NSObject, SPUUpdaterDelegate, @preconcurr } func activate() { + guard Config.sparkleUpdatesConfigured else { return } guard !hasActivated else { return } hasActivated = true @@ -100,6 +101,7 @@ final class SparkleUpdaterCoordinator: NSObject, SPUUpdaterDelegate, @preconcurr } func checkForUpdates() { + guard Config.sparkleUpdatesConfigured else { return } activate() if let version = availableUpdatePrompt?.versionString { diff --git a/10x-macos/ViewModels/AuthManager.swift b/10x-macos/ViewModels/AuthManager.swift index dce4b0d..06dd5f3 100644 --- a/10x-macos/ViewModels/AuthManager.swift +++ b/10x-macos/ViewModels/AuthManager.swift @@ -57,11 +57,13 @@ final class AuthManager { var userId: String? var userEmail: String? var authError: String? + var isGuestMode = false private let tokenKey = "tenx_access_token" private let refreshTokenKey = "tenx_refresh_token" private let userIdKey = "tenx_user_id" private let userEmailKey = "tenx_user_email" + private let guestModeKey = "tenx_guest_mode" private let callbackScheme = "app.10x.macos" private let callbackURLString = "app.10x.macos://auth/callback" private let authTokenStore = AuthTokenStore() @@ -80,9 +82,12 @@ final class AuthManager { } init() { - startAuthStateObserver() + if Config.supabaseConfigured { + startAuthStateObserver() + } - if let saved = authTokenStore.string(for: tokenKey, allowUserInteraction: false), !saved.isEmpty { + if Config.supabaseConfigured, + let saved = authTokenStore.string(for: tokenKey, allowUserInteraction: false), !saved.isEmpty { accessToken = saved refreshToken = authTokenStore.string(for: refreshTokenKey, allowUserInteraction: false) userId = UserDefaults.standard.string(forKey: userIdKey) @@ -107,17 +112,48 @@ final class AuthManager { } } } + + if !isAuthenticated, UserDefaults.standard.bool(forKey: guestModeKey) { + isGuestMode = true + isAuthenticated = true + } } func signInWithGoogle() { + isGuestMode = false + UserDefaults.standard.set(false, forKey: guestModeKey) startOAuthSignIn(provider: .google) } + func skipSignIn() { + webAuthSession?.cancel() + activeSignInProvider = nil + accessToken = nil + refreshToken = nil + userId = nil + userEmail = nil + authError = nil + isGuestMode = true + isAuthenticated = true + + authTokenStore.remove(tokenKey) + authTokenStore.remove(refreshTokenKey) + UserDefaults.standard.removeObject(forKey: userIdKey) + UserDefaults.standard.removeObject(forKey: userEmailKey) + UserDefaults.standard.set(true, forKey: guestModeKey) + } + private func startOAuthSignIn(provider: SignInProvider) { guard activeSignInProvider == nil || activeSignInProvider == provider else { return } activeSignInProvider = provider currentAppleNonce = nil + guard Config.supabaseConfigured else { + activeSignInProvider = nil + authError = "Supabase is not configured. Add SUPABASE_URL and SUPABASE_ANON_KEY, or continue without signing in." + return + } + let supabaseURL = Config.supabaseURL guard !supabaseURL.isEmpty else { activeSignInProvider = nil @@ -208,6 +244,9 @@ final class AuthManager { } func signInWithApple() { + isGuestMode = false + UserDefaults.standard.set(false, forKey: guestModeKey) + if !Config.useNativeAppleSignIn { startOAuthSignIn(provider: .apple) return @@ -384,6 +423,9 @@ final class AuthManager { } func refreshSession() async -> Bool { + guard Config.supabaseConfigured else { + return false + } guard let refresh = refreshToken else { return false } @@ -424,6 +466,22 @@ final class AuthManager { return nil } + func generationAccessToken() async -> String? { + if LLMConnectionService.shared.hasActiveDirectConnection && (isGuestMode || accessToken?.isEmpty != false) { + return LLMConnectionService.localDirectAccessToken + } + + if let token = await validAccessToken() { + return token + } + + if LLMConnectionService.shared.hasActiveDirectConnection { + return LLMConnectionService.localDirectAccessToken + } + + return nil + } + private func fetchUser(accessToken: String) async { let supabaseURL = Config.supabaseURL guard !supabaseURL.isEmpty else { return } @@ -471,6 +529,8 @@ final class AuthManager { self.userId = userId self.userEmail = userEmail self.authError = nil + self.isGuestMode = false + UserDefaults.standard.set(false, forKey: guestModeKey) authTokenStore.set(accessToken, for: tokenKey) authTokenStore.set(refreshToken, for: refreshTokenKey) @@ -538,6 +598,7 @@ final class AuthManager { } private func syncSessionToSupabaseSDK(accessToken: String, refreshToken: String?) async { + guard Config.supabaseConfigured else { return } guard let refreshToken, !refreshToken.isEmpty else { return } do { @@ -553,6 +614,7 @@ final class AuthManager { } private func startAuthStateObserver() { + guard Config.supabaseConfigured else { return } authStateTask?.cancel() authStateTask = Task { [weak self] in guard let self else { return } @@ -625,6 +687,9 @@ final class AuthManager { } private static func requestRefreshedSession(refreshToken: String) async throws -> RefreshedSessionPayload { + guard Config.supabaseConfigured else { + throw StartupAuthError.invalidConfiguration + } let supabaseURL = Config.supabaseURL guard !supabaseURL.isEmpty, let url = URL(string: "\(supabaseURL)/auth/v1/token?grant_type=refresh_token") else { @@ -780,6 +845,7 @@ final class AuthManager { userId = nil userEmail = nil isAuthenticated = false + isGuestMode = false authError = nil webAuthSession = nil @@ -787,6 +853,7 @@ final class AuthManager { authTokenStore.remove(refreshTokenKey) UserDefaults.standard.removeObject(forKey: userIdKey) UserDefaults.standard.removeObject(forKey: userEmailKey) + UserDefaults.standard.set(false, forKey: guestModeKey) } } diff --git a/10x-macos/ViewModels/BuilderViewModel+Chats.swift b/10x-macos/ViewModels/BuilderViewModel+Chats.swift index 723dc14..6229277 100644 --- a/10x-macos/ViewModels/BuilderViewModel+Chats.swift +++ b/10x-macos/ViewModels/BuilderViewModel+Chats.swift @@ -432,6 +432,7 @@ extension BuilderViewModel { !chat.hasGeneratedTitle, !titleRequestsInFlight.contains(chat.id), let accessToken = sessionAccessToken, + accessToken != LLMConnectionService.localDirectAccessToken, let query = firstUserQuery(in: messages) else { return diff --git a/10x-macos/ViewModels/BuilderViewModel+Generation.swift b/10x-macos/ViewModels/BuilderViewModel+Generation.swift index 4cadf9a..66924e9 100644 --- a/10x-macos/ViewModels/BuilderViewModel+Generation.swift +++ b/10x-macos/ViewModels/BuilderViewModel+Generation.swift @@ -51,6 +51,78 @@ extension BuilderViewModel { return trimmed.isEmpty ? accessToken : trimmed } + private var isUsingLocalDirectLLM: Bool { + sessionAccessToken == LLMConnectionService.localDirectAccessToken + } + + private static func formattedQuestionnaireAnswers(from queue: QuestionQueue) -> String { + var lines = ["Questionnaire responses. Use these answers as the user's complete clarification set and do not re-ask these same questions:"] + + for (index, question) in queue.questions.enumerated() { + let answer = queue.answers[question.question]?.trimmingCharacters(in: .whitespacesAndNewlines) + guard let answer, !answer.isEmpty else { continue } + lines.append("\(index + 1). \(question.question)\nAnswer: \(answer)") + } + + return lines.joined(separator: "\n\n") + } + + private static func localWebSearch(query: String) async throws -> String { + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return "No search query provided." } + guard let encoded = trimmed.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed), + let url = URL(string: "https://duckduckgo.com/html/?q=\(encoded)") + else { + return "Invalid search query." + } + + var request = URLRequest(url: url) + request.timeoutInterval = 15 + request.setValue("Mozilla/5.0", forHTTPHeaderField: "User-Agent") + + let (data, _) = try await URLSession.shared.data(for: request) + let html = String(decoding: data, as: UTF8.self) + let text = strippedHTML(html) + let lines = text + .components(separatedBy: .newlines) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + .prefix(40) + .joined(separator: "\n") + + return lines.isEmpty ? "No search results found for \(trimmed)." : "Search results for \(trimmed):\n\(lines)" + } + + private static func localScrapeURL(_ rawURL: String) async throws -> String { + let trimmed = rawURL.trimmingCharacters(in: .whitespacesAndNewlines) + guard let url = URL(string: trimmed), ["http", "https"].contains(url.scheme?.lowercased()) else { + return "Invalid URL." + } + + var request = URLRequest(url: url) + request.timeoutInterval = 20 + request.setValue("Mozilla/5.0", forHTTPHeaderField: "User-Agent") + + let (data, _) = try await URLSession.shared.data(for: request) + let html = String(decoding: data, as: UTF8.self) + let text = strippedHTML(html) + return String(text.prefix(12_000)) + } + + private static func strippedHTML(_ html: String) -> String { + html + .replacingOccurrences(of: #"(?is)"#, with: "\n", options: .regularExpression) + .replacingOccurrences(of: #"(?is)"#, with: "\n", options: .regularExpression) + .replacingOccurrences(of: #"<[^>]+>"#, with: "\n", options: .regularExpression) + .replacingOccurrences(of: "&", with: "&") + .replacingOccurrences(of: """, with: "\"") + .replacingOccurrences(of: "'", with: "'") + .replacingOccurrences(of: "<", with: "<") + .replacingOccurrences(of: ">", with: ">") + .replacingOccurrences(of: #"\n{3,}"#, with: "\n\n", options: .regularExpression) + .trimmingCharacters(in: .whitespacesAndNewlines) + } + func retryLastMessage(accessToken: String) { guard let failedRequest = lastFailedRequest else { return } lastFailedRequest = nil @@ -251,17 +323,7 @@ extension BuilderViewModel { if queue.isComplete { questionQueue = nil - let answerText: String - if queue.answers.count == 1, let only = queue.answers.values.first { - answerText = only - } else { - answerText = queue.questions - .compactMap { q in - guard let a = queue.answers[q.question] else { return nil } - return "**\(q.question)**: \(a)" - } - .joined(separator: "\n\n") - } + let answerText = Self.formattedQuestionnaireAnswers(from: queue) if !activeSteps.isEmpty { let answeredSteps = finalizeAnsweredQuestionSteps(activeSteps) @@ -507,6 +569,8 @@ extension BuilderViewModel { } func persistToSupabase(projectId: String) async { + guard !isUsingLocalDirectLLM else { return } + do { let (_, conversationId) = try await Self.withTimeout( seconds: 5, @@ -903,6 +967,9 @@ extension BuilderViewModel { webSearchHandler: { query in do { let accessToken = await currentAccessToken() + if accessToken == LLMConnectionService.localDirectAccessToken { + return try await Self.localWebSearch(query: query) + } let response: [String: String] = try await api.post( APIClient.builder("web-search"), json: ["query": query], @@ -916,6 +983,9 @@ extension BuilderViewModel { urlScrapeHandler: { url in do { let accessToken = await currentAccessToken() + if accessToken == LLMConnectionService.localDirectAccessToken { + return try await Self.localScrapeURL(url) + } let response: [String: String] = try await api.post( APIClient.builder("scrape-url"), json: ["url": url], @@ -946,7 +1016,9 @@ extension BuilderViewModel { ) let promptAccessToken = await currentAccessToken() - let skillsCatalogSection = await skillsManager.catalogSection(accessToken: promptAccessToken) + let skillsCatalogSection = promptAccessToken == LLMConnectionService.localDirectAccessToken + ? nil + : await skillsManager.catalogSection(accessToken: promptAccessToken) let systemPrompt = BuilderPrompts.systemPrompt(mode: mode) let promptContext = BuilderPrompts.messageContext( @@ -1180,7 +1252,7 @@ extension BuilderViewModel { case .toolCallStart(let toolUseId, let name): if !currentSegmentContent.isEmpty { - if !suppressIntermediateAssistantText || name == "ask_user" { + if !suppressIntermediateAssistantText && name != "ask_user" { appendAssistantSegmentMessage(currentSegmentContent) } currentSegmentContent = "" @@ -1295,11 +1367,6 @@ extension BuilderViewModel { } integrationApproval = nil questionQueue = QuestionQueue(questions: parsed, toolUseId: toolUseId) - if !currentSegmentContent.isEmpty { - if !suppressIntermediateAssistantText { - appendAssistantSegmentMessage(currentSegmentContent) - } - } finalizePendingDependencyChecklistAnchorIfNeeded() pendingAssistantContent = "" currentSegmentContent = "" diff --git a/10x-macos/ViewModels/BuilderViewModel+Projects.swift b/10x-macos/ViewModels/BuilderViewModel+Projects.swift index 79171e0..7d7a5ab 100644 --- a/10x-macos/ViewModels/BuilderViewModel+Projects.swift +++ b/10x-macos/ViewModels/BuilderViewModel+Projects.swift @@ -2,7 +2,19 @@ import AppKit import Foundation extension BuilderViewModel { + private var isLocalDirectMode: Bool { + sessionAccessToken == LLMConnectionService.localDirectAccessToken + } + func loadProjects(accessToken: String) async { + guard accessToken != LLMConnectionService.localDirectAccessToken else { + let localProjects = await localStore.loadLocalProjects() + projects = localProjects.filter { $0.status != "archived" } + archivedProjects = localProjects.filter { $0.status == "archived" } + hasLoadedProjects = true + return + } + isLoadingProjects = true defer { isLoadingProjects = false @@ -22,6 +34,12 @@ extension BuilderViewModel { } func loadAvailableSkills(accessToken: String) async { + guard accessToken != LLMConnectionService.localDirectAccessToken else { + availableSkills = [] + isLoadingSkills = false + return + } + guard !accessToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { availableSkills = [] isLoadingSkills = false @@ -39,6 +57,18 @@ extension BuilderViewModel { } func archiveProject(_ project: BuilderProject) async { + if isLocalDirectMode { + let archivedProject = Self.updatedProject(project, status: "archived") + projects.removeAll { $0.id == project.id } + upsertProject(archivedProject, in: &archivedProjects) + if activeProject?.id == archivedProject.id { + activeProject = archivedProject + await saveLocally(touchChat: false) + } + await localStore.saveProjectMetadata(archivedProject) + return + } + do { let archivedProject = try await supabase.archiveProject(id: project.id) projects.removeAll { $0.id == project.id } @@ -52,6 +82,18 @@ extension BuilderViewModel { } func unarchiveProject(_ project: BuilderProject) async { + if isLocalDirectMode { + let restoredProject = Self.updatedProject(project, status: "draft") + archivedProjects.removeAll { $0.id == project.id } + upsertProject(restoredProject, in: &projects) + if activeProject?.id == restoredProject.id { + activeProject = restoredProject + await saveLocally(touchChat: false) + } + await localStore.saveProjectMetadata(restoredProject) + return + } + do { let restoredProject = try await supabase.unarchiveProject(id: project.id) archivedProjects.removeAll { $0.id == project.id } @@ -65,6 +107,30 @@ extension BuilderViewModel { } func deleteProject(_ project: BuilderProject) async { + if isLocalDirectMode { + projects.removeAll { $0.id == project.id } + archivedProjects.removeAll { $0.id == project.id } + if activeProject?.id == project.id { + activeProject = nil + localProjectPath = nil + previewScreenshot = nil + lastPreviewedFileTreeRevision = nil + projectWarnings = [] + productionChecklistState = .empty + appStoreReviewState = .empty + appStoreReviewStatus = nil + appStoreReviewError = nil + appStoreSubmissionDraft = .empty + appStoreSubmissionStatus = nil + appStoreSubmissionError = nil + isGeneratingAppStoreSubmission = false + isPublishingAppStoreSubmission = false + reviewAssetImageCache = [:] + } + await localStore.deleteProjectData(projectName: project.name, projectId: project.id) + return + } + do { try await supabase.deleteProject(id: project.id) projects.removeAll { $0.id == project.id } @@ -109,7 +175,15 @@ extension BuilderViewModel { do { let finalProject: BuilderProject if trimmedName != project.name { - finalProject = try await supabase.updateProject(id: project.id, data: UpdateProjectData(name: trimmedName)) + if isLocalDirectMode { + finalProject = Self.updatedProject( + project, + name: trimmedName, + slug: Self.slug(from: trimmedName) + ) + } else { + finalProject = try await supabase.updateProject(id: project.id, data: UpdateProjectData(name: trimmedName)) + } } else { finalProject = project } @@ -139,6 +213,9 @@ extension BuilderViewModel { } replaceProject(finalProject) + if isLocalDirectMode { + await localStore.saveProjectMetadata(finalProject) + } if activeProject?.id == project.id { projectIcon = customIcon @@ -171,6 +248,29 @@ extension BuilderViewModel { } func createProject(name: String, accessToken: String) async { + if accessToken == LLMConnectionService.localDirectAccessToken { + let now = ISO8601DateFormatter().string(from: Date()) + let safeName = name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "Untitled App" : name + let project = BuilderProject( + id: UUID().uuidString, + userId: "local-direct", + name: safeName, + description: nil, + slug: Self.slug(from: safeName), + platform: "ios", + status: "draft", + currentVersionId: nil, + settings: nil, + createdAt: now, + updatedAt: now + ) + projects.insert(project, at: 0) + await localStore.saveProjectMetadata(project) + selectProject(project, accessToken: accessToken) + await saveLocally(touchChat: false) + return + } + do { let userId = Self.userIdFromJWT(accessToken) guard let userId else { @@ -187,14 +287,61 @@ extension BuilderViewModel { } } - func importExistingProject(from selectionURL: URL, accessToken: String) async throws -> BuilderProject { - guard let userId = Self.userIdFromJWT(accessToken) else { - throw ExistingProjectImportError.invalidSelection("You need a valid session before importing a project.") + private static func slug(from name: String) -> String { + let scalars = name.lowercased().unicodeScalars.map { scalar -> Character in + CharacterSet.alphanumerics.contains(scalar) ? Character(scalar) : "-" } + let collapsed = String(scalars) + .split(separator: "-") + .joined(separator: "-") + return collapsed.isEmpty ? "untitled-app" : collapsed + } + func importExistingProject(from selectionURL: URL, accessToken: String) async throws -> BuilderProject { let selection = try await projectImporter.resolveSelection(at: selectionURL) try await projectImporter.validateSwiftUISources(at: selection.rootURL) + if accessToken == LLMConnectionService.localDirectAccessToken { + let now = ISO8601DateFormatter().string(from: Date()) + let baseProject = BuilderProject( + id: UUID().uuidString, + userId: "local-direct", + name: selection.displayName, + description: nil, + slug: Self.slug(from: selection.displayName), + platform: "ios", + status: "draft", + currentVersionId: nil, + settings: nil, + createdAt: now, + updatedAt: now + ) + let projectRoot = try await previewService.scaffoldProjectDirectory( + projectName: baseProject.name, + projectId: baseProject.id + ) + let importResult = try await projectImporter.importProject( + from: selection, + into: projectRoot + ) + let project = Self.updatedProject( + baseProject, + settings: importResult.metadata.settingsDictionary + ) + await localStore.saveFileTree( + importResult.fileTree, + projectName: project.name, + projectId: project.id + ) + await localStore.saveProjectMetadata(project) + upsertProject(project, in: &projects) + return project + } + + guard let userId = Self.userIdFromJWT(accessToken) else { + throw ExistingProjectImportError.invalidSelection("You need a valid session before importing a project.") + } + let createdProject = try await supabase.createProject( userId: userId, name: selection.displayName @@ -407,6 +554,7 @@ extension BuilderViewModel { private func loadProjectData(projectId: String) async { guard let projectName = activeProject?.name else { return } + let isLocalDirectProject = sessionAccessToken == LLMConnectionService.localDirectAccessToken projectIcon = await localStore.loadCustomIcon(projectName: projectName, projectId: projectId) let projectDir = await previewService.projectDir(for: projectName, projectId: projectId) @@ -445,6 +593,7 @@ extension BuilderViewModel { let hadLocalTree = hasLocalTree async let messagesTask: [BuilderMessage] = { + guard !isLocalDirectProject else { return [] } do { let (msgs, _) = try await supabase.fetchConversation(projectId: projectId) return msgs @@ -455,6 +604,7 @@ extension BuilderViewModel { }() async let versionsTask: [BuilderVersion] = { + guard !isLocalDirectProject else { return [] } do { return try await supabase.fetchVersions(projectId: projectId) } catch { @@ -594,6 +744,28 @@ extension BuilderViewModel { list.insert(updated, at: 0) } + private static func updatedProject( + _ project: BuilderProject, + name: String? = nil, + slug: String? = nil, + status: String? = nil, + settings: [String: AnyCodableValue]? = nil + ) -> BuilderProject { + BuilderProject( + id: project.id, + userId: project.userId, + name: name ?? project.name, + description: project.description, + slug: slug ?? project.slug, + platform: project.platform, + status: status ?? project.status, + currentVersionId: project.currentVersionId, + settings: settings ?? project.settings, + createdAt: project.createdAt, + updatedAt: ISO8601DateFormatter().string(from: Date()) + ) + } + func focusEnvironmentIntegration(_ integrationID: ProjectIntegrationID?) { pendingEnvironmentIntegrationFocus = integrationID viewMode = .environment @@ -616,14 +788,20 @@ extension BuilderViewModel { mergedSettings.removeValue(forKey: BuilderProject.dependencyManifestSettingsKey) } - do { - let updatedProject = try await supabase.updateProject( - id: project.id, - data: UpdateProjectData(settings: mergedSettings.isEmpty ? nil : mergedSettings) - ) + if isLocalDirectMode { + let updatedProject = Self.updatedProject(project, settings: mergedSettings.isEmpty ? nil : mergedSettings) replaceProject(updatedProject) - } catch { - print("Failed to save dependency manifest: \(error)") + await localStore.saveProjectMetadata(updatedProject) + } else { + do { + let updatedProject = try await supabase.updateProject( + id: project.id, + data: UpdateProjectData(settings: mergedSettings.isEmpty ? nil : mergedSettings) + ) + replaceProject(updatedProject) + } catch { + print("Failed to save dependency manifest: \(error)") + } } await saveLocally(touchChat: false) @@ -641,14 +819,20 @@ extension BuilderViewModel { mergedSettings.removeValue(forKey: BuilderProject.backendStateSettingsKey) } - do { - let updatedProject = try await supabase.updateProject( - id: project.id, - data: UpdateProjectData(settings: mergedSettings.isEmpty ? nil : mergedSettings) - ) + if isLocalDirectMode { + let updatedProject = Self.updatedProject(project, settings: mergedSettings.isEmpty ? nil : mergedSettings) replaceProject(updatedProject) - } catch { - print("Failed to save backend state: \(error)") + await localStore.saveProjectMetadata(updatedProject) + } else { + do { + let updatedProject = try await supabase.updateProject( + id: project.id, + data: UpdateProjectData(settings: mergedSettings.isEmpty ? nil : mergedSettings) + ) + replaceProject(updatedProject) + } catch { + print("Failed to save backend state: \(error)") + } } await saveLocally(touchChat: false) @@ -666,14 +850,20 @@ extension BuilderViewModel { mergedSettings.removeValue(forKey: BuilderProject.superwallStateSettingsKey) } - do { - let updatedProject = try await supabase.updateProject( - id: project.id, - data: UpdateProjectData(settings: mergedSettings.isEmpty ? nil : mergedSettings) - ) + if isLocalDirectMode { + let updatedProject = Self.updatedProject(project, settings: mergedSettings.isEmpty ? nil : mergedSettings) replaceProject(updatedProject) - } catch { - print("Failed to save Superwall state: \(error)") + await localStore.saveProjectMetadata(updatedProject) + } else { + do { + let updatedProject = try await supabase.updateProject( + id: project.id, + data: UpdateProjectData(settings: mergedSettings.isEmpty ? nil : mergedSettings) + ) + replaceProject(updatedProject) + } catch { + print("Failed to save Superwall state: \(error)") + } } await saveLocally(touchChat: false) diff --git a/10x-macos/Views/Auth/LoginView.swift b/10x-macos/Views/Auth/LoginView.swift index 1880308..1076fb7 100644 --- a/10x-macos/Views/Auth/LoginView.swift +++ b/10x-macos/Views/Auth/LoginView.swift @@ -317,6 +317,35 @@ struct LoginView: View { .disabled(isAuthenticating) .opacity(isAuthenticating && !googleInFlight ? 0.6 : 1) + Button { + auth.skipSignIn() + } label: { + HStack(spacing: 8) { + Image(systemName: "arrow.right.circle") + .font(.system(size: 13, weight: .semibold)) + + Text("Continue without signing in") + .font(Theme.geist(13, weight: .medium)) + } + .foregroundStyle(Theme.textPrimary) + .frame(maxWidth: .infinity) + .frame(height: Self.authButtonHeight) + .contentShape( + RoundedRectangle(cornerRadius: Self.authButtonCornerRadius, style: .continuous) + ) + } + .buttonStyle(.plain) + .background( + RoundedRectangle(cornerRadius: Self.authButtonCornerRadius, style: .continuous) + .fill(Color.white.opacity(0.06)) + ) + .overlay( + RoundedRectangle(cornerRadius: Self.authButtonCornerRadius, style: .continuous) + .stroke(Color.white.opacity(0.10), lineWidth: 1) + ) + .disabled(isAuthenticating) + .opacity(isAuthenticating ? 0.6 : 1) + if let status = auth.signInStatusMessage { HStack(spacing: 8) { ProgressView() diff --git a/10x-macos/Views/Chat/ChatInputView.swift b/10x-macos/Views/Chat/ChatInputView.swift index 2e805d9..f068142 100644 --- a/10x-macos/Views/Chat/ChatInputView.swift +++ b/10x-macos/Views/Chat/ChatInputView.swift @@ -295,7 +295,7 @@ struct ChatInputView: View { syncPickerPresentations() } .task(id: auth.accessToken) { - guard let token = await auth.validAccessToken() else { return } + guard let token = await auth.generationAccessToken() else { return } await viewModel.loadAvailableSkills(accessToken: token) syncPickerPresentations() } @@ -754,7 +754,7 @@ struct ChatInputView: View { if let composerError, !composerError.isEmpty { return } - if billing.totalCredits <= 0 { + if billing.totalCredits <= 0 && !LLMConnectionService.shared.hasActiveDirectConnection { composerError = "You’re out of credits. Get a plan or credit pack to continue." openPlansAndPacks() return @@ -765,7 +765,10 @@ struct ChatInputView: View { } Task { @MainActor in - guard let token = await auth.validAccessToken() else { return } + guard let token = await auth.generationAccessToken() else { + composerError = "Sign in or activate a direct LLM connection to continue." + return + } let draftText = preparedDraft.text if let error = viewModel.sendMessage( diff --git a/10x-macos/Views/Chat/ChatPanelView.swift b/10x-macos/Views/Chat/ChatPanelView.swift index b2defc5..7ff42d3 100644 --- a/10x-macos/Views/Chat/ChatPanelView.swift +++ b/10x-macos/Views/Chat/ChatPanelView.swift @@ -17,6 +17,10 @@ struct ChatPanelView: View { private let chatHeaderFadeHeight: CGFloat = 64 private let composerHeightChangeThreshold: CGFloat = 1 + private var canUseGenerationActions: Bool { + auth.isAuthenticated || LLMConnectionService.shared.hasActiveDirectConnection + } + var body: some View { ZStack(alignment: .bottom) { VStack(spacing: 0) { @@ -41,7 +45,6 @@ struct ChatPanelView: View { confirmPlanSection activeGenerationSection liveBuildFixSection - questionSection resumeSection dependencyReminderSection @@ -79,9 +82,6 @@ struct ChatPanelView: View { guard shouldFollowLive else { return } scheduleScrollToBottom(with: proxy) } - .onChange(of: viewModel.questionQueue?.currentIndex) { - proxy.scrollTo("question", anchor: .bottom) - } .onChange(of: viewModel.isGenerating) { _, generating in if generating { shouldFollowLive = true @@ -133,6 +133,8 @@ struct ChatPanelView: View { } } } + + questionModalOverlay } .background(Theme.surfaceInset) .overlay(alignment: .bottom) { @@ -635,10 +637,10 @@ struct ChatPanelView: View { Spacer() - if auth.isAuthenticated { + if canUseGenerationActions { Button { Task { @MainActor in - guard let token = await auth.validAccessToken() else { return } + guard let token = await auth.generationAccessToken() else { return } _ = viewModel.sendMessage("I'd like to refine the plan. Let me give you some feedback.", accessToken: token) } } label: { @@ -657,7 +659,7 @@ struct ChatPanelView: View { Button { Task { @MainActor in - guard let token = await auth.validAccessToken() else { return } + guard let token = await auth.generationAccessToken() else { return } _ = viewModel.sendMessage( "The plan looks great. Start building the app now.", accessToken: token, @@ -751,17 +753,44 @@ struct ChatPanelView: View { // MARK: - Question @ViewBuilder - private var questionSection: some View { + private var questionModalOverlay: some View { if let queue = viewModel.questionQueue, let current = queue.currentQuestion { - InlineQuestionView( - question: current, - questionIndex: queue.currentIndex + 1, - totalQuestions: queue.totalCount, - currentAnswer: queue.answers[current.question], - canGoBack: queue.currentIndex > 0 - ) - .padding(.bottom, Theme.spacingSM) - .id("question") + ZStack { + Color.black.opacity(0.48) + .ignoresSafeArea() + + VStack(alignment: .leading, spacing: Theme.spacingMD) { + HStack(spacing: Theme.spacingSM) { + Image(systemName: "list.bullet.clipboard.fill") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(Theme.accent) + + Text("Quick Questionnaire") + .font(Theme.geist(15, weight: .semibold)) + .foregroundStyle(Theme.textPrimary) + + Spacer(minLength: 0) + } + + InlineQuestionView( + question: current, + questionIndex: queue.currentIndex + 1, + totalQuestions: queue.totalCount, + currentAnswer: queue.answers[current.question], + canGoBack: queue.currentIndex > 0 + ) + } + .padding(Theme.spacingLG) + .frame(maxWidth: 520) + .background { + RoundedRectangle(cornerRadius: Theme.radiusLG, style: .continuous) + .fill(Theme.surface) + .shadow(color: .black.opacity(0.28), radius: 28, x: 0, y: 18) + } + .padding(.horizontal, Theme.spacingXL) + } + .zIndex(2000) + .transition(.opacity.combined(with: .scale(scale: 0.98))) } } @@ -787,10 +816,10 @@ struct ChatPanelView: View { Spacer() - if auth.isAuthenticated { + if canUseGenerationActions { Button { Task { @MainActor in - guard let token = await auth.validAccessToken() else { return } + guard let token = await auth.generationAccessToken() else { return } _ = viewModel.sendMessage("Continue", accessToken: token) } } label: { @@ -858,10 +887,10 @@ struct ChatPanelView: View { .foregroundStyle(Theme.accent) } .buttonStyle(.plain) - } else if viewModel.lastFailedRequest != nil, auth.isAuthenticated { + } else if viewModel.lastFailedRequest != nil, canUseGenerationActions { Button { Task { @MainActor in - guard let token = await auth.validAccessToken() else { return } + guard let token = await auth.generationAccessToken() else { return } viewModel.retryLastMessage(accessToken: token) } } label: { diff --git a/10x-macos/Views/Chat/InlineQuestionView.swift b/10x-macos/Views/Chat/InlineQuestionView.swift index 7efcfff..b896f53 100644 --- a/10x-macos/Views/Chat/InlineQuestionView.swift +++ b/10x-macos/Views/Chat/InlineQuestionView.swift @@ -416,7 +416,7 @@ struct InlineQuestionView: View { guard !trimmedAnswer.isEmpty else { return } Task { @MainActor in - guard let token = await auth.validAccessToken() else { return } + guard let token = await auth.generationAccessToken() else { return } freeformInput = "" selectedOptions = [] selectedSingleOption = nil @@ -426,7 +426,7 @@ struct InlineQuestionView: View { private func skip() { Task { @MainActor in - guard let token = await auth.validAccessToken() else { return } + guard let token = await auth.generationAccessToken() else { return } viewModel.skipCurrentQuestion(accessToken: token) } } diff --git a/10x-macos/Views/Chat/IntegrationApprovalView.swift b/10x-macos/Views/Chat/IntegrationApprovalView.swift index c195f66..b31606d 100644 --- a/10x-macos/Views/Chat/IntegrationApprovalView.swift +++ b/10x-macos/Views/Chat/IntegrationApprovalView.swift @@ -101,7 +101,7 @@ struct IntegrationApprovalView: View { private func respond(approved: Bool) { Task { @MainActor in - guard let token = await auth.validAccessToken() else { return } + guard let token = await auth.generationAccessToken() else { return } viewModel.respondToIntegrationApproval(approved, accessToken: token) } } diff --git a/10x-macos/Views/HomeView.swift b/10x-macos/Views/HomeView.swift index 50c0a24..1adf05e 100644 --- a/10x-macos/Views/HomeView.swift +++ b/10x-macos/Views/HomeView.swift @@ -1015,10 +1015,10 @@ struct HomeView: View { isImportingProject = true Task { - guard let token = await auth.validAccessToken() else { + guard let token = await auth.generationAccessToken() else { await MainActor.run { isImportingProject = false - promptComposerError = "Sign in before importing an existing project." + promptComposerError = "Sign in or enable a direct LLM connection before importing an existing project." } return } @@ -1230,7 +1230,7 @@ struct HomeView: View { resumingDraft = nil Task { - guard let token = await auth.validAccessToken() else { return } + guard let token = await auth.generationAccessToken() else { return } if let project = draftProject { // Resuming from a draft — clean up draft file and use existing project await localStore.deleteOnboardingDraft(projectName: project.name, projectId: project.id) @@ -1256,7 +1256,7 @@ struct HomeView: View { resumingDraft = nil Task { - guard let token = await auth.validAccessToken() else { return } + guard let token = await auth.generationAccessToken() else { return } if let project = draftProject { await localStore.deleteOnboardingDraft(projectName: project.name, projectId: project.id) onOpenProject(project, text, nil, nil, attachments) @@ -1289,7 +1289,7 @@ struct HomeView: View { pendingPrompt = "" prompt = description Task { - guard let token = await auth.validAccessToken() else { return } + guard let token = await auth.generationAccessToken() else { return } await viewModel.createProject(name: name, accessToken: token) if let project = viewModel.activeProject { await localStore.saveOnboardingDraft(draft, projectName: project.name, projectId: project.id) diff --git a/10x-macos/Views/Preview/PreviewPanelView.swift b/10x-macos/Views/Preview/PreviewPanelView.swift index 30a6783..f942584 100644 --- a/10x-macos/Views/Preview/PreviewPanelView.swift +++ b/10x-macos/Views/Preview/PreviewPanelView.swift @@ -11,10 +11,10 @@ struct PreviewPanelView: View { } private var buildIssueFixAction: (() -> Void)? { - guard auth.isAuthenticated else { return nil } + guard auth.isAuthenticated || LLMConnectionService.shared.hasActiveDirectConnection else { return nil } return { Task { @MainActor in - guard let token = await auth.validAccessToken() else { return } + guard let token = await auth.generationAccessToken() else { return } viewModel.fixBuildError(accessToken: token) } } diff --git a/10x-macos/Views/Settings/ConnectionsSettingsView.swift b/10x-macos/Views/Settings/ConnectionsSettingsView.swift new file mode 100644 index 0000000..b9e3609 --- /dev/null +++ b/10x-macos/Views/Settings/ConnectionsSettingsView.swift @@ -0,0 +1,1016 @@ +import SwiftUI + +struct ConnectionsSettingsView: View { + @State private var service = LLMConnectionService.shared + @State private var showingAddSheet = false + @State private var editingConnection: LLMConnection? + @State private var testingConnectionIDs: Set = [] + @State private var testResults: [UUID: LLMConnectionTestResult] = [:] + + var body: some View { + SettingsPageContainer { + SettingsPageHeader("Connections") { + Button { + editingConnection = nil + showingAddSheet = true + } label: { + HStack(spacing: 6) { + Image(systemName: "plus") + .font(.system(size: 11, weight: .semibold)) + Text("Add Connection") + .font(Theme.geist(13, weight: .medium)) + } + .foregroundStyle(Theme.accent) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: Theme.radiusSM, style: .continuous) + .fill(Theme.accent.opacity(0.08)) + ) + .overlay( + RoundedRectangle(cornerRadius: Theme.radiusSM, style: .continuous) + .stroke(Theme.accent.opacity(0.2), lineWidth: 1) + ) + } + .buttonStyle(.plain) + } + + routingPanel + + if service.connections.isEmpty { + emptyState + } else { + connectionsList + } + } + .sheet(isPresented: $showingAddSheet) { + ConnectionEditorSheet(connection: editingConnection) + } + } + + private var routingPanel: some View { + SettingsPanel("Generation Routing") { + HStack(alignment: .center, spacing: Theme.spacingLG) { + Image(systemName: service.activeConnection == nil ? "cloud" : "bolt.horizontal.circle.fill") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(service.activeConnection == nil ? Theme.textSecondary : Theme.accent) + .frame(width: 28) + + VStack(alignment: .leading, spacing: 4) { + Text(service.activeConnection?.displayName ?? "Default 10x backend") + .font(Theme.geist(14, weight: .semibold)) + .foregroundStyle(Theme.textPrimary) + + Text(service.activeConnection == nil + ? "No direct provider is active. Requests use the hosted backend." + : "New generations use this direct provider before falling back to hosted billing flows.") + .font(Theme.geist(12)) + .foregroundStyle(Theme.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + + Spacer() + + if service.activeConnection != nil { + Button { + service.setActive(nil) + } label: { + Text("Use Backend") + .font(Theme.geist(12, weight: .medium)) + .foregroundStyle(Theme.textPrimary) + .padding(.horizontal, 12) + .padding(.vertical, 7) + .background( + RoundedRectangle(cornerRadius: Theme.radiusSM, style: .continuous) + .fill(Theme.surfaceInset) + ) + } + .buttonStyle(.plain) + } + } + } + } + + private var emptyState: some View { + SettingsPanel("Direct Providers") { + VStack(alignment: .leading, spacing: Theme.spacingXL) { + HStack(alignment: .top, spacing: Theme.spacingLG) { + ZStack { + Circle() + .fill(Theme.accent.opacity(0.10)) + Image(systemName: "network") + .font(.system(size: 24, weight: .semibold)) + .foregroundStyle(Theme.accent) + } + .frame(width: 56, height: 56) + + VStack(alignment: .leading, spacing: 6) { + Text("Bring your own LLM") + .font(Theme.geist(18, weight: .semibold)) + .foregroundStyle(Theme.textPrimary) + + Text("Connect OpenAI, Claude, OpenRouter, or a local Ollama server. Once a connection is active, generation streams directly through that provider.") + .font(Theme.geist(13)) + .foregroundStyle(Theme.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + } + + Button { + editingConnection = nil + showingAddSheet = true + } label: { + HStack(spacing: 8) { + Image(systemName: "plus") + .font(.system(size: 12, weight: .semibold)) + Text("Add First Connection") + .font(Theme.geist(13, weight: .semibold)) + } + .foregroundStyle(.black) + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: Theme.radiusSM, style: .continuous) + .fill(Theme.accent) + ) + } + .buttonStyle(.plain) + } + } + } + + private var connectionsList: some View { + VStack(alignment: .leading, spacing: Theme.spacingLG) { + providerSummaryStrip + + HStack(alignment: .firstTextBaseline) { + Text("Saved Connections") + .font(Theme.geist(16, weight: .semibold)) + .foregroundStyle(Theme.textPrimary) + + Spacer() + + Text("\(service.connections.count) total") + .font(Theme.geistMono(11, weight: .medium)) + .foregroundStyle(Theme.textTertiary) + } + + ForEach(service.connections) { connection in + connectionCard(connection) + } + } + } + + private var providerSummaryStrip: some View { + HStack(spacing: Theme.spacingSM) { + ForEach(LLMProvider.allCases) { provider in + providerSummaryItem(provider) + } + } + } + + private func providerSummaryItem(_ provider: LLMProvider) -> some View { + let count = service.connections.filter { $0.provider == provider }.count + let activeCount = service.connections.filter { $0.provider == provider && service.activeConnectionID == $0.id }.count + + return HStack(spacing: 8) { + Image(systemName: provider.iconName) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(activeCount > 0 ? Theme.accent : Theme.textSecondary) + .frame(width: 16) + + VStack(alignment: .leading, spacing: 1) { + Text(provider.displayName) + .font(Theme.geist(11, weight: .semibold)) + .foregroundStyle(Theme.textPrimary) + .lineLimit(1) + + Text("\(count)") + .font(Theme.geistMono(10, weight: .medium)) + .foregroundStyle(Theme.textTertiary) + } + + Spacer(minLength: 0) + } + .padding(.horizontal, 10) + .padding(.vertical, 9) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: Theme.radiusSM, style: .continuous) + .fill(activeCount > 0 ? Theme.accent.opacity(0.08) : Theme.surfaceInset) + ) + .overlay( + RoundedRectangle(cornerRadius: Theme.radiusSM, style: .continuous) + .stroke(activeCount > 0 ? Theme.accent.opacity(0.20) : Theme.separator, lineWidth: 1) + ) + } + + private func connectionCard(_ connection: LLMConnection) -> some View { + let isSelected = service.activeConnectionID == connection.id + let isReady = service.isUsable(connection) + let validation = service.validationMessage(for: connection) + let isTesting = testingConnectionIDs.contains(connection.id) + let result = testResults[connection.id] + + return VStack(alignment: .leading, spacing: Theme.spacingLG) { + HStack(alignment: .top, spacing: Theme.spacingLG) { + providerIcon(connection.provider, highlighted: isSelected && isReady) + + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .firstTextBaseline, spacing: Theme.spacingSM) { + Text(connection.displayName) + .font(Theme.geist(15, weight: .semibold)) + .foregroundStyle(Theme.textPrimary) + .lineLimit(1) + + connectionChip(connection.provider.displayName, color: Theme.textSecondary, filled: false) + + if isSelected && isReady { + connectionChip("Active", color: Theme.accent, filled: true) + } else if isSelected { + connectionChip("Selected", color: Theme.warning, filled: true) + } else if !isReady { + connectionChip("Needs Setup", color: Theme.warning, filled: true) + } else { + connectionChip("Ready", color: Theme.success, filled: false) + } + } + + connectionMetadataGrid(connection, validation: validation) + } + + Spacer() + + Menu { + if !isSelected { + Button { + service.setActive(connection) + } label: { + Label("Set as Active", systemImage: "checkmark.circle") + } + .disabled(!isReady) + } else { + Button { + service.setActive(nil) + } label: { + Label("Deactivate", systemImage: "xmark.circle") + } + } + + Button { + testConnection(connection) + } label: { + Label("Test Connection", systemImage: "antenna.radiowaves.left.and.right") + } + .disabled(isTesting) + + Button { + editingConnection = connection + showingAddSheet = true + } label: { + Label("Edit", systemImage: "pencil") + } + + Button(role: .destructive) { + service.delete(connection) + } label: { + Label("Delete", systemImage: "trash") + } + } label: { + Image(systemName: "ellipsis") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(Theme.textTertiary) + .frame(width: 32, height: 32) + .background( + RoundedRectangle(cornerRadius: Theme.radiusSM, style: .continuous) + .fill(Theme.surfaceInset) + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + } + + if let result { + connectionTestBanner(result) + } + + HStack(spacing: Theme.spacingSM) { + Button { + if isSelected { + service.setActive(nil) + } else { + service.setActive(connection) + } + } label: { + Label(isSelected ? "Deactivate" : "Use for Generation", systemImage: isSelected ? "power" : "bolt.horizontal") + } + .buttonStyle(ConnectionActionButtonStyle(kind: isSelected ? .secondary : .primary)) + .disabled(!isReady && !isSelected) + + Button { + testConnection(connection) + } label: { + if isTesting { + Label("Testing...", systemImage: "hourglass") + } else { + Label("Test", systemImage: "antenna.radiowaves.left.and.right") + } + } + .buttonStyle(ConnectionActionButtonStyle(kind: .secondary)) + .disabled(isTesting) + + Button { + editingConnection = connection + showingAddSheet = true + } label: { + Label("Edit", systemImage: "pencil") + } + .buttonStyle(ConnectionActionButtonStyle(kind: .secondary)) + + Spacer() + } + } + .padding(16) + .background( + RoundedRectangle(cornerRadius: Theme.radiusMD, style: .continuous) + .fill(isSelected ? Theme.accent.opacity(0.055) : Theme.surfaceElevated) + ) + .overlay( + RoundedRectangle(cornerRadius: Theme.radiusMD, style: .continuous) + .stroke(isSelected ? Theme.accent.opacity(0.22) : Theme.separator, lineWidth: 1) + ) + } + + private func providerIcon(_ provider: LLMProvider, highlighted: Bool) -> some View { + ZStack { + RoundedRectangle(cornerRadius: Theme.radiusSM, style: .continuous) + .fill(highlighted ? Theme.accent.opacity(0.12) : Theme.surfaceInset) + Image(systemName: provider.iconName) + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(highlighted ? Theme.accent : Theme.textSecondary) + } + .frame(width: 44, height: 44) + .overlay( + RoundedRectangle(cornerRadius: Theme.radiusSM, style: .continuous) + .stroke(highlighted ? Theme.accent.opacity(0.22) : Theme.separator, lineWidth: 1) + ) + } + + private func connectionMetadataGrid(_ connection: LLMConnection, validation: String?) -> some View { + VStack(alignment: .leading, spacing: 7) { + HStack(spacing: Theme.spacingSM) { + metadataItem("Model", value: connection.model, monospace: true) + metadataItem("Key", value: service.hasAPIKey(for: connection) ? "Saved" : "None") + } + + HStack(spacing: Theme.spacingSM) { + metadataItem("Base URL", value: connection.effectiveBaseURL, monospace: true) + metadataItem("State", value: connection.isEnabled ? "Enabled" : "Disabled") + } + + if let validation { + HStack(spacing: 6) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 10, weight: .semibold)) + Text(validation) + .font(Theme.geist(11, weight: .medium)) + } + .foregroundStyle(Theme.warning) + .padding(.top, 2) + } + } + } + + private func metadataItem(_ label: String, value: String, monospace: Bool = false) -> some View { + VStack(alignment: .leading, spacing: 2) { + Text(label.uppercased()) + .font(Theme.geistMono(9, weight: .semibold)) + .foregroundStyle(Theme.textTertiary) + + Text(value.isEmpty ? "Not set" : value) + .font(monospace ? Theme.geistMono(11, weight: .medium) : Theme.geist(11, weight: .medium)) + .foregroundStyle(Theme.textSecondary) + .lineLimit(1) + .truncationMode(.middle) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func connectionChip(_ text: String, color: Color, filled: Bool) -> some View { + HStack(spacing: 4) { + Circle() + .fill(color) + .frame(width: 6, height: 6) + Text(text) + .font(Theme.geistMono(10, weight: .semibold)) + .foregroundStyle(color) + } + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background( + RoundedRectangle(cornerRadius: 4, style: .continuous) + .fill(filled ? color.opacity(0.1) : Color.clear) + ) + .overlay( + RoundedRectangle(cornerRadius: 4, style: .continuous) + .stroke(color.opacity(filled ? 0 : 0.18), lineWidth: 1) + ) + } + + private func connectionTestBanner(_ result: LLMConnectionTestResult) -> some View { + HStack(alignment: .top, spacing: Theme.spacingSM) { + Image(systemName: result.isSuccess ? "checkmark.circle.fill" : "exclamationmark.triangle.fill") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(result.isSuccess ? Theme.success : Theme.warning) + .padding(.top, 1) + + Text(result.message) + .font(Theme.geist(12, weight: .medium)) + .foregroundStyle(result.isSuccess ? Theme.textSecondary : Theme.warning) + .fixedSize(horizontal: false, vertical: true) + } + .padding(Theme.spacingMD) + .background( + RoundedRectangle(cornerRadius: Theme.radiusSM, style: .continuous) + .fill((result.isSuccess ? Theme.success : Theme.warning).opacity(0.08)) + ) + .overlay( + RoundedRectangle(cornerRadius: Theme.radiusSM, style: .continuous) + .stroke((result.isSuccess ? Theme.success : Theme.warning).opacity(0.18), lineWidth: 1) + ) + } + + private func testConnection(_ connection: LLMConnection) { + guard !testingConnectionIDs.contains(connection.id) else { return } + testingConnectionIDs.insert(connection.id) + testResults[connection.id] = nil + + Task { + let result = await service.test(connection) + await MainActor.run { + testResults[connection.id] = result + testingConnectionIDs.remove(connection.id) + } + } + } +} + +private struct ConnectionActionButtonStyle: ButtonStyle { + enum Kind { + case primary + case secondary + } + + let kind: Kind + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(Theme.geist(12, weight: .medium)) + .labelStyle(.titleAndIcon) + .foregroundStyle(kind == .primary ? .black : Theme.textPrimary) + .padding(.horizontal, 12) + .padding(.vertical, 7) + .background( + RoundedRectangle(cornerRadius: Theme.radiusSM, style: .continuous) + .fill(kind == .primary ? Theme.accent : Theme.surfaceInset) + ) + .overlay( + RoundedRectangle(cornerRadius: Theme.radiusSM, style: .continuous) + .stroke(kind == .primary ? Color.clear : Theme.separator, lineWidth: 1) + ) + .opacity(configuration.isPressed ? 0.72 : 1) + } +} + +// MARK: - Editor Sheet + +struct ConnectionEditorSheet: View { + let connection: LLMConnection? + @Environment(\.dismiss) private var dismiss + @State private var service = LLMConnectionService.shared + @State private var name = "" + @State private var provider: LLMProvider = .openai + @State private var apiKey = "" + @State private var baseURL = "" + @State private var model = "" + @State private var isEnabled = true + @State private var isTesting = false + @State private var testResult: LLMConnectionTestResult? + @State private var isFetchingModels = false + @State private var fetchedOllamaModels: [String] = [] + @State private var modelFetchMessage: String? + @FocusState private var focusedField: ConnectionField? + + private enum ConnectionField: Hashable { + case name, apiKey, baseURL, model + } + + private var isEditing: Bool { connection != nil } + + init(connection: LLMConnection? = nil) { + self.connection = connection + if let connection { + _name = State(initialValue: connection.name) + _provider = State(initialValue: connection.provider) + _baseURL = State(initialValue: connection.baseURL) + _model = State(initialValue: connection.model) + _isEnabled = State(initialValue: connection.isEnabled) + } + } + + var body: some View { + VStack(spacing: 0) { + header + formContent + footer + } + .frame(minWidth: 480, minHeight: 400) + .background(Theme.surface) + .onAppear { + if isEditing, let connection { + apiKey = LLMConnectionService.apiKey(for: connection.id) + } else { + applyProviderDefaults(for: provider, clearKey: false) + } + refreshModelsIfNeeded() + } + .onChange(of: baseURL) { _, _ in + guard provider == .ollama else { return } + fetchedOllamaModels = [] + modelFetchMessage = nil + } + } + + private var header: some View { + HStack { + Text(isEditing ? "Edit Connection" : "New Connection") + .font(Theme.geist(18, weight: .bold)) + .foregroundStyle(Theme.textPrimary) + + Spacer() + + Button { + dismiss() + } label: { + Image(systemName: "xmark") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(Theme.textSecondary) + .frame(width: 28, height: 28) + .background( + Circle() + .fill(Theme.surfaceElevated) + ) + .overlay( + Circle() + .stroke(Theme.separator, lineWidth: 1) + ) + } + .buttonStyle(.plain) + } + .padding(Theme.spacingXL) + .background(Theme.surface) + .overlay( + Rectangle() + .fill(Theme.separator) + .frame(height: 1), + alignment: .bottom + ) + } + + private var formContent: some View { + ScrollView { + VStack(alignment: .leading, spacing: Theme.spacingLG) { + providerPicker + providerHint + nameField + modelField + apiKeyField + baseURLField + enabledToggle + if let testResult { + testResultView(testResult) + } + } + .padding(Theme.spacingXL) + } + } + + private var providerPicker: some View { + VStack(alignment: .leading, spacing: Theme.spacingSM) { + Text("Provider") + .font(Theme.geist(13, weight: .semibold)) + .foregroundStyle(Theme.textPrimary) + + LazyVGrid( + columns: [ + GridItem(.flexible(), spacing: Theme.spacingSM), + GridItem(.flexible(), spacing: Theme.spacingSM) + ], + spacing: Theme.spacingSM + ) { + ForEach(LLMProvider.allCases) { option in + providerOptionButton(option) + } + } + } + } + + private func providerOptionButton(_ option: LLMProvider) -> some View { + let isSelected = provider == option + + return Button { + guard provider != option else { return } + provider = option + applyProviderDefaults(for: option, clearKey: true) + refreshModelsIfNeeded(for: option) + testResult = nil + } label: { + HStack(spacing: Theme.spacingSM) { + Image(systemName: option.iconName) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(isSelected ? Theme.accent : Theme.textSecondary) + .frame(width: 18) + + Text(option.displayName) + .font(Theme.geist(13, weight: .semibold)) + .foregroundStyle(Theme.textPrimary) + .lineLimit(1) + + Spacer(minLength: 0) + } + .padding(.horizontal, Theme.spacingMD) + .padding(.vertical, 10) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: Theme.radiusSM, style: .continuous) + .fill(isSelected ? Theme.accent.opacity(0.10) : Theme.surfaceInset) + ) + .overlay( + RoundedRectangle(cornerRadius: Theme.radiusSM, style: .continuous) + .stroke(isSelected ? Theme.accent.opacity(0.26) : Theme.separator, lineWidth: 1) + ) + } + .buttonStyle(.plain) + } + + private var providerHint: some View { + HStack(alignment: .top, spacing: Theme.spacingSM) { + Image(systemName: "info.circle") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(Theme.textTertiary) + .padding(.top, 1) + + Text(provider.setupHint) + .font(Theme.geist(12)) + .foregroundStyle(Theme.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + .padding(Theme.spacingMD) + .background( + RoundedRectangle(cornerRadius: Theme.radiusSM, style: .continuous) + .fill(Theme.surfaceInset) + ) + } + + private var nameField: some View { + VStack(alignment: .leading, spacing: Theme.spacingSM) { + Text("Name (optional)") + .font(Theme.geist(13, weight: .semibold)) + .foregroundStyle(Theme.textPrimary) + + TextField("e.g. Work OpenAI", text: $name) + .font(Theme.geist(13)) + .textFieldStyle(.plain) + .padding(.horizontal, Theme.spacingMD) + .padding(.vertical, 10) + .background(Theme.surfaceInset) + .clipShape(RoundedRectangle(cornerRadius: Theme.radiusSM, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: Theme.radiusSM, style: .continuous) + .stroke(Theme.separator, lineWidth: 1) + ) + .focused($focusedField, equals: .name) + } + } + + private var modelField: some View { + VStack(alignment: .leading, spacing: Theme.spacingSM) { + HStack(alignment: .center) { + Text("Model") + .font(Theme.geist(13, weight: .semibold)) + .foregroundStyle(Theme.textPrimary) + + Spacer() + + if provider == .ollama { + Button { + fetchOllamaModels() + } label: { + HStack(spacing: 5) { + if isFetchingModels { + ProgressView() + .controlSize(.small) + } else { + Image(systemName: "arrow.clockwise") + .font(.system(size: 10, weight: .semibold)) + } + Text(isFetchingModels ? "Fetching" : "Refresh Local Models") + .font(Theme.geist(11, weight: .medium)) + } + .foregroundStyle(Theme.textSecondary) + } + .buttonStyle(.plain) + .disabled(isFetchingModels) + } + } + + VStack(spacing: Theme.spacingSM) { + Picker("Model", selection: $model) { + ForEach(modelOptions, id: \.self) { m in + Text(m).tag(m) + } + Text("Custom").tag("") + } + .pickerStyle(.menu) + .labelsHidden() + .frame(maxWidth: .infinity, alignment: .leading) + + if !modelOptions.contains(model) { + TextField(provider.modelPlaceholder, text: $model) + .font(Theme.geist(13)) + .textFieldStyle(.plain) + .padding(.horizontal, Theme.spacingMD) + .padding(.vertical, 10) + .background(Theme.surfaceInset) + .clipShape(RoundedRectangle(cornerRadius: Theme.radiusSM, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: Theme.radiusSM, style: .continuous) + .stroke(Theme.separator, lineWidth: 1) + ) + .focused($focusedField, equals: .model) + } + + if provider == .ollama { + Text(ollamaModelHelperText) + .font(Theme.geist(11)) + .foregroundStyle(modelFetchMessage == nil ? Theme.textTertiary : Theme.warning) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + } + + private var apiKeyField: some View { + VStack(alignment: .leading, spacing: Theme.spacingSM) { + Text(provider.apiKeyLabel) + .font(Theme.geist(13, weight: .semibold)) + .foregroundStyle(Theme.textPrimary) + + SecureField("sk-...", text: $apiKey) + .font(Theme.geist(13)) + .textFieldStyle(.plain) + .padding(.horizontal, Theme.spacingMD) + .padding(.vertical, 10) + .background(Theme.surfaceInset) + .clipShape(RoundedRectangle(cornerRadius: Theme.radiusSM, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: Theme.radiusSM, style: .continuous) + .stroke(Theme.separator, lineWidth: 1) + ) + .focused($focusedField, equals: .apiKey) + + if !provider.requiresAPIKey { + Text("Leave blank unless your local server requires an authorization header.") + .font(Theme.geist(11)) + .foregroundStyle(Theme.textTertiary) + } + } + } + + private var baseURLField: some View { + VStack(alignment: .leading, spacing: Theme.spacingSM) { + Text(provider.baseURLLabel) + .font(Theme.geist(13, weight: .semibold)) + .foregroundStyle(Theme.textPrimary) + + TextField(provider.defaultBaseURL, text: $baseURL) + .font(Theme.geist(13)) + .textFieldStyle(.plain) + .padding(.horizontal, Theme.spacingMD) + .padding(.vertical, 10) + .background(Theme.surfaceInset) + .clipShape(RoundedRectangle(cornerRadius: Theme.radiusSM, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: Theme.radiusSM, style: .continuous) + .stroke(Theme.separator, lineWidth: 1) + ) + .focused($focusedField, equals: .baseURL) + } + } + + private var enabledToggle: some View { + Toggle(isOn: $isEnabled) { + VStack(alignment: .leading, spacing: 2) { + Text("Enabled") + .font(Theme.geist(13, weight: .semibold)) + .foregroundStyle(Theme.textPrimary) + Text("Disabled connections stay saved but cannot be selected for generation.") + .font(Theme.geist(11)) + .foregroundStyle(Theme.textTertiary) + } + } + .toggleStyle(.switch) + } + + private func testResultView(_ result: LLMConnectionTestResult) -> some View { + HStack(alignment: .top, spacing: Theme.spacingSM) { + Image(systemName: result.isSuccess ? "checkmark.circle.fill" : "exclamationmark.triangle.fill") + .foregroundStyle(result.isSuccess ? Theme.accent : Theme.warning) + .font(.system(size: 13, weight: .semibold)) + .padding(.top, 1) + + Text(result.message) + .font(Theme.geist(12, weight: .medium)) + .foregroundStyle(result.isSuccess ? Theme.textPrimary : Theme.warning) + .fixedSize(horizontal: false, vertical: true) + } + .padding(Theme.spacingMD) + .background( + RoundedRectangle(cornerRadius: Theme.radiusSM, style: .continuous) + .fill((result.isSuccess ? Theme.accent : Theme.warning).opacity(0.08)) + ) + .overlay( + RoundedRectangle(cornerRadius: Theme.radiusSM, style: .continuous) + .stroke((result.isSuccess ? Theme.accent : Theme.warning).opacity(0.18), lineWidth: 1) + ) + } + + private var footer: some View { + HStack(spacing: Theme.spacingMD) { + Spacer() + + Button { + testConnection() + } label: { + HStack(spacing: 6) { + if isTesting { + ProgressView() + .controlSize(.small) + } else { + Image(systemName: "antenna.radiowaves.left.and.right") + .font(.system(size: 12, weight: .medium)) + } + Text(isTesting ? "Testing..." : "Test Connection") + .font(Theme.geist(13, weight: .medium)) + } + .foregroundStyle(canSave ? Theme.textPrimary : Theme.textTertiary) + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: Theme.radiusSM, style: .continuous) + .fill(Theme.surfaceInset) + ) + } + .buttonStyle(.plain) + .disabled(!canSave || isTesting) + + Button { + dismiss() + } label: { + Text("Cancel") + .font(Theme.geist(13, weight: .medium)) + .foregroundStyle(Theme.textSecondary) + .padding(.horizontal, 16) + .padding(.vertical, 8) + } + .buttonStyle(.plain) + + Button { + save() + } label: { + Text(isEditing ? "Save" : "Add Connection") + .font(Theme.geist(13, weight: .semibold)) + .foregroundStyle(.white) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: Theme.radiusSM, style: .continuous) + .fill(canSave ? Theme.accent : Theme.accent.opacity(0.4)) + ) + } + .buttonStyle(.plain) + .disabled(!canSave) + } + .padding(Theme.spacingXL) + .background(Theme.surface) + .overlay( + Rectangle() + .fill(Theme.separator) + .frame(height: 1), + alignment: .top + ) + } + + private var canSave: Bool { + !model.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && (!provider.requiresAPIKey || !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + && URL(string: effectiveBaseURL) != nil + } + + private var effectiveBaseURL: String { + let trimmed = baseURL.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? provider.defaultBaseURL : trimmed + } + + private var modelOptions: [String] { + if provider == .ollama { + let localModels = fetchedOllamaModels + return localModels.isEmpty ? provider.defaultModels : localModels + } + return provider.defaultModels + } + + private var ollamaModelHelperText: String { + if let modelFetchMessage { + return modelFetchMessage + } + if fetchedOllamaModels.isEmpty { + return "Showing starter suggestions until local models are fetched from \(effectiveBaseURL)." + } + return "Showing \(fetchedOllamaModels.count) local model\(fetchedOllamaModels.count == 1 ? "" : "s") from \(effectiveBaseURL). Custom local model names are still allowed." + } + + private func save() { + let newConnection = draftConnection() + + if isEditing { + service.update(newConnection) + } else { + service.add(newConnection) + } + + dismiss() + } + + private func testConnection() { + guard !isTesting else { return } + testResult = nil + isTesting = true + + Task { + let result = await service.test(draftConnection()) + await MainActor.run { + testResult = result + isTesting = false + } + } + } + + private func draftConnection() -> LLMConnection { + LLMConnection( + id: connection?.id ?? UUID(), + name: name.trimmingCharacters(in: .whitespacesAndNewlines), + provider: provider, + apiKey: apiKey.trimmingCharacters(in: .whitespacesAndNewlines), + baseURL: baseURL.trimmingCharacters(in: .whitespacesAndNewlines), + model: model.trimmingCharacters(in: .whitespacesAndNewlines), + isEnabled: isEnabled + ) + } + + private func applyProviderDefaults(for provider: LLMProvider, clearKey: Bool) { + model = provider.defaultModels.first ?? provider.modelPlaceholder + baseURL = provider.defaultBaseURL + fetchedOllamaModels = [] + modelFetchMessage = nil + if clearKey { + apiKey = "" + } + } + + private func refreshModelsIfNeeded(for selectedProvider: LLMProvider? = nil) { + guard (selectedProvider ?? provider) == .ollama else { return } + fetchOllamaModels() + } + + private func fetchOllamaModels() { + guard provider == .ollama, !isFetchingModels else { return } + isFetchingModels = true + modelFetchMessage = nil + + Task { + let result = await service.fetchModels(for: draftConnection()) + await MainActor.run { + fetchedOllamaModels = result.models + modelFetchMessage = result.message + if !result.models.isEmpty && (model.isEmpty || !result.models.contains(model)) { + model = result.models[0] + } + isFetchingModels = false + } + } + } +} diff --git a/10x-macos/Views/Settings/GeneralSettingsView.swift b/10x-macos/Views/Settings/GeneralSettingsView.swift index 4e4207d..4600368 100644 --- a/10x-macos/Views/Settings/GeneralSettingsView.swift +++ b/10x-macos/Views/Settings/GeneralSettingsView.swift @@ -35,11 +35,15 @@ struct GeneralSettingsView: View { .frame(width: 56, height: 56) VStack(alignment: .leading, spacing: 4) { - Text(auth.userEmail ?? "No email on file") + Text(auth.isGuestMode ? "Guest mode" : auth.userEmail ?? "No email on file") .font(Theme.geist(18, weight: .semibold)) .foregroundStyle(Theme.textPrimary) - if let plan = billing.currentPlan { + if auth.isGuestMode { + Text("Sign in to sync projects and manage billing.") + .font(Theme.geist(12, weight: .medium)) + .foregroundStyle(Theme.textSecondary) + } else if let plan = billing.currentPlan { HStack(spacing: Theme.spacingSM) { Text(plan.name) .font(Theme.geist(12, weight: .medium)) @@ -59,6 +63,12 @@ struct GeneralSettingsView: View { } VStack(spacing: Theme.spacingSM) { + if auth.isGuestMode { + SettingsInsetRow { + accountRow(label: "Mode", value: "Guest") + } + } + if let email = auth.userEmail { SettingsInsetRow { accountRow(label: "Email", value: email) @@ -133,6 +143,13 @@ struct GeneralSettingsView: View { SettingsInsetRow { accountRow(label: "Feed", value: Config.sparkleFeedURL) } + + SettingsInsetRow { + accountRow( + label: "Updater", + value: Config.sparkleUpdatesConfigured ? "Configured" : "Disabled until feed and key are configured" + ) + } } } } @@ -148,7 +165,7 @@ struct GeneralSettingsView: View { HStack(spacing: 8) { Image(systemName: "rectangle.portrait.and.arrow.right") .font(.system(size: 13)) - Text("Sign Out") + Text(auth.isGuestMode ? "Sign In" : "Sign Out") .font(Theme.geist(13, weight: .medium)) } .foregroundStyle(Theme.error) @@ -206,6 +223,7 @@ struct GeneralSettingsView: View { } private var initials: String { + if auth.isGuestMode { return "G" } guard let email = auth.userEmail else { return "?" } let parts = email.split(separator: "@").first?.split(separator: ".") ?? [] if parts.count >= 2 { diff --git a/10x-macos/Views/Settings/SettingsView.swift b/10x-macos/Views/Settings/SettingsView.swift index ed214e3..340d5ac 100644 --- a/10x-macos/Views/Settings/SettingsView.swift +++ b/10x-macos/Views/Settings/SettingsView.swift @@ -2,6 +2,7 @@ import SwiftUI enum SettingsSection: String, CaseIterable, Identifiable { case general = "General" + case connections = "Connections" case usage = "Usage" case billing = "Billing" @@ -11,7 +12,7 @@ enum SettingsSection: String, CaseIterable, Identifiable { switch self { case .billing: return !Config.billingTestMode - case .general, .usage: + case .general, .usage, .connections: return true } } @@ -19,12 +20,12 @@ enum SettingsSection: String, CaseIterable, Identifiable { var icon: String { switch self { case .general: "gearshape" + case .connections: "network" case .usage: "chart.bar" case .billing: "creditcard" } } } - struct SettingsView: View { @Binding var selectedSection: SettingsSection @@ -117,6 +118,8 @@ struct SettingsView: View { switch selectedSection { case .general: GeneralSettingsView() + case .connections: + ConnectionsSettingsView() case .usage: UsageSettingsView() case .billing: