Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions 10x-macos/Config.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down Expand Up @@ -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]
Expand All @@ -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")
}
}
11 changes: 9 additions & 2 deletions 10x-macos/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []
Expand All @@ -201,6 +201,7 @@ struct ContentView: View {
}

syncSessionAccessTokens(token)
let isLocalDirectMode = token == LLMConnectionService.localDirectAccessToken

Task.detached(priority: .utility) {
await SimulatorPreviewService.shared.prewarmOnAppLaunchIfNeeded()
Expand All @@ -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)
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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 {
Expand Down
179 changes: 179 additions & 0 deletions 10x-macos/Models/LLMConnection.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
4 changes: 4 additions & 0 deletions 10x-macos/Services/Builder/BuilderContextManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
14 changes: 12 additions & 2 deletions 10x-macos/Services/Builder/BuilderGenerationRequestPlanner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion 10x-macos/Services/Builder/BuilderPrompts.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions 10x-macos/Services/Builder/BuilderToolDefinitions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading