362 lines
13 KiB
Swift
362 lines
13 KiB
Swift
//
|
|
// 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<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
|
|
}
|
|
|
|
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()
|
|
}
|