// // ConversationListView.swift // oAI // // Saved conversations list // import os import SwiftUI struct ConversationListView: View { @Environment(\.dismiss) var dismiss @State private var searchText = "" @State private var conversations: [Conversation] = [] @State private var selectedConversations: Set = [] @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 } if useSemanticSearch && settings.embeddingsEnabled { return semanticResults } else { return conversations.filter { $0.name.lowercased().contains(searchText.lowercased()) } } } var body: some View { VStack(spacing: 0) { // Header HStack { Text("Conversations") .font(.system(size: 18, weight: .bold)) Spacer() if isSelecting { Button("Cancel") { isSelecting = false selectedConversations.removeAll() } .buttonStyle(.plain) if !selectedConversations.isEmpty { Button(role: .destructive) { deleteSelected() } label: { HStack(spacing: 4) { Image(systemName: "trash") Text("Delete (\(selectedConversations.count))") } } .buttonStyle(.plain) .foregroundStyle(.red) } } else { if !conversations.isEmpty { Button("Select") { isSelecting = true } .buttonStyle(.plain) } Button { dismiss() } label: { Image(systemName: "xmark.circle.fill") .font(.title2) .foregroundStyle(.secondary) } .buttonStyle(.plain) .keyboardShortcut(.escape, modifiers: []) } } .padding(.horizontal, 24) .padding(.top, 20) .padding(.bottom, 12) // Search bar HStack(spacing: 8) { Image(systemName: "magnifyingglass") .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") .foregroundStyle(.tertiary) } .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)) .padding(.horizontal, 24) .padding(.bottom, 12) Divider() // Content if filteredConversations.isEmpty { Spacer() VStack(spacing: 8) { Image(systemName: searchText.isEmpty ? "tray" : "magnifyingglass") .font(.largeTitle) .foregroundStyle(.tertiary) Text(searchText.isEmpty ? "No Saved Conversations" : "No Matches") .font(.headline) .foregroundStyle(.secondary) Text(searchText.isEmpty ? "Conversations you save will appear here" : "Try a different search term") .font(.caption) .foregroundStyle(.tertiary) } Spacer() } else { List { ForEach(filteredConversations) { conversation in HStack(spacing: 12) { if isSelecting { Button { toggleSelection(conversation.id) } label: { Image(systemName: selectedConversations.contains(conversation.id) ? "checkmark.circle.fill" : "circle") .foregroundStyle(selectedConversations.contains(conversation.id) ? .blue : .secondary) .font(.title2) } .buttonStyle(.plain) } ConversationRow(conversation: conversation) .contentShape(Rectangle()) .onTapGesture { if isSelecting { toggleSelection(conversation.id) } else { onLoad?(conversation) dismiss() } } Spacer() if !isSelecting { Button { deleteConversation(conversation) } label: { Image(systemName: "trash") .foregroundStyle(.red) .font(.system(size: 16)) } .buttonStyle(.plain) .help("Delete conversation") } } .swipeActions(edge: .trailing, allowsFullSwipe: false) { Button(role: .destructive) { deleteConversation(conversation) } label: { Label("Delete", systemImage: "trash") } Button { exportConversation(conversation) } label: { Label("Export", systemImage: "square.and.arrow.up") } .tint(.blue) } } } .listStyle(.plain) } Divider() // Bottom bar HStack { Spacer() Button("Done") { dismiss() } .keyboardShortcut(.return, modifiers: []) .buttonStyle(.borderedProminent) .controlSize(.regular) Spacer() } .padding(.horizontal, 24) .padding(.vertical, 12) } .onAppear { loadConversations() } .frame(minWidth: 700, idealWidth: 800, minHeight: 500, idealHeight: 600) } private func loadConversations() { do { conversations = try DatabaseService.shared.listConversations() } catch { Log.db.error("Failed to load conversations: \(error.localizedDescription)") conversations = [] } } private func toggleSelection(_ id: UUID) { if selectedConversations.contains(id) { selectedConversations.remove(id) } else { selectedConversations.insert(id) } } private func deleteSelected() { for id in selectedConversations { do { let _ = try DatabaseService.shared.deleteConversation(id: id) } catch { Log.db.error("Failed to delete conversation: \(error.localizedDescription)") } } withAnimation { conversations.removeAll { selectedConversations.contains($0.id) } selectedConversations.removeAll() isSelecting = false } } private func deleteConversation(_ conversation: Conversation) { do { let _ = try DatabaseService.shared.deleteConversation(id: conversation.id) withAnimation { conversations.removeAll { $0.id == conversation.id } } } catch { Log.db.error("Failed to delete conversation: \(error.localizedDescription)") } } 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 { return } let content = loadedMessages.map { msg in let header = msg.role == .user ? "**User**" : "**Assistant**" return "\(header)\n\n\(msg.content)" }.joined(separator: "\n\n---\n\n") let downloads = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first ?? FileManager.default.temporaryDirectory let filename = conversation.name.replacingOccurrences(of: " ", with: "_") + ".md" let fileURL = downloads.appendingPathComponent(filename) try? content.write(to: fileURL, atomically: true, encoding: .utf8) } } struct ConversationRow: View { let conversation: Conversation private var formattedDate: String { let formatter = DateFormatter() formatter.dateFormat = "dd.MM.yyyy HH:mm:ss" return formatter.string(from: conversation.updatedAt) } var body: some View { VStack(alignment: .leading, spacing: 8) { Text(conversation.name) .font(.system(size: 16, weight: .semibold)) HStack(spacing: 8) { Label("\(conversation.messageCount)", systemImage: "message") .font(.system(size: 13)) Text("\u{2022}") .font(.system(size: 13)) Text(formattedDate) .font(.system(size: 13)) } .foregroundColor(.secondary) } .padding(.vertical, 6) } } #Preview { ConversationListView() }