Files
oai-swift/oAI/Views/Screens/SettingsView.swift

771 lines
29 KiB
Swift

//
// SettingsView.swift
// oAI
//
// Settings and configuration screen
//
import SwiftUI
import UniformTypeIdentifiers
struct SettingsView: View {
@Environment(\.dismiss) var dismiss
@Bindable private var settingsService = SettingsService.shared
private var mcpService = MCPService.shared
@State private var openrouterKey = ""
@State private var anthropicKey = ""
@State private var openaiKey = ""
@State private var googleKey = ""
@State private var googleEngineID = ""
@State private var showFolderPicker = false
@State private var selectedTab = 0
// OAuth state
@State private var oauthCode = ""
@State private var oauthError: String?
@State private var showOAuthCodeField = false
private var oauthService = AnthropicOAuthService.shared
private let labelWidth: CGFloat = 160
// Default system prompt
private let defaultSystemPrompt = """
You are a helpful AI assistant. Follow these guidelines:
1. **Accuracy First**: Never invent information or make assumptions. If you're unsure about something, say so clearly.
2. **Ask for Clarification**: When a request is ambiguous or lacks necessary details, ask clarifying questions before proceeding.
3. **Be Honest About Limitations**: If you cannot or should not help with a request, explain why clearly and politely. Don't attempt tasks outside your capabilities.
4. **Stay Grounded**: Base responses on facts and known information. Clearly distinguish between facts, informed opinions, and speculation.
5. **Be Direct**: Provide concise, relevant answers. Avoid unnecessary preambles or apologies.
6. **Use Markdown Formatting**: Always format your responses using standard Markdown syntax:
- Use **bold** for emphasis
- Use bullet points and numbered lists for organization
- Use code blocks with language tags for code (e.g., ```python)
- Use proper headings (##, ###) to structure long responses
- If the user requests output in other formats (HTML, JSON, XML, etc.), wrap those in appropriate code blocks
7. **Break Down Complex Tasks**: When working with tools (file access, search, etc.), break complex tasks into smaller, manageable steps. If a task requires many operations:
- Complete one logical step at a time
- Present findings or progress after each step
- Ask the user if you should continue to the next step
- Be mindful of tool usage limits (typically 25-30 tool calls per request)
8. **Incremental Progress**: For large codebases or complex analyses, work incrementally. Don't try to explore everything at once. Focus on what's immediately relevant to the user's question.
Remember: It's better to ask questions or admit uncertainty than to provide incorrect or fabricated information.
"""
var body: some View {
VStack(spacing: 0) {
// Title
Text("Settings")
.font(.system(size: 22, weight: .bold))
.padding(.top, 20)
.padding(.bottom, 12)
// Tab picker
Picker("", selection: $selectedTab) {
Text("General").tag(0)
Text("MCP").tag(1)
Text("Appearance").tag(2)
Text("Advanced").tag(3)
}
.pickerStyle(.segmented)
.padding(.horizontal, 24)
.padding(.bottom, 12)
Divider()
ScrollView {
VStack(alignment: .leading, spacing: 20) {
switch selectedTab {
case 0:
generalTab
case 1:
mcpTab
case 2:
appearanceTab
case 3:
advancedTab
default:
generalTab
}
}
.padding(.horizontal, 24)
.padding(.vertical, 16)
}
Divider()
// Bottom bar
HStack {
Spacer()
Button("Done") { dismiss() }
.keyboardShortcut(.return, modifiers: [])
.buttonStyle(.borderedProminent)
.controlSize(.regular)
Spacer()
}
.padding(.horizontal, 24)
.padding(.vertical, 12)
}
.frame(minWidth: 700, idealWidth: 800, minHeight: 600, idealHeight: 750)
}
// MARK: - General Tab
@ViewBuilder
private var generalTab: some View {
// Provider
sectionHeader("Provider")
row("Default Provider") {
Picker("", selection: $settingsService.defaultProvider) {
ForEach(ProviderRegistry.shared.configuredProviders, id: \.self) { provider in
Text(provider.displayName).tag(provider)
}
}
.labelsHidden()
.fixedSize()
}
divider()
// API Keys
sectionHeader("API Keys")
row("OpenRouter") {
SecureField("sk-or-...", text: $openrouterKey)
.textFieldStyle(.roundedBorder)
.font(.system(size: 13))
.onAppear { openrouterKey = settingsService.openrouterAPIKey ?? "" }
.onChange(of: openrouterKey) {
settingsService.openrouterAPIKey = openrouterKey.isEmpty ? nil : openrouterKey
ProviderRegistry.shared.clearCache()
}
}
// Anthropic: OAuth or API key
row("Anthropic") {
VStack(alignment: .leading, spacing: 8) {
if oauthService.isAuthenticated {
// Logged in via OAuth
HStack(spacing: 8) {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
Text("Logged in via Claude Pro/Max")
.font(.system(size: 14))
Spacer()
Button("Logout") {
oauthService.logout()
ProviderRegistry.shared.clearCache()
}
.font(.system(size: 14))
.foregroundStyle(.red)
}
} else if showOAuthCodeField {
// Waiting for code paste
HStack(spacing: 8) {
TextField("Paste authorization code...", text: $oauthCode)
.textFieldStyle(.roundedBorder)
Button("Submit") {
Task { await submitOAuthCode() }
}
.disabled(oauthCode.isEmpty || oauthService.isLoggingIn)
Button("Cancel") {
showOAuthCodeField = false
oauthCode = ""
oauthError = nil
}
.font(.system(size: 14))
}
if let error = oauthError {
Text(error)
.font(.system(size: 13))
.foregroundStyle(.red)
}
} else {
// Login button + API key field
HStack(spacing: 8) {
Button {
startOAuthLogin()
} label: {
HStack(spacing: 4) {
Image(systemName: "person.circle")
Text("Login with Claude Pro/Max")
}
.font(.system(size: 14))
}
.buttonStyle(.borderedProminent)
.controlSize(.small)
Text("or")
.font(.system(size: 13))
.foregroundStyle(.secondary)
}
SecureField("sk-ant-... (API key)", text: $anthropicKey)
.textFieldStyle(.roundedBorder)
.onAppear { anthropicKey = settingsService.anthropicAPIKey ?? "" }
.onChange(of: anthropicKey) {
settingsService.anthropicAPIKey = anthropicKey.isEmpty ? nil : anthropicKey
ProviderRegistry.shared.clearCache()
}
}
}
}
row("OpenAI") {
SecureField("sk-...", text: $openaiKey)
.textFieldStyle(.roundedBorder)
.onAppear { openaiKey = settingsService.openaiAPIKey ?? "" }
.onChange(of: openaiKey) {
settingsService.openaiAPIKey = openaiKey.isEmpty ? nil : openaiKey
ProviderRegistry.shared.clearCache()
}
}
row("Ollama URL") {
TextField("http://localhost:11434", text: $settingsService.ollamaBaseURL)
.textFieldStyle(.roundedBorder)
.help("Enter your Ollama server URL to enable the Ollama provider")
}
divider()
// Features
sectionHeader("Features")
row("") {
VStack(alignment: .leading, spacing: 6) {
Toggle("Online Mode (Web Search)", isOn: $settingsService.onlineMode)
Toggle("Conversation Memory", isOn: $settingsService.memoryEnabled)
Toggle("MCP (File Access)", isOn: $settingsService.mcpEnabled)
}
}
divider()
// Web Search
sectionHeader("Web Search")
row("Search Provider") {
Picker("", selection: $settingsService.searchProvider) {
ForEach(Settings.SearchProvider.allCases, id: \.self) { provider in
Text(provider.displayName).tag(provider)
}
}
.labelsHidden()
.fixedSize()
}
if settingsService.searchProvider == .google {
row("Google API Key") {
SecureField("", text: $googleKey)
.textFieldStyle(.roundedBorder)
.onAppear { googleKey = settingsService.googleAPIKey ?? "" }
.onChange(of: googleKey) {
settingsService.googleAPIKey = googleKey.isEmpty ? nil : googleKey
}
}
row("Search Engine ID") {
TextField("", text: $googleEngineID)
.textFieldStyle(.roundedBorder)
.onAppear { googleEngineID = settingsService.googleSearchEngineID ?? "" }
.onChange(of: googleEngineID) {
settingsService.googleSearchEngineID = googleEngineID.isEmpty ? nil : googleEngineID
}
}
}
divider()
// Model Settings
sectionHeader("Model Settings")
row("Default Model ID") {
TextField("e.g. anthropic/claude-sonnet-4", text: Binding(
get: { settingsService.defaultModel ?? "" },
set: { settingsService.defaultModel = $0.isEmpty ? nil : $0 }
))
.textFieldStyle(.roundedBorder)
}
divider()
// Logging
sectionHeader("Logging")
row("Log Level") {
Picker("", selection: Binding(
get: { FileLogger.shared.minimumLevel },
set: { FileLogger.shared.minimumLevel = $0 }
)) {
ForEach(LogLevel.allCases, id: \.self) { level in
Text(level.displayName).tag(level)
}
}
.labelsHidden()
.fixedSize()
}
HStack {
Spacer().frame(width: labelWidth + 12)
Text("Controls which messages are written to ~/Library/Logs/oAI.log")
.font(.system(size: 13))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
// MARK: - MCP Tab
@ViewBuilder
private var mcpTab: some View {
// Description header
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
Image(systemName: "folder.badge.gearshape")
.font(.title2)
.foregroundStyle(.blue)
Text("Model Context Protocol")
.font(.system(size: 18, weight: .semibold))
}
Text("MCP gives the AI controlled access to read and optionally write files on your computer. The AI can search, read, and analyze files in allowed folders to help with coding, analysis, and other tasks.")
.font(.system(size: 14))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.horizontal, 24)
.padding(.top, 12)
.padding(.bottom, 16)
divider()
// Enable toggle with status
sectionHeader("Status")
row("") {
Toggle("Enable MCP", isOn: $settingsService.mcpEnabled)
}
HStack {
Spacer().frame(width: labelWidth + 12)
HStack(spacing: 4) {
Image(systemName: settingsService.mcpEnabled ? "checkmark.circle.fill" : "circle")
.foregroundStyle(settingsService.mcpEnabled ? .green : .secondary)
.font(.system(size: 13))
Text(settingsService.mcpEnabled ? "Active - AI can access allowed folders" : "Disabled - No file access")
.font(.system(size: 13))
.foregroundStyle(.secondary)
}
}
if settingsService.mcpEnabled {
divider()
// Folders
sectionHeader("Allowed Folders")
if mcpService.allowedFolders.isEmpty {
VStack(spacing: 8) {
Image(systemName: "folder.badge.plus")
.font(.system(size: 32))
.foregroundStyle(.tertiary)
Text("No folders added yet")
.font(.system(size: 14, weight: .medium))
.foregroundStyle(.secondary)
Text("Click 'Add Folder' below to grant AI access to a folder")
.font(.system(size: 13))
.foregroundStyle(.tertiary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 24)
.background(Color.gray.opacity(0.05))
.cornerRadius(8)
.padding(.horizontal, labelWidth + 24)
} else {
ForEach(Array(mcpService.allowedFolders.enumerated()), id: \.offset) { index, folder in
HStack(spacing: 0) {
Spacer().frame(width: labelWidth + 12)
Image(systemName: "folder.fill")
.foregroundStyle(.blue)
.frame(width: 20)
VStack(alignment: .leading, spacing: 0) {
Text((folder as NSString).lastPathComponent)
.font(.body)
Text(abbreviatePath(folder))
.font(.system(size: 13))
.foregroundStyle(.secondary)
}
.padding(.leading, 6)
Spacer()
Button {
withAnimation { _ = mcpService.removeFolder(at: index) }
} label: {
Image(systemName: "trash.fill")
.foregroundStyle(.red)
.font(.system(size: 13))
}
.buttonStyle(.plain)
}
}
}
HStack(spacing: 0) {
Spacer().frame(width: labelWidth + 12)
Button {
showFolderPicker = true
} label: {
HStack(spacing: 4) {
Image(systemName: "plus")
Text("Add Folder...")
}
.font(.system(size: 14))
}
.buttonStyle(.borderless)
Spacer()
}
.fileImporter(
isPresented: $showFolderPicker,
allowedContentTypes: [.folder],
allowsMultipleSelection: false
) { result in
if case .success(let urls) = result, let url = urls.first {
if url.startAccessingSecurityScopedResource() {
withAnimation { _ = mcpService.addFolder(url.path) }
url.stopAccessingSecurityScopedResource()
}
}
}
divider()
// Permissions
sectionHeader("Permissions")
VStack(alignment: .leading, spacing: 0) {
HStack {
Spacer().frame(width: labelWidth + 12)
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 6) {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
.font(.system(size: 12))
Text("Read access (always enabled)")
.font(.system(size: 13))
.foregroundStyle(.secondary)
}
Text("The AI can read and search files in allowed folders")
.font(.system(size: 12))
.foregroundStyle(.tertiary)
.padding(.leading, 18)
}
Spacer()
}
.padding(.bottom, 12)
HStack {
Spacer().frame(width: labelWidth + 12)
Text("Write Permissions (optional)")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(.secondary)
Spacer()
}
.padding(.bottom, 8)
HStack {
Spacer().frame(width: labelWidth + 12)
VStack(alignment: .leading, spacing: 6) {
Toggle("Write & Edit Files", isOn: $settingsService.mcpCanWriteFiles)
Toggle("Delete Files", isOn: $settingsService.mcpCanDeleteFiles)
Toggle("Create Directories", isOn: $settingsService.mcpCanCreateDirectories)
Toggle("Move & Copy Files", isOn: $settingsService.mcpCanMoveFiles)
}
Spacer()
}
}
divider()
// Filtering
sectionHeader("Filtering")
row("") {
Toggle("Respect .gitignore", isOn: Binding(
get: { settingsService.mcpRespectGitignore },
set: { newValue in
settingsService.mcpRespectGitignore = newValue
mcpService.reloadGitignores()
}
))
}
HStack {
Spacer().frame(width: labelWidth + 12)
Text("When enabled, listing and searching skip gitignored files. Write operations always ignore .gitignore.")
.font(.system(size: 13))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
}
// MARK: - Appearance Tab
@ViewBuilder
private var appearanceTab: some View {
sectionHeader("Text Sizes")
row("GUI Text") {
HStack(spacing: 8) {
Slider(value: $settingsService.guiTextSize, in: 10...20, step: 1)
.frame(maxWidth: 200)
Text("\(Int(settingsService.guiTextSize)) pt")
.font(.system(size: 13))
.foregroundStyle(.secondary)
.frame(width: 40)
}
}
row("Dialog Text") {
HStack(spacing: 8) {
Slider(value: $settingsService.dialogTextSize, in: 10...24, step: 1)
.frame(maxWidth: 200)
Text("\(Int(settingsService.dialogTextSize)) pt")
.font(.system(size: 13))
.foregroundStyle(.secondary)
.frame(width: 40)
}
}
row("Input Text") {
HStack(spacing: 8) {
Slider(value: $settingsService.inputTextSize, in: 10...24, step: 1)
.frame(maxWidth: 200)
Text("\(Int(settingsService.inputTextSize)) pt")
.font(.system(size: 13))
.foregroundStyle(.secondary)
.frame(width: 40)
}
}
}
// MARK: - Advanced Tab
@ViewBuilder
private var advancedTab: some View {
sectionHeader("Response Generation")
row("") {
Toggle("Enable Streaming Responses", isOn: $settingsService.streamEnabled)
}
HStack {
Spacer().frame(width: labelWidth + 12)
Text("Stream responses as they're generated. Disable for single, complete responses.")
.font(.system(size: 13))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
divider()
sectionHeader("Model Parameters")
// Max Tokens
row("Max Tokens") {
HStack(spacing: 8) {
Slider(value: Binding(
get: { Double(settingsService.maxTokens) },
set: { settingsService.maxTokens = Int($0) }
), in: 0...32000, step: 256)
.frame(maxWidth: 250)
Text(settingsService.maxTokens == 0 ? "Default" : "\(settingsService.maxTokens)")
.font(.system(size: 13))
.foregroundStyle(.secondary)
.frame(width: 70, alignment: .leading)
}
}
HStack {
Spacer().frame(width: labelWidth + 12)
Text("Maximum tokens in the response. Set to 0 to use model default. Higher values allow longer responses but cost more.")
.font(.system(size: 13))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
// Temperature
row("Temperature") {
HStack(spacing: 8) {
Slider(value: $settingsService.temperature, in: 0...2, step: 0.1)
.frame(maxWidth: 250)
Text(settingsService.temperature == 0.0 ? "Default" : String(format: "%.1f", settingsService.temperature))
.font(.system(size: 13))
.foregroundStyle(.secondary)
.frame(width: 70, alignment: .leading)
}
}
HStack {
Spacer().frame(width: labelWidth + 12)
VStack(alignment: .leading, spacing: 4) {
Text("Controls randomness. Set to 0 to use model default.")
.font(.system(size: 13))
.foregroundStyle(.secondary)
Text("• Lower (0.0-0.7): More focused, deterministic")
.font(.system(size: 13))
.foregroundStyle(.secondary)
Text("• Higher (0.8-2.0): More creative, random")
.font(.system(size: 13))
.foregroundStyle(.secondary)
}
.fixedSize(horizontal: false, vertical: true)
}
divider()
sectionHeader("System Prompts")
// Default prompt (read-only)
VStack(alignment: .leading, spacing: 8) {
HStack {
Spacer().frame(width: labelWidth + 12)
HStack(spacing: 4) {
Text("Default Prompt")
.font(.system(size: 14))
.fontWeight(.medium)
Text("(always used)")
.font(.system(size: 13))
.foregroundStyle(.secondary)
}
}
HStack(alignment: .top, spacing: 0) {
Spacer().frame(width: labelWidth + 12)
ScrollView {
Text(defaultSystemPrompt)
.font(.system(size: 13, design: .monospaced))
.foregroundStyle(.secondary)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(8)
}
.frame(height: 160)
.background(Color(NSColor.controlBackgroundColor))
.cornerRadius(6)
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(Color.secondary.opacity(0.2), lineWidth: 1)
)
}
HStack {
Spacer().frame(width: labelWidth + 12)
Text("This default prompt is always included to ensure accurate, helpful responses.")
.font(.system(size: 13))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
.padding(.bottom, 8)
// Custom prompt (editable)
VStack(alignment: .leading, spacing: 8) {
HStack {
Spacer().frame(width: labelWidth + 12)
HStack(spacing: 4) {
Text("Your Custom Prompt")
.font(.system(size: 14))
.fontWeight(.medium)
Text("(optional)")
.font(.system(size: 13))
.foregroundStyle(.secondary)
}
}
HStack(alignment: .top, spacing: 0) {
Spacer().frame(width: labelWidth + 12)
TextEditor(text: Binding(
get: { settingsService.systemPrompt ?? "" },
set: { settingsService.systemPrompt = $0.isEmpty ? nil : $0 }
))
.font(.system(size: 13, design: .monospaced))
.frame(height: 120)
.padding(8)
.background(Color(NSColor.textBackgroundColor))
.cornerRadius(6)
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(Color.secondary.opacity(0.3), lineWidth: 1)
)
}
HStack {
Spacer().frame(width: labelWidth + 12)
Text("Add additional instructions here. This will be appended to the default prompt. Leave empty if you don't need custom instructions.")
.font(.system(size: 13))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
divider()
sectionHeader("Info")
HStack {
Spacer().frame(width: labelWidth + 12)
VStack(alignment: .leading, spacing: 8) {
Text("⚠️ These are advanced settings")
.font(.system(size: 13))
.foregroundStyle(.orange)
.fontWeight(.medium)
Text("Changing these values affects how the AI generates responses. The defaults work well for most use cases. Only adjust if you know what you're doing.")
.font(.system(size: 13))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
}
// MARK: - Layout Helpers
private func sectionHeader(_ title: String) -> some View {
Text(title)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(.secondary)
.textCase(.uppercase)
}
private func row<Content: View>(_ label: String, @ViewBuilder content: () -> Content) -> some View {
HStack(alignment: .center, spacing: 12) {
Text(label)
.font(.system(size: 14))
.frame(width: labelWidth, alignment: .trailing)
content()
}
}
private func divider() -> some View {
Divider().padding(.vertical, 2)
}
private func abbreviatePath(_ path: String) -> String {
let home = NSHomeDirectory()
if path.hasPrefix(home) {
return "~" + path.dropFirst(home.count)
}
return path
}
// MARK: - OAuth Helpers
private func startOAuthLogin() {
let url = oauthService.generateAuthorizationURL()
#if os(macOS)
NSWorkspace.shared.open(url)
#endif
showOAuthCodeField = true
oauthError = nil
oauthCode = ""
}
private func submitOAuthCode() async {
oauthService.isLoggingIn = true
oauthError = nil
do {
try await oauthService.exchangeCode(oauthCode)
showOAuthCodeField = false
oauthCode = ""
ProviderRegistry.shared.clearCache()
} catch {
oauthError = error.localizedDescription
}
oauthService.isLoggingIn = false
}
}
#Preview {
SettingsView()
}