Small feature changes and bug fixes

This commit is contained in:
2026-02-16 13:17:08 +01:00
parent 04c9b8da1e
commit 25bcca213e
20 changed files with 2193 additions and 125 deletions

View File

@@ -12,6 +12,12 @@ 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 = ""
@@ -31,6 +37,7 @@ struct SettingsView: View {
@State private var showSyncToken = false
@State private var isTestingSync = false
@State private var syncTestResult: String?
@State private var isSyncing = false
// OAuth state
@State private var oauthCode = ""
@@ -769,6 +776,141 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
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)
@@ -1066,57 +1208,72 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
row("") {
HStack(spacing: 12) {
Button("Clone Repository") {
Task { await cloneRepo() }
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)
}
.disabled(!settingsService.syncConfigured)
Button("Export All") {
Task { await exportConversations() }
}
.disabled(!GitSyncService.shared.syncStatus.isCloned)
Button("Push") {
Task { await pushToGit() }
}
.disabled(!GitSyncService.shared.syncStatus.isCloned)
Button("Pull") {
Task { await pullFromGit() }
}
.disabled(!GitSyncService.shared.syncStatus.isCloned)
Button("Import") {
Task { await importConversations() }
}
.disabled(!GitSyncService.shared.syncStatus.isCloned)
Spacer()
}
}
// Status
if GitSyncService.shared.syncStatus.isCloned {
if gitSync.syncStatus.isCloned {
HStack {
Spacer().frame(width: labelWidth + 12)
VStack(alignment: .leading, spacing: 4) {
if let lastSync = GitSyncService.shared.syncStatus.lastSyncTime {
if let lastSync = gitSync.syncStatus.lastSyncTime {
Text("Last sync: \(timeAgo(lastSync))")
.font(.system(size: 13))
.foregroundStyle(.secondary)
}
if GitSyncService.shared.syncStatus.uncommittedChanges > 0 {
Text("Uncommitted changes: \(GitSyncService.shared.syncStatus.uncommittedChanges)")
if gitSync.syncStatus.uncommittedChanges > 0 {
Text("Uncommitted changes: \(gitSync.syncStatus.uncommittedChanges)")
.font(.system(size: 13))
.foregroundStyle(.orange)
}
if let branch = GitSyncService.shared.syncStatus.currentBranch {
if let branch = gitSync.syncStatus.currentBranch {
Text("Branch: \(branch)")
.font(.system(size: 13))
.foregroundStyle(.secondary)
}
if let status = GitSyncService.shared.syncStatus.remoteStatus {
if let status = gitSync.syncStatus.remoteStatus {
Text("Remote: \(status)")
.font(.system(size: 13))
.foregroundStyle(.secondary)
@@ -1133,6 +1290,11 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
syncUsername = settingsService.syncUsername ?? ""
syncPassword = settingsService.syncPassword ?? ""
syncAccessToken = settingsService.syncAccessToken ?? ""
// Update sync status to check if repository is already cloned
Task {
await gitSync.updateStatus()
}
}
}
@@ -1691,7 +1853,7 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
syncTestResult = nil
do {
let result = try await GitSyncService.shared.testConnection()
let result = try await gitSync.testConnection()
syncTestResult = "\(result)"
} catch {
syncTestResult = "\(error.localizedDescription)"
@@ -1703,27 +1865,27 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
private var syncStatusIcon: String {
guard settingsService.syncEnabled else { return "externaldrive.slash" }
guard settingsService.syncConfigured else { return "exclamationmark.triangle" }
guard GitSyncService.shared.syncStatus.isCloned else { return "externaldrive.badge.questionmark" }
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 GitSyncService.shared.syncStatus.isCloned 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 GitSyncService.shared.syncStatus.isCloned else { return "Not cloned" }
guard gitSync.syncStatus.isCloned else { return "Not cloned" }
return "Ready"
}
private func cloneRepo() async {
do {
try await GitSyncService.shared.cloneRepository()
try await gitSync.cloneRepository()
syncTestResult = "✓ Repository cloned successfully"
} catch {
syncTestResult = "\(error.localizedDescription)"
@@ -1732,7 +1894,7 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
private func exportConversations() async {
do {
try await GitSyncService.shared.exportAllConversations()
try await gitSync.exportAllConversations()
syncTestResult = "✓ Conversations exported"
} catch {
syncTestResult = "\(error.localizedDescription)"
@@ -1742,9 +1904,9 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
private func pushToGit() async {
do {
// First export conversations
try await GitSyncService.shared.exportAllConversations()
try await gitSync.exportAllConversations()
// Then push
try await GitSyncService.shared.push()
try await gitSync.push()
syncTestResult = "✓ Changes pushed successfully"
} catch {
syncTestResult = "\(error.localizedDescription)"
@@ -1753,7 +1915,7 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
private func pullFromGit() async {
do {
try await GitSyncService.shared.pull()
try await gitSync.pull()
syncTestResult = "✓ Changes pulled successfully"
} catch {
syncTestResult = "\(error.localizedDescription)"
@@ -1762,13 +1924,44 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
private func importConversations() async {
do {
let result = try await GitSyncService.shared.importAllConversations()
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") {