Small feature changes and bug fixes
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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") {
|
||||
|
||||
Reference in New Issue
Block a user