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

1941 lines
78 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// 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
private let gitSync = GitSyncService.shared
var chatViewModel: ChatViewModel?
init(chatViewModel: ChatViewModel? = nil) {
self.chatViewModel = chatViewModel
}
@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
// Git Sync state
@State private var syncRepoURL = ""
@State private var syncLocalPath = "~/oAI-sync"
@State private var syncUsername = ""
@State private var syncPassword = ""
@State private var syncAccessToken = ""
@State private var showSyncPassword = false
@State private var showSyncToken = false
@State private var isTestingSync = false
@State private var syncTestResult: String?
@State private var isSyncing = false
// Email handler state
@State private var showEmailLog = false
@State private var showEmailModelSelector = false
@State private var emailHandlerSystemPrompt = ""
@State private var emailAvailableModels: [ModelInfo] = []
@State private var isLoadingEmailModels = false
@State private var showEmailPassword = false
@State private var isTestingEmailConnection = false
@State private var emailConnectionTestResult: String?
private let labelWidth: CGFloat = 160
// Default system prompt - generic for all models
private let defaultSystemPrompt = """
You are a helpful AI assistant. Follow these core principles:
## CORE BEHAVIOR
- **Accuracy First**: Never invent information. If unsure, say so clearly.
- **Ask for Clarification**: When ambiguous, ask questions before proceeding.
- **Be Direct**: Provide concise, relevant answers. No unnecessary preambles.
- **Show Your Work**: If you use capabilities (tools, web search, etc.), demonstrate what you did.
- **Complete Tasks Properly**: If you start something, finish it correctly.
## FORMATTING
Always use Markdown formatting:
- **Bold** for emphasis
- Code blocks with language tags: ```python
- Headings (##, ###) for structure
- Lists for organization
## HONESTY
It's better to admit "I need more information" or "I cannot do that" than to fake completion or invent answers.
"""
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)
Text("Sync").tag(4)
Text("Email").tag(5)
Text("Shortcuts").tag(6)
Text("Skills").tag(7)
}
.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
case 4:
syncTab
case 5:
emailTab
case 6:
shortcutsTab
case 7:
agentSkillsTab
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)
.sheet(isPresented: $showEmailLog) {
EmailLogView()
}
}
// 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))
.frame(width: 400)
.onAppear { openrouterKey = settingsService.openrouterAPIKey ?? "" }
.onChange(of: openrouterKey) {
settingsService.openrouterAPIKey = openrouterKey.isEmpty ? nil : openrouterKey
ProviderRegistry.shared.clearCache()
}
}
// Anthropic: API key
row("Anthropic") {
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)
.frame(width: 400)
.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)
.frame(width: 400)
.help("Enter your Ollama server URL to enable the Ollama provider")
}
divider()
// Features
sectionHeader("Features")
row("Online Mode (Web Search)") {
Toggle("", isOn: $settingsService.onlineMode)
.toggleStyle(.switch)
}
row("Conversation Memory") {
Toggle("", isOn: $settingsService.memoryEnabled)
.toggleStyle(.switch)
}
row("MCP (File Access)") {
Toggle("", isOn: $settingsService.mcpEnabled)
.toggleStyle(.switch)
}
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("Enable MCP") {
Toggle("", isOn: $settingsService.mcpEnabled)
.toggleStyle(.switch)
}
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: 8) {
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)
}
Spacer()
Button {
withAnimation { _ = mcpService.removeFolder(at: index) }
} label: {
Image(systemName: "trash.fill")
.foregroundStyle(.red)
.font(.system(size: 13))
}
.buttonStyle(.plain)
}
}
}
HStack(spacing: 4) {
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: 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)
}
.padding(.bottom, 12)
Text("Write Permissions (optional)")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(.secondary)
.padding(.bottom, 8)
row("Write & Edit Files") {
Toggle("", isOn: $settingsService.mcpCanWriteFiles)
.toggleStyle(.switch)
}
row("Delete Files") {
Toggle("", isOn: $settingsService.mcpCanDeleteFiles)
.toggleStyle(.switch)
}
row("Create Directories") {
Toggle("", isOn: $settingsService.mcpCanCreateDirectories)
.toggleStyle(.switch)
}
row("Move & Copy Files") {
Toggle("", isOn: $settingsService.mcpCanMoveFiles)
.toggleStyle(.switch)
}
divider()
// Filtering
sectionHeader("Filtering")
row("Respect .gitignore") {
Toggle("", isOn: Binding(
get: { settingsService.mcpRespectGitignore },
set: { newValue in
settingsService.mcpRespectGitignore = newValue
mcpService.reloadGitignores()
}
))
.toggleStyle(.switch)
}
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)
}
// Anytype integration UI hidden (work in progress see AnytypeMCPService.swift)
}
// 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)
}
}
sectionHeader("Toolbar")
row("Icon Size") {
HStack(spacing: 8) {
Slider(value: $settingsService.toolbarIconSize, in: 16...32, step: 2)
.frame(maxWidth: 200)
Text("\(Int(settingsService.toolbarIconSize)) pt")
.font(.system(size: 13))
.foregroundStyle(.secondary)
.frame(width: 40)
}
}
row("") {
Toggle("Show Icon Labels", isOn: $settingsService.showToolbarLabels)
.toggleStyle(.switch)
}
HStack {
Text("Show text labels below toolbar icons (helpful for new users)")
.font(.system(size: 11))
.foregroundStyle(.secondary)
Spacer()
}
.padding(.horizontal, labelWidth + 20)
.padding(.bottom, 12)
}
// MARK: - Advanced Tab
@ViewBuilder
private var advancedTab: some View {
sectionHeader("Response Generation")
row("Enable Streaming") {
Toggle("", isOn: $settingsService.streamEnabled)
.toggleStyle(.switch)
}
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)
}
}
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)
}
}
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 mode toggle
HStack {
Text("Use Only Your Prompt")
.frame(width: labelWidth, alignment: .trailing)
Toggle("", isOn: Binding(
get: { settingsService.customPromptMode == .replace },
set: { settingsService.customPromptMode = $0 ? .replace : .append }
))
.toggleStyle(.switch)
.labelsHidden()
Text(settingsService.customPromptMode == .replace ? "BYOP Mode" : "Default + Custom")
.font(.system(size: 13))
.foregroundStyle(settingsService.customPromptMode == .replace ? .orange : .secondary)
.frame(width: 140, alignment: .leading)
Spacer()
}
HStack {
Spacer().frame(width: labelWidth + 12)
Text(settingsService.customPromptMode == .replace
? "⚠️ Only your custom prompt will be used. Default prompt and tool guidelines are disabled."
: "Your custom prompt will be added after the default prompt and tool guidelines.")
.font(.system(size: 13))
.foregroundStyle(settingsService.customPromptMode == .replace ? .orange : .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(settingsService.customPromptMode == .replace
? "Now using only your prompt shown below"
: "Your Custom Prompt")
.font(.system(size: 14))
.fontWeight(.medium)
.foregroundStyle(settingsService.customPromptMode == .replace ? .orange : .primary)
if settingsService.customPromptMode == .append {
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(settingsService.customPromptMode == .append
? "This will be added after the default prompt and tool-specific guidelines."
: "In BYOP mode, ONLY your custom prompt will be used. The default prompt and tool guidelines will be ignored.")
.font(.system(size: 13))
.foregroundStyle(settingsService.customPromptMode == .replace ? .orange : .secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
divider()
sectionHeader("Memory & Context")
row("Smart Context Selection") {
Toggle("", isOn: $settingsService.contextSelectionEnabled)
.toggleStyle(.switch)
}
Text("Automatically select relevant messages instead of sending all history. Reduces token usage and improves response quality for long conversations.")
.font(.system(size: 13))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
if settingsService.contextSelectionEnabled {
row("Max Context Tokens") {
HStack(spacing: 8) {
TextField("", value: $settingsService.contextMaxTokens, format: .number)
.textFieldStyle(.roundedBorder)
.frame(width: 120)
Text("tokens")
.font(.system(size: 13))
.foregroundStyle(.secondary)
}
}
HStack {
Spacer().frame(width: labelWidth + 12)
Text("Maximum context window size. Default is 100,000 tokens. Smart selection will prioritize recent and starred messages within this limit.")
.font(.system(size: 13))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
divider()
sectionHeader("Semantic Search")
row("Enable Embeddings") {
Toggle("", isOn: $settingsService.embeddingsEnabled)
.toggleStyle(.switch)
.disabled(!EmbeddingService.shared.isAvailable)
}
// Show status based on available providers
if let provider = EmbeddingService.shared.getBestAvailableProvider() {
Text("Enable AI-powered semantic search across conversations using \(provider.displayName) embeddings.")
.font(.system(size: 13))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
} else {
Text("⚠️ No embedding providers available. Please configure an API key for OpenAI, OpenRouter, or Google in the General tab.")
.font(.system(size: 13))
.foregroundStyle(.orange)
.fixedSize(horizontal: false, vertical: true)
}
if settingsService.embeddingsEnabled {
row("Model") {
Picker("", selection: $settingsService.embeddingProvider) {
if settingsService.openaiAPIKey != nil && !settingsService.openaiAPIKey!.isEmpty {
Text("OpenAI (text-embedding-3-small)").tag("openai-small")
Text("OpenAI (text-embedding-3-large)").tag("openai-large")
}
if settingsService.openrouterAPIKey != nil && !settingsService.openrouterAPIKey!.isEmpty {
Text("OpenRouter (OpenAI small)").tag("openrouter-openai-small")
Text("OpenRouter (OpenAI large)").tag("openrouter-openai-large")
Text("OpenRouter (Qwen 8B)").tag("openrouter-qwen")
}
if settingsService.googleAPIKey != nil && !settingsService.googleAPIKey!.isEmpty {
Text("Google (Gemini embedding)").tag("google-gemini")
}
}
.pickerStyle(.menu)
}
HStack {
Spacer().frame(width: labelWidth + 12)
Text("Cost: OpenAI ~$0.02-0.13/1M tokens, OpenRouter similar, Google ~$0.15/1M tokens")
.font(.system(size: 13))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
HStack {
Spacer().frame(width: labelWidth + 12)
Button("Embed All Conversations") {
Task {
if let chatVM = chatViewModel {
await chatVM.batchEmbedAllConversations()
}
}
}
.help("Generate embeddings for all existing messages (one-time operation)")
}
HStack {
Spacer().frame(width: labelWidth + 12)
Text("⚠️ This will generate embeddings for all messages in all conversations. Estimated cost: ~$0.04 for 10,000 messages.")
.font(.system(size: 13))
.foregroundStyle(.orange)
.fixedSize(horizontal: false, vertical: true)
}
}
divider()
sectionHeader("Progressive Summarization")
row("Enable Summarization") {
Toggle("", isOn: $settingsService.progressiveSummarizationEnabled)
.toggleStyle(.switch)
}
Text("Automatically summarize old portions of long conversations to save tokens and improve context efficiency.")
.font(.system(size: 13))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
if settingsService.progressiveSummarizationEnabled {
row("Message Threshold") {
HStack(spacing: 8) {
TextField("", value: $settingsService.summarizationThreshold, format: .number)
.textFieldStyle(.roundedBorder)
.frame(width: 80)
Text("messages")
.font(.system(size: 13))
.foregroundStyle(.secondary)
}
}
HStack {
Spacer().frame(width: labelWidth + 12)
Text("When a conversation exceeds this many messages, older messages will be summarized. Default: 50 messages.")
.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: - Sync Tab
@ViewBuilder
private var syncTab: some View {
Group {
sectionHeader("Git Sync")
Text("Sync conversations and settings across multiple machines using Git.")
.font(.system(size: 14))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
row("Enable Git Sync") {
Toggle("", isOn: $settingsService.syncEnabled)
.toggleStyle(.switch)
}
if settingsService.syncEnabled {
VStack(alignment: .leading, spacing: 16) {
// Status indicator
HStack(spacing: 8) {
Image(systemName: syncStatusIcon)
.foregroundStyle(syncStatusColor)
Text(syncStatusText)
.font(.system(size: 14))
.foregroundStyle(syncStatusColor)
}
.padding(.leading, labelWidth + 12)
divider()
// Repository URL
sectionHeader("Repository")
row("URL") {
TextField("https://github.com/user/oai-sync.git", text: $syncRepoURL)
.textFieldStyle(.roundedBorder)
.onChange(of: syncRepoURL) {
settingsService.syncRepoURL = syncRepoURL
}
}
Text("💡 Use HTTPS URL (e.g., https://gitlab.pm/user/repo.git) - works with all auth methods")
.font(.system(size: 13))
.foregroundStyle(.secondary)
row("Local Path") {
TextField("~/Library/Application Support/oAI/sync", text: $syncLocalPath)
.textFieldStyle(.roundedBorder)
.onChange(of: syncLocalPath) {
settingsService.syncLocalPath = syncLocalPath
}
}
divider()
// Authentication
sectionHeader("Authentication")
row("Method") {
Picker("", selection: $settingsService.syncAuthMethod) {
Text("SSH Key").tag("ssh")
Text("Username + Password").tag("password")
Text("Access Token").tag("token")
}
.pickerStyle(.segmented)
.frame(width: 400)
}
// SSH info
if settingsService.syncAuthMethod == "ssh" {
VStack(alignment: .leading, spacing: 6) {
Text(" SSH Key Authentication")
.font(.system(size: 13, weight: .semibold))
Text("• Uses your system SSH keys (~/.ssh/id_ed25519)")
Text("• Add public key to your git provider")
Text("• No credentials needed in oAI")
}
.font(.system(size: 13))
.foregroundStyle(.secondary)
}
// Username + Password
if settingsService.syncAuthMethod == "password" {
row("Username") {
TextField("username", text: $syncUsername)
.textFieldStyle(.roundedBorder)
.onChange(of: syncUsername) {
settingsService.syncUsername = syncUsername.isEmpty ? nil : syncUsername
}
}
row("Password") {
HStack {
if showSyncPassword {
TextField("", text: $syncPassword)
.textFieldStyle(.roundedBorder)
} else {
SecureField("", text: $syncPassword)
.textFieldStyle(.roundedBorder)
}
Button(action: { showSyncPassword.toggle() }) {
Image(systemName: showSyncPassword ? "eye.slash" : "eye")
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.onChange(of: syncPassword) {
settingsService.syncPassword = syncPassword.isEmpty ? nil : syncPassword
}
}
}
Text("⚠️ Many providers (GitHub) no longer support password authentication. Use Access Token instead.")
.font(.system(size: 13))
.foregroundStyle(.orange)
}
// Access Token
if settingsService.syncAuthMethod == "token" {
row("Token") {
HStack {
if showSyncToken {
TextField("", text: $syncAccessToken)
.textFieldStyle(.roundedBorder)
} else {
SecureField("", text: $syncAccessToken)
.textFieldStyle(.roundedBorder)
}
Button(action: { showSyncToken.toggle() }) {
Image(systemName: showSyncToken ? "eye.slash" : "eye")
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.onChange(of: syncAccessToken) {
settingsService.syncAccessToken = syncAccessToken.isEmpty ? nil : syncAccessToken
}
}
}
VStack(alignment: .leading, spacing: 6) {
Text("💡 Generate Access Token:")
.font(.system(size: 13, weight: .semibold))
if let tokenURL = tokenGenerationURL {
Link("→ Open \(extractProvider()) Settings", destination: URL(string: tokenURL)!)
.font(.system(size: 13))
} else {
Text("• GitHub: Settings > Developer > Personal Access Tokens")
Text("• GitLab: Preferences > Access Tokens")
Text("• Gitea: Settings > Applications > Generate New Token")
}
}
.font(.system(size: 13))
.foregroundStyle(.secondary)
}
// Test connection
row("") {
HStack {
if let result = syncTestResult {
Text(result)
.font(.system(size: 14))
.foregroundStyle(result.hasPrefix("") ? .green : .red)
}
Button(action: { Task { await testSyncConnection() } }) {
HStack {
if isTestingSync {
ProgressView()
.scaleEffect(0.7)
.frame(width: 14, height: 14)
} else {
Image(systemName: "checkmark.circle")
}
Text("Test Connection")
}
}
.disabled(isTestingSync || !settingsService.syncConfigured)
}
}
divider()
// Sync options
sectionHeader("Sync Options")
row("Auto-export on save") {
Toggle("", isOn: $settingsService.syncAutoExport)
.toggleStyle(.switch)
}
row("Auto-pull on launch") {
Toggle("", isOn: $settingsService.syncAutoPull)
.toggleStyle(.switch)
}
divider()
// Auto-Save & Smart Sync
sectionHeader("Auto-Save & Smart Sync")
row("Enable Auto-Save") {
Toggle("", isOn: $settingsService.syncAutoSave)
.toggleStyle(.switch)
}
if settingsService.syncAutoSave {
// Warning about conflicts
HStack(spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
Text("Auto-sync can cause conflicts if running on multiple machines simultaneously.")
.font(.system(size: 13))
.foregroundStyle(.orange)
}
.padding(.vertical, 4)
// Minimum messages
row("Min Messages") {
HStack {
Slider(value: Binding(
get: { Double(settingsService.syncAutoSaveMinMessages) },
set: { settingsService.syncAutoSaveMinMessages = Int($0) }
), in: 3...20, step: 1)
.frame(width: 200)
Text("\(settingsService.syncAutoSaveMinMessages)")
.font(.system(size: 14))
.frame(width: 30)
}
}
Text("Only auto-save conversations with at least this many messages")
.font(.system(size: 13))
.foregroundStyle(.secondary)
// Trigger options
Text("Triggers")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(.secondary)
.padding(.top, 8)
row("On model switch") {
Toggle("", isOn: $settingsService.syncAutoSaveOnModelSwitch)
.toggleStyle(.switch)
}
row("On app quit") {
Toggle("", isOn: $settingsService.syncAutoSaveOnAppQuit)
.toggleStyle(.switch)
}
row("After idle timeout") {
Toggle("", isOn: $settingsService.syncAutoSaveOnIdle)
.toggleStyle(.switch)
}
// Idle timeout
if settingsService.syncAutoSaveOnIdle {
row("Idle Timeout") {
HStack {
Slider(value: Binding(
get: { Double(settingsService.syncAutoSaveIdleMinutes) },
set: { settingsService.syncAutoSaveIdleMinutes = Int($0) }
), in: 1...30, step: 1)
.frame(width: 200)
Text("\(settingsService.syncAutoSaveIdleMinutes) min")
.font(.system(size: 14))
.frame(width: 60)
}
}
Text("Auto-save if no messages for this many minutes")
.font(.system(size: 13))
.foregroundStyle(.secondary)
}
}
divider()
// Manual actions
sectionHeader("Manual Sync")
row("") {
HStack(spacing: 12) {
if !gitSync.syncStatus.isCloned {
// Not cloned yet - show initialize button
Button {
Task { await cloneRepo() }
} label: {
HStack(spacing: 6) {
if isSyncing {
ProgressView()
.scaleEffect(0.7)
.frame(width: 16, height: 16)
} else {
Image(systemName: "arrow.down.circle")
}
Text("Initialize Repository")
}
.frame(minWidth: 160)
}
.disabled(!settingsService.syncConfigured || isSyncing)
} else {
// Already cloned - show sync button
Button {
Task { await syncNow() }
} label: {
HStack(spacing: 6) {
if isSyncing {
ProgressView()
.scaleEffect(0.7)
.frame(width: 16, height: 16)
} else {
Image(systemName: "arrow.triangle.2.circlepath")
}
Text("Sync Now")
}
.frame(minWidth: 160)
}
.disabled(isSyncing)
}
Spacer()
}
}
// Status
if gitSync.syncStatus.isCloned {
HStack {
Spacer().frame(width: labelWidth + 12)
VStack(alignment: .leading, spacing: 4) {
if let lastSync = gitSync.syncStatus.lastSyncTime {
Text("Last sync: \(timeAgo(lastSync))")
.font(.system(size: 13))
.foregroundStyle(.secondary)
}
if gitSync.syncStatus.uncommittedChanges > 0 {
Text("Uncommitted changes: \(gitSync.syncStatus.uncommittedChanges)")
.font(.system(size: 13))
.foregroundStyle(.orange)
}
if let branch = gitSync.syncStatus.currentBranch {
Text("Branch: \(branch)")
.font(.system(size: 13))
.foregroundStyle(.secondary)
}
if let status = gitSync.syncStatus.remoteStatus {
Text("Remote: \(status)")
.font(.system(size: 13))
.foregroundStyle(.secondary)
}
}
}
}
}
}
}
.onAppear {
syncRepoURL = settingsService.syncRepoURL
syncLocalPath = settingsService.syncLocalPath
syncUsername = settingsService.syncUsername ?? ""
syncPassword = settingsService.syncPassword ?? ""
syncAccessToken = settingsService.syncAccessToken ?? ""
// Update sync status to check if repository is already cloned
Task {
await gitSync.updateStatus()
}
}
}
// MARK: - Email Tab
@ViewBuilder
private var emailTab: some View {
Group {
sectionHeader("AI Email Handler")
Text("Let AI automatically respond to emails sent to your designated email account. Uses IMAP IDLE for real-time monitoring and replies with AI-generated responses.")
.font(.system(size: settingsService.guiTextSize))
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
// Security recommendation box
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
Image(systemName: "shield.fill")
.foregroundColor(.orange)
Text("Security Recommendation")
.font(.system(size: settingsService.guiTextSize, weight: .semibold))
.foregroundColor(.orange)
}
Text("For security, create a dedicated email account specifically for AI handling. Do NOT use your personal email address.")
.font(.system(size: settingsService.guiTextSize))
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
Text("Example: oai-bot-x7k2m9p3@gmail.com")
.font(.system(size: settingsService.guiTextSize - 1, design: .monospaced))
.foregroundColor(.blue)
.padding(.vertical, 4)
.padding(.horizontal, 8)
.background(Color.blue.opacity(0.1))
.cornerRadius(4)
}
.padding(12)
.background(Color.orange.opacity(0.05))
.cornerRadius(8)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(Color.orange.opacity(0.3), lineWidth: 1)
)
divider()
// Enable toggle
row("Enable Email Handler") {
Toggle("", isOn: $settingsService.emailHandlerEnabled)
.toggleStyle(.switch)
}
if settingsService.emailHandlerEnabled {
divider()
// AI Configuration
sectionHeader("AI Configuration")
// Provider selection
row("AI Provider") {
Picker("", selection: $settingsService.emailHandlerProvider) {
ForEach(ProviderRegistry.shared.configuredProviders, id: \.self) { provider in
Text(provider.displayName).tag(provider.rawValue)
}
}
.labelsHidden()
.frame(width: 250)
.onChange(of: settingsService.emailHandlerProvider) {
Task {
await loadEmailModels()
}
}
}
// Model selection
row("AI Model") {
if isLoadingEmailModels {
ProgressView()
.scaleEffect(0.7)
.frame(width: 250, alignment: .leading)
} else if emailAvailableModels.isEmpty {
Text("No models available")
.font(.system(size: settingsService.guiTextSize))
.foregroundColor(.secondary)
.frame(width: 250, alignment: .leading)
} else {
Button(action: { showEmailModelSelector = true }) {
HStack {
Text(emailAvailableModels.first(where: { $0.id == settingsService.emailHandlerModel })?.name ?? "Select model...")
.font(.system(size: settingsService.guiTextSize))
.foregroundColor(.primary)
Spacer()
Image(systemName: "chevron.up.chevron.down")
.font(.system(size: 10))
.foregroundColor(.secondary)
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.frame(width: 250)
.background(Color(nsColor: .controlBackgroundColor))
.cornerRadius(6)
}
.buttonStyle(.plain)
}
}
Text("Select which AI model handles incoming emails. This runs in parallel to your main chat session.")
.font(.system(size: settingsService.guiTextSize - 1))
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
divider()
// Email Server Configuration
sectionHeader("Email Server")
row("IMAP Host") {
TextField("imap.gmail.com", text: Binding(
get: { settingsService.emailImapHost ?? "" },
set: { settingsService.emailImapHost = $0.isEmpty ? nil : $0 }
))
.textFieldStyle(.roundedBorder)
.frame(width: 250)
}
row("SMTP Host") {
TextField("smtp.gmail.com", text: Binding(
get: { settingsService.emailSmtpHost ?? "" },
set: { settingsService.emailSmtpHost = $0.isEmpty ? nil : $0 }
))
.textFieldStyle(.roundedBorder)
.frame(width: 250)
}
row("IMAP Port") {
TextField("993", text: Binding(
get: { String(settingsService.emailImapPort) },
set: { settingsService.emailImapPort = Int($0) ?? 993 }
))
.textFieldStyle(.roundedBorder)
.frame(width: 100)
}
row("SMTP Port") {
TextField("587", text: Binding(
get: { String(settingsService.emailSmtpPort) },
set: { settingsService.emailSmtpPort = Int($0) ?? 587 }
))
.textFieldStyle(.roundedBorder)
.frame(width: 100)
}
row("Username") {
TextField("your-email@gmail.com", text: Binding(
get: { settingsService.emailUsername ?? "" },
set: { settingsService.emailUsername = $0.isEmpty ? nil : $0 }
))
.textFieldStyle(.roundedBorder)
.frame(width: 250)
}
row("Password") {
HStack {
if showEmailPassword {
TextField("", text: Binding(
get: { settingsService.emailPassword ?? "" },
set: { settingsService.emailPassword = $0.isEmpty ? nil : $0 }
))
.textFieldStyle(.roundedBorder)
} else {
SecureField("", text: Binding(
get: { settingsService.emailPassword ?? "" },
set: { settingsService.emailPassword = $0.isEmpty ? nil : $0 }
))
.textFieldStyle(.roundedBorder)
}
Button(action: { showEmailPassword.toggle() }) {
Image(systemName: showEmailPassword ? "eye.slash" : "eye")
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
}
.frame(width: 250)
}
Text("💡 For Gmail, use an App Password (not your regular password). Go to Google Account > Security > 2-Step Verification > App passwords.")
.font(.system(size: settingsService.guiTextSize - 1))
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
row("") {
HStack {
Button(action: {
Task { await testEmailConnection() }
}) {
HStack {
if isTestingEmailConnection {
ProgressView()
.scaleEffect(0.7)
.frame(width: 14, height: 14)
} else {
Image(systemName: "checkmark.circle")
}
Text("Test Connection")
}
}
.disabled(isTestingEmailConnection || !settingsService.emailServerConfigured)
if let result = emailConnectionTestResult {
Text(result)
.font(.system(size: settingsService.guiTextSize - 1))
.foregroundColor(result.hasPrefix("") ? .green : .red)
.padding(.leading, 8)
}
}
}
divider()
// Email Trigger
sectionHeader("Email Trigger")
row("Subject Identifier") {
TextField("", text: $settingsService.emailSubjectIdentifier)
.textFieldStyle(.roundedBorder)
.frame(width: 200)
}
Text("Only emails with this text in the subject line will be processed. Example: \"[OAIBOT] What's the weather?\"")
.font(.system(size: settingsService.guiTextSize - 1))
.foregroundColor(.secondary)
.padding(.leading, labelWidth + 12)
.fixedSize(horizontal: false, vertical: true)
divider()
// Rate Limiting
sectionHeader("Rate Limiting")
row("Enable Rate Limit") {
Toggle("", isOn: $settingsService.emailRateLimitEnabled)
.toggleStyle(.switch)
}
if settingsService.emailRateLimitEnabled {
row("Max Emails Per Hour") {
HStack {
Slider(value: Binding(
get: { Double(settingsService.emailRateLimitPerHour) },
set: { settingsService.emailRateLimitPerHour = Int($0) }
), in: 1...100, step: 1)
.frame(width: 200)
Text("\(settingsService.emailRateLimitPerHour)")
.font(.system(size: settingsService.guiTextSize))
.frame(width: 40, alignment: .trailing)
if settingsService.emailRateLimitPerHour == 100 {
Text("(Unlimited)")
.font(.system(size: settingsService.guiTextSize - 1))
.foregroundColor(.secondary)
}
}
}
Text("Prevents abuse by limiting how many emails the AI will process per hour. Set to 100 for unlimited.")
.font(.system(size: settingsService.guiTextSize - 1))
.foregroundColor(.secondary)
.padding(.leading, labelWidth + 12)
.fixedSize(horizontal: false, vertical: true)
}
divider()
// Response Settings
sectionHeader("Response Settings")
row("Max Response Tokens") {
HStack {
Slider(value: Binding(
get: { Double(settingsService.emailMaxTokens) },
set: { settingsService.emailMaxTokens = Int($0) }
), in: 100...8000, step: 100)
.frame(width: 200)
Text("\(settingsService.emailMaxTokens)")
.font(.system(size: settingsService.guiTextSize))
.frame(width: 60, alignment: .trailing)
}
}
Text("Limits the length of AI responses to prevent excessive API costs. ~750 tokens = ~500 words.")
.font(.system(size: settingsService.guiTextSize - 1))
.foregroundColor(.secondary)
.padding(.leading, labelWidth + 12)
.fixedSize(horizontal: false, vertical: true)
row("Enable Online Mode") {
Toggle("", isOn: $settingsService.emailOnlineMode)
.toggleStyle(.switch)
}
Text("Allow email handler to search the web for current information. Useful for weather, news, stock prices, or fact-checking. May increase response time and API costs.")
.font(.system(size: settingsService.guiTextSize - 1))
.foregroundColor(.secondary)
.padding(.leading, labelWidth + 12)
.fixedSize(horizontal: false, vertical: true)
divider()
// Custom System Prompt
sectionHeader("Custom System Prompt (Optional)")
VStack(alignment: .leading, spacing: 8) {
// Warning box
HStack(alignment: .top, spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.orange)
VStack(alignment: .leading, spacing: 4) {
Text("Prompt Isolation & Override")
.font(.system(size: settingsService.guiTextSize, weight: .semibold))
.foregroundColor(.orange)
Text("The email handler uses ONLY its own system prompt, completely isolated from your main chat settings. If you provide a custom prompt below, it will override the default email instructions. Your main chat system prompt and any Advanced settings prompts are never used for email handling.")
.font(.system(size: settingsService.guiTextSize - 1))
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
.padding(10)
.background(Color.orange.opacity(0.1))
.cornerRadius(6)
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(Color.orange.opacity(0.3), lineWidth: 1)
)
// Text editor
VStack(alignment: .leading, spacing: 6) {
HStack {
Text("Email Handler System Prompt")
.font(.system(size: settingsService.guiTextSize - 1, weight: .medium))
.foregroundColor(.secondary)
Spacer()
if !emailHandlerSystemPrompt.isEmpty {
Button("Clear") {
emailHandlerSystemPrompt = ""
settingsService.emailHandlerSystemPrompt = nil
}
.font(.system(size: settingsService.guiTextSize - 1))
}
}
TextEditor(text: $emailHandlerSystemPrompt)
.font(.system(size: settingsService.guiTextSize, design: .monospaced))
.frame(height: 120)
.padding(8)
.background(Color.secondary.opacity(0.05))
.cornerRadius(6)
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(Color.secondary.opacity(0.2), lineWidth: 1)
)
.onChange(of: emailHandlerSystemPrompt) {
settingsService.emailHandlerSystemPrompt = emailHandlerSystemPrompt.isEmpty ? nil : emailHandlerSystemPrompt
}
if emailHandlerSystemPrompt.isEmpty {
Text("Leave empty to use the default email handler system prompt. The default prompt instructs the AI to be professional, use proper email etiquette, and format responses in Markdown. This is completely separate from your main chat settings.")
.font(.system(size: settingsService.guiTextSize - 2))
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
} else {
Text("⚠️ Custom email prompt active - Only this prompt will be sent to the model. All other prompts are excluded.")
.font(.system(size: settingsService.guiTextSize - 2))
.foregroundColor(.orange)
.fixedSize(horizontal: false, vertical: true)
}
}
}
divider()
// View Email Log
row("Email Activity") {
Button(action: {
showEmailLog = true
}) {
HStack {
Image(systemName: "envelope.badge.fill")
Text("View Email Log")
}
}
}
Text("View history of processed emails, AI responses, and any errors.")
.font(.system(size: settingsService.guiTextSize - 1))
.foregroundColor(.secondary)
.padding(.leading, labelWidth + 12)
.fixedSize(horizontal: false, vertical: true)
divider()
// MCP Access Notice
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
Image(systemName: "info.circle.fill")
.foregroundColor(.blue)
Text("File Access Permissions")
.font(.system(size: settingsService.guiTextSize, weight: .semibold))
.foregroundColor(.blue)
}
Text("Email tasks have READ-ONLY access to MCP folders. The AI cannot write, delete, or modify files when processing emails.")
.font(.system(size: settingsService.guiTextSize))
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
.padding(12)
.background(Color.blue.opacity(0.05))
.cornerRadius(8)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(Color.blue.opacity(0.3), lineWidth: 1)
)
}
}
.onAppear {
emailHandlerSystemPrompt = settingsService.emailHandlerSystemPrompt ?? ""
Task {
await loadEmailModels()
}
}
.sheet(isPresented: $showEmailModelSelector) {
ModelSelectorView(
models: emailAvailableModels.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending },
selectedModel: emailAvailableModels.first(where: { $0.id == settingsService.emailHandlerModel })
) { selectedModel in
settingsService.emailHandlerModel = selectedModel.id
showEmailModelSelector = false
}
}
}
// MARK: - Shortcuts Tab
@ViewBuilder
private var shortcutsTab: some View {
ShortcutsTabContent()
}
// MARK: - Agent Skills Tab
@ViewBuilder
private var agentSkillsTab: some View {
AgentSkillsTabContent()
}
// 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))
Spacer()
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: - Email Helpers
private func loadEmailModels() async {
guard settingsService.emailHandlerEnabled else {
emailAvailableModels = []
return
}
let providerRawValue = settingsService.emailHandlerProvider
guard let providerType = Settings.Provider(rawValue: providerRawValue),
let provider = ProviderRegistry.shared.getProvider(for: providerType) else {
emailAvailableModels = []
return
}
isLoadingEmailModels = true
defer { isLoadingEmailModels = false }
do {
let models = try await provider.listModels()
emailAvailableModels = models
// If current model is not in the list, select the first one
if !models.contains(where: { $0.id == settingsService.emailHandlerModel }) {
if let firstModel = models.first {
settingsService.emailHandlerModel = firstModel.id
}
}
} catch {
Log.ui.error("Failed to load email models: \(error.localizedDescription)")
emailAvailableModels = []
}
}
private func testEmailConnection() async {
isTestingEmailConnection = true
emailConnectionTestResult = nil
do {
let result = try await EmailService.shared.testConnection()
emailConnectionTestResult = "\(result)"
} catch {
emailConnectionTestResult = "\(error.localizedDescription)"
}
isTestingEmailConnection = false
}
// MARK: - Sync Helpers
private func testSyncConnection() async {
isTestingSync = true
syncTestResult = nil
do {
let result = try await gitSync.testConnection()
syncTestResult = "\(result)"
} catch {
syncTestResult = "\(error.localizedDescription)"
}
isTestingSync = false
}
private var syncStatusIcon: String {
guard settingsService.syncEnabled else { return "externaldrive.slash" }
guard settingsService.syncConfigured else { return "exclamationmark.triangle" }
guard gitSync.syncStatus.isCloned else { return "externaldrive.badge.questionmark" }
return "externaldrive.badge.checkmark"
}
private var syncStatusColor: Color {
guard settingsService.syncEnabled else { return .secondary }
guard settingsService.syncConfigured else { return .orange }
guard gitSync.syncStatus.isCloned else { return .orange }
return .green
}
private var syncStatusText: String {
guard settingsService.syncEnabled else { return "Disabled" }
guard settingsService.syncConfigured else { return "Not configured" }
guard gitSync.syncStatus.isCloned else { return "Not cloned" }
return "Ready"
}
private func cloneRepo() async {
do {
try await gitSync.cloneRepository()
syncTestResult = "✓ Repository cloned successfully"
} catch {
syncTestResult = "\(error.localizedDescription)"
}
}
private func exportConversations() async {
do {
try await gitSync.exportAllConversations()
syncTestResult = "✓ Conversations exported"
} catch {
syncTestResult = "\(error.localizedDescription)"
}
}
private func pushToGit() async {
do {
// First export conversations
try await gitSync.exportAllConversations()
// Then push
try await gitSync.push()
syncTestResult = "✓ Changes pushed successfully"
} catch {
syncTestResult = "\(error.localizedDescription)"
}
}
private func pullFromGit() async {
do {
try await gitSync.pull()
syncTestResult = "✓ Changes pulled successfully"
} catch {
syncTestResult = "\(error.localizedDescription)"
}
}
private func importConversations() async {
do {
let result = try await gitSync.importAllConversations()
syncTestResult = "✓ Imported \(result.imported) conversations (skipped \(result.skipped) duplicates)"
} catch {
syncTestResult = "\(error.localizedDescription)"
}
}
private func syncNow() async {
isSyncing = true
syncTestResult = nil
do {
// Step 1: Export all conversations
syncTestResult = "Exporting conversations..."
try await gitSync.exportAllConversations()
// Step 2: Pull from remote
syncTestResult = "Pulling changes..."
try await gitSync.pull()
// Step 3: Import any new conversations
syncTestResult = "Importing conversations..."
let result = try await gitSync.importAllConversations()
// Step 4: Push to remote
syncTestResult = "Pushing changes..."
try await gitSync.push()
// Success
await gitSync.updateStatus()
syncTestResult = "✓ Sync complete: \(result.imported) imported, \(result.skipped) skipped"
} catch {
syncTestResult = "✗ Sync failed: \(error.localizedDescription)"
}
isSyncing = false
}
private var tokenGenerationURL: String? {
let url = settingsService.syncRepoURL.lowercased()
if url.contains("github.com") {
return "https://github.com/settings/tokens"
} else if url.contains("gitlab.com") {
return "https://gitlab.com/-/profile/personal_access_tokens"
} else if url.contains("gitea") {
return extractProvider() + "/user/settings/applications"
} else {
return nil
}
}
private func extractProvider() -> String {
let url = settingsService.syncRepoURL
if url.contains("github.com") {
return "GitHub"
} else if url.contains("gitlab.com") {
return "GitLab"
} else if url.contains("gitea") {
return "Gitea"
} else {
return "Git repository"
}
}
private func timeAgo(_ date: Date) -> String {
let seconds = Int(Date().timeIntervalSince(date))
if seconds < 60 {
return "just now"
} else if seconds < 3600 {
let minutes = seconds / 60
return "\(minutes) minute\(minutes == 1 ? "" : "s") ago"
} else if seconds < 86400 {
let hours = seconds / 3600
return "\(hours) hour\(hours == 1 ? "" : "s") ago"
} else {
let days = seconds / 86400
return "\(days) day\(days == 1 ? "" : "s") ago"
}
}
}
#Preview {
SettingsView()
}