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

@@ -32,7 +32,7 @@ struct ChatView: View {
ScrollView {
LazyVStack(alignment: .leading, spacing: 12) {
ForEach(viewModel.messages) { message in
MessageRow(message: message)
MessageRow(message: message, viewModel: viewModel)
.id(message.id)
}

View File

@@ -60,7 +60,7 @@ struct ContentView: View {
.sheet(isPresented: $vm.showSettings, onDismiss: {
chatViewModel.syncFromSettings()
}) {
SettingsView()
SettingsView(chatViewModel: chatViewModel)
}
.sheet(isPresented: $vm.showStats) {
StatsView(

View File

@@ -84,6 +84,7 @@ struct FooterItem: View {
struct SyncStatusFooter: View {
private let gitSync = GitSyncService.shared
private let settings = SettingsService.shared
private let guiSize = SettingsService.shared.guiTextSize
@State private var syncText = "Not Synced"
@State private var syncColor: Color = .secondary
@@ -104,17 +105,23 @@ struct SyncStatusFooter: View {
.onChange(of: gitSync.syncStatus.lastSyncTime) {
updateSyncStatus()
}
.onChange(of: gitSync.syncStatus.isCloned) {
updateSyncStatus()
}
.onChange(of: gitSync.lastSyncError) {
updateSyncStatus()
}
.onChange(of: gitSync.isSyncing) {
updateSyncStatus()
}
.onChange(of: settings.syncConfigured) {
updateSyncStatus()
}
}
private func updateSyncStatus() {
if let error = gitSync.lastSyncError {
syncText = "Error With Sync"
syncText = "Sync Error"
syncColor = .red
} else if gitSync.isSyncing {
syncText = "Syncing..."
@@ -123,10 +130,13 @@ struct SyncStatusFooter: View {
syncText = "Last Sync: \(timeAgo(lastSync))"
syncColor = .green
} else if gitSync.syncStatus.isCloned {
syncText = "Not Synced"
syncText = "Sync: Ready"
syncColor = .secondary
} else if settings.syncConfigured {
syncText = "Sync: Not Initialized"
syncColor = .orange
} else {
syncText = "Not Configured"
syncText = "Sync: Off"
syncColor = .secondary
}
}

View File

@@ -12,13 +12,20 @@ import AppKit
struct MessageRow: View {
let message: Message
let viewModel: ChatViewModel?
private let settings = SettingsService.shared
#if os(macOS)
@State private var isHovering = false
@State private var showCopied = false
@State private var isStarred = false
#endif
init(message: Message, viewModel: ChatViewModel? = nil) {
self.message = message
self.viewModel = viewModel
}
var body: some View {
// Compact layout for system messages (tool calls)
if message.role == .system && !isErrorMessage {
@@ -45,6 +52,18 @@ struct MessageRow: View {
Spacer()
#if os(macOS)
// Star button (user/assistant messages only, visible on hover)
if (message.role == .user || message.role == .assistant) && isHovering {
Button(action: toggleStar) {
Image(systemName: isStarred ? "star.fill" : "star")
.font(.system(size: 11))
.foregroundColor(isStarred ? .yellow : .oaiSecondary)
}
.buttonStyle(.plain)
.transition(.opacity)
.help("Star this message to always include it in context")
}
// Copy button (assistant messages only, visible on hover)
if message.role == .assistant && isHovering && !message.content.isEmpty {
Button(action: copyContent) {
@@ -138,6 +157,9 @@ struct MessageRow: View {
isHovering = hovering
}
}
.onAppear {
loadStarredState()
}
#endif
}
@@ -235,6 +257,17 @@ struct MessageRow: View {
}
}
}
private func loadStarredState() {
if let metadata = try? DatabaseService.shared.getMessageMetadata(messageId: message.id) {
isStarred = metadata.user_starred == 1
}
}
private func toggleStar() {
viewModel?.toggleMessageStar(messageId: message.id)
isStarred.toggle()
}
#endif
private var roleIcon: some View {

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