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

@@ -14,14 +14,23 @@ struct ConversationListView: View {
@State private var conversations: [Conversation] = []
@State private var selectedConversations: Set<UUID> = []
@State private var isSelecting = false
@State private var useSemanticSearch = false
@State private var semanticResults: [Conversation] = []
@State private var isSearching = false
private let settings = SettingsService.shared
var onLoad: ((Conversation) -> Void)?
private var filteredConversations: [Conversation] {
if searchText.isEmpty {
return conversations
}
return conversations.filter {
$0.name.lowercased().contains(searchText.lowercased())
if useSemanticSearch && settings.embeddingsEnabled {
return semanticResults
} else {
return conversations.filter {
$0.name.lowercased().contains(searchText.lowercased())
}
}
}
@@ -79,6 +88,11 @@ struct ConversationListView: View {
.foregroundStyle(.secondary)
TextField("Search conversations...", text: $searchText)
.textFieldStyle(.plain)
.onChange(of: searchText) {
if useSemanticSearch && settings.embeddingsEnabled && !searchText.isEmpty {
performSemanticSearch()
}
}
if !searchText.isEmpty {
Button { searchText = "" } label: {
Image(systemName: "xmark.circle.fill")
@@ -86,6 +100,25 @@ struct ConversationListView: View {
}
.buttonStyle(.plain)
}
if settings.embeddingsEnabled {
Divider()
.frame(height: 16)
Toggle("Semantic", isOn: $useSemanticSearch)
.toggleStyle(.switch)
.controlSize(.small)
.onChange(of: useSemanticSearch) {
if useSemanticSearch && !searchText.isEmpty {
performSemanticSearch()
}
}
.help("Use AI-powered semantic search instead of keyword matching")
}
if isSearching {
ProgressView()
.controlSize(.small)
}
}
.padding(10)
.background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 8))
@@ -231,6 +264,52 @@ struct ConversationListView: View {
}
}
private func performSemanticSearch() {
guard !searchText.isEmpty else {
semanticResults = []
return
}
isSearching = true
Task {
do {
// Use user's selected provider, or fall back to best available
guard let provider = EmbeddingService.shared.getSelectedProvider() else {
Log.api.warning("No embedding providers available - skipping semantic search")
await MainActor.run {
isSearching = false
}
return
}
// Generate embedding for search query
let embedding = try await EmbeddingService.shared.generateEmbedding(
text: searchText,
provider: provider
)
// Search conversations
let results = try DatabaseService.shared.searchConversationsBySemantic(
queryEmbedding: embedding,
limit: 20
)
await MainActor.run {
semanticResults = results.map { $0.0 }
isSearching = false
Log.ui.info("Semantic search found \(results.count) results using \(provider.displayName)")
}
} catch {
await MainActor.run {
semanticResults = []
isSearching = false
Log.ui.error("Semantic search failed: \(error)")
}
}
}
}
private func exportConversation(_ conversation: Conversation) {
guard let (_, loadedMessages) = try? DatabaseService.shared.loadConversation(id: conversation.id),
!loadedMessages.isEmpty else {

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") {