diff --git a/LexAI_iOS/LexAI_iOS/Managers/FirebaseManager.swift b/LexAI_iOS/LexAI_iOS/Managers/FirebaseManager.swift index d0dcfbb..b6a4cd4 100644 --- a/LexAI_iOS/LexAI_iOS/Managers/FirebaseManager.swift +++ b/LexAI_iOS/LexAI_iOS/Managers/FirebaseManager.swift @@ -1,8 +1,9 @@ // -// AuthManager.swift +// FirebaseManager.swift // LexAI_iOS // // Created by Hassan Alkhafaji on 2/16/26. +// Extended with session/message persistence — Sprint 4 // import Foundation @@ -10,16 +11,16 @@ import Combine import FirebaseAuth import FirebaseFirestore - class FirebaseManager: ObservableObject { - + @Published var user: User? @Published var isAuthenticated = false @Published var errorMessage: String? @Published var isLoading = false - + private var authStateListener: AuthStateDidChangeListenerHandle? - + private let db = Firestore.firestore() + init() { authStateListener = Auth.auth().addStateDidChangeListener { [weak self] _, user in Task { @MainActor in @@ -27,8 +28,6 @@ class FirebaseManager: ObservableObject { self?.isAuthenticated = user != nil } } - - // Ensure callable Functions can be tested without UI sign-in. if Auth.auth().currentUser == nil { Task { @MainActor in do { @@ -39,19 +38,132 @@ class FirebaseManager: ObservableObject { } } } - + deinit { if let listener = authStateListener { Auth.auth().removeStateDidChangeListener(listener) } } - + // MARK: - Firestore path helpers - // MARK: -chat storage - mirshod 3/13 - func saveChat(prompt: ChatPrompt, completion: @escaping (Bool) -> Void) { - let db = Firestore.firestore() + private var uid: String? { Auth.auth().currentUser?.uid } + + private func sessionsRef() -> CollectionReference? { + guard let uid else { return nil } + return db.collection("users").document(uid).collection("sessions") + } + + private func messagesRef(sessionId: String) -> CollectionReference? { + return sessionsRef()?.document(sessionId).collection("messages") + } + + // MARK: - Session: Create + + func createSession(_ session: ChatSession) { + guard let ref = sessionsRef() else { return } + var data: [String: Any] = [ + "title": session.title, + "preview": session.preview, + "createdAt": Timestamp(date: session.createdAt) + ] + if let tag = session.tag { data["tag"] = tag.rawValue } + ref.document(session.id.uuidString).setData(data) + } + + // MARK: - Session: Load all (for sidebar) + + func loadSessions(completion: @escaping ([ChatSession]) -> Void) { + guard let ref = sessionsRef() else { completion([]); return } + + ref.order(by: "createdAt", descending: true).getDocuments { snapshot, error in + if let error { print("loadSessions error: \(error)"); completion([]); return } + + let sessions: [ChatSession] = snapshot?.documents.compactMap { doc in + let d = doc.data() + return ChatSession( + id: UUID(uuidString: doc.documentID) ?? UUID(), + title: d["title"] as? String ?? "Conversation", + preview: d["preview"] as? String ?? "", + tag: (d["tag"] as? String).flatMap { SessionTag(rawValue: $0) }, + createdAt: (d["createdAt"] as? Timestamp)?.dateValue() ?? Date() + ) + } ?? [] + + DispatchQueue.main.async { completion(sessions) } + } + } + + // MARK: - Session: Update title/preview/tag + + func updateSession(_ session: ChatSession) { + guard let ref = sessionsRef() else { return } + var data: [String: Any] = [ + "title": session.title, + "preview": session.preview + ] + if let tag = session.tag { data["tag"] = tag.rawValue } + ref.document(session.id.uuidString).updateData(data) + } + + // MARK: - Session: Rename + + func renameSession(_ session: ChatSession, to title: String) { + guard let ref = sessionsRef() else { return } + ref.document(session.id.uuidString).updateData(["title": title]) + } + + // MARK: - Session: Delete (+ all messages) + + func deleteSession(_ session: ChatSession) { + guard let ref = sessionsRef() else { return } + let sessionDoc = ref.document(session.id.uuidString) + sessionDoc.collection("messages").getDocuments { snapshot, _ in + let batch = self.db.batch() + snapshot?.documents.forEach { batch.deleteDocument($0.reference) } + batch.deleteDocument(sessionDoc) + batch.commit() + } + } + + // MARK: - Message: Save + + func saveMessage(_ message: ChatMessage, to session: ChatSession) { + guard let ref = messagesRef(sessionId: session.id.uuidString) else { return } + ref.document(message.id.uuidString).setData([ + "text": message.text, + "isFromUser": message.isFromUser, + "date": Timestamp(date: message.date) + ]) + } + + // MARK: - Message: Load all for a session + + func loadMessages(for session: ChatSession, completion: @escaping ([ChatMessage]) -> Void) { + guard let ref = messagesRef(sessionId: session.id.uuidString) else { completion([]); return } + + ref.order(by: "date", descending: false).getDocuments { snapshot, error in + if let error { print("loadMessages error: \(error)"); completion([]); return } + let messages: [ChatMessage] = snapshot?.documents.compactMap { doc in + let d = doc.data() + guard let text = d["text"] as? String, + let isFromUser = d["isFromUser"] as? Bool else { return nil } + return ChatMessage( + id: UUID(uuidString: doc.documentID) ?? UUID(), + text: text, + isFromUser: isFromUser, + date: (d["date"] as? Timestamp)?.dateValue() ?? Date() + ) + } ?? [] + + DispatchQueue.main.async { completion(messages) } + } + } + + // MARK: - Legacy: single-prompt chat history (kept for backwards compatibility) + + func saveChat(prompt: ChatPrompt, completion: @escaping (Bool) -> Void) { let data: [String: Any] = [ "prompt": prompt.prompt, "documents": prompt.documents, @@ -60,71 +172,47 @@ class FirebaseManager: ObservableObject { "user": prompt.user, "timestamp": FieldValue.serverTimestamp() ] - db.collection("chatHistory").addDocument(data: data) { error in - if let error = error { - print("Error saving chat: \(error)") - completion(false) - } else{ - print("Chat successfully saved.") - completion(true) - } + completion(error == nil) } } - // MARK: - fetching chats (could be used in sidebar) - mirshod 3/24 func fetchChats(userId: String, completion: @escaping ([ChatPrompt]) -> Void) { - let db = Firestore.firestore() - db.collection("chatHistory") .whereField("user", isEqualTo: userId) - .getDocuments { snapshot, error in - var chats: [ChatPrompt] = [] - - if let documents = snapshot?.documents { - for doc in documents { - let data = doc.data() - - let chat = ChatPrompt( - prompt: data["prompt"] as? String ?? "", - documents: data["documents"] as? [String] ?? [], - location: data["location"] as? String ?? "", - language: "", - user: data["user"] as? String ?? "" - ) - chats.append(chat) - } - } + .getDocuments { snapshot, _ in + let chats: [ChatPrompt] = snapshot?.documents.compactMap { doc in + let d = doc.data() + return ChatPrompt( + prompt: d["prompt"] as? String ?? "", + documents: d["documents"] as? [String] ?? [], + location: d["location"] as? String ?? "", + language: d["language"] as? String ?? "", + user: d["user"] as? String ?? "" + ) + } ?? [] completion(chats) } } - // MARK: - deleting a chat - mirshod 3/24 func deleteChat(chatId: String, completion: @escaping (Bool) -> Void) { - let db = Firestore.firestore() - db.collection("chatHistory") - .document(chatId) - .delete { error in - completion(error == nil) - } + db.collection("chatHistory").document(chatId).delete { error in + completion(error == nil) + } } - // MARK - updating chat storage - mirshod 3/24 func updateChat(chatId: String, newPrompt: String, completion: @escaping (Bool) -> Void) { - let db = Firestore.firestore() - - db.collection("chatHistory") - .document(chatId) + db.collection("chatHistory").document(chatId) .updateData(["prompt": newPrompt]) { error in completion(error == nil) } } - - + + // MARK: - Auth + func signUp(email: String, password: String) async { isLoading = true errorMessage = nil - do { let result = try await Auth.auth().createUser(withEmail: email, password: password) self.user = result.user @@ -132,36 +220,23 @@ class FirebaseManager: ObservableObject { } catch { self.errorMessage = mapFirebaseError(error) } - isLoading = false } - -// Import XCTests -// Add the special tag over the Test function -// Create a function definition that will call the function your testing with mock data -// Test failures -// Test successes - - + @MainActor func signIn(email: String, password: String) async { isLoading = true errorMessage = nil - do { let result = try await Auth.auth().signIn(withEmail: email, password: password) self.user = result.user self.isAuthenticated = true - print("*****Successfully signed in") - } catch { self.errorMessage = mapFirebaseError(error) - print("SIGN IN ERROR: \(error)") // Add this to see the real error } - isLoading = false } - + @MainActor func signOut() { do { @@ -172,39 +247,28 @@ class FirebaseManager: ObservableObject { self.errorMessage = error.localizedDescription } } - - - + private func mapFirebaseError(_ error: Error) -> String { let nsError = error as NSError guard let errorCode = AuthErrorCode(rawValue: nsError.code) else { return error.localizedDescription } - switch errorCode { - case .emailAlreadyInUse: - return "This email is already in use." - case .invalidEmail: - return "Please enter a valid email address." - case .weakPassword: - return "Password must be at least 6 characters." - case .wrongPassword: - return "Incorrect password. Please try again." - case .userNotFound: - return "No account found with this email." - case .networkError: - return "Network error. Please check your connection." - case .tooManyRequests: - return "Too many attempts. Please try again later." - default: - return error.localizedDescription + case .emailAlreadyInUse: return "This email is already in use." + case .invalidEmail: return "Please enter a valid email address." + case .weakPassword: return "Password must be at least 6 characters." + case .wrongPassword: return "Incorrect password. Please try again." + case .userNotFound: return "No account found with this email." + case .networkError: return "Network error. Please check your connection." + case .tooManyRequests: return "Too many attempts. Please try again later." + default: return error.localizedDescription } } } +// MARK: - Models -// struct for storing chats -struct ChatPrompt{ +struct ChatPrompt { let prompt: String let documents: [String] let location: String diff --git a/LexAI_iOS/LexAI_iOS/Views/ChatView.swift b/LexAI_iOS/LexAI_iOS/Views/ChatView.swift index d4fc469..fdb5b22 100644 --- a/LexAI_iOS/LexAI_iOS/Views/ChatView.swift +++ b/LexAI_iOS/LexAI_iOS/Views/ChatView.swift @@ -1,24 +1,20 @@ -// // ChatView.swift // LexAI_iOS -// import SwiftUI import FirebaseFunctions -import FirebaseAuth private let bottomAnchorId = "bottom" struct ChatView: View { - @State private var messages: [ChatMessage] = [] - @State private var inputText: String = "" - @State private var showScanDocuments = false - @State private var isAwaitingReply = false + @Binding var messages: [ChatMessage] + @Binding var selectedLanguage: String - @Binding var selectedLanguage: String // language in conversation - @EnvironmentObject var firebaseManager: FirebaseManager - @Environment(\.scenePhase) private var scenePhase // to detect app exit + var vm: SidebarViewModel? = nil + var sessionID: UUID? = nil + @State private var inputText: String = "" + @State private var isAwaitingReply = false private let functions = Functions.functions() var body: some View { @@ -46,63 +42,28 @@ struct ChatView: View { ) .ignoresSafeArea() ) - .fullScreenCover(isPresented: $showScanDocuments) { - #if targetEnvironment(simulator) - VStack(spacing: 20) { - Text("Document Scanner Preview") - .font(.headline) - .padding() - Button("Dismiss") { showScanDocuments = false } - .buttonStyle(.borderedProminent) - } - #else - ScanDocumentsView(isPresented: $showScanDocuments) { scannedText in - messages.append(ChatMessage(text: scannedText, isFromUser: true)) - } - #endif - } - // trigger 1: user leaves the view (new chat) - .onDisappear { - saveChatIfNeeded() - } - - // trigger 2: app goes to background or is killed - .onChange(of: scenePhase) { _, newPhase in - if newPhase == .background || newPhase == .inactive { - saveChatIfNeeded() - } - } - } - - // MARK: - Save chat - private func saveChatIfNeeded() { - guard !messages.isEmpty else { return } - guard let userId = firebaseManager.user?.uid else { return } - - // Build a readable transcript from the message array - let transcript = messages - .map { ($0.isFromUser ? "User" : "LexAI") + ": " + $0.text } - .joined(separator: "\n") - - let chatPrompt = ChatPrompt( - prompt: transcript, - documents: [], - location: "", - language: selectedLanguage, - user: userId - ) - - firebaseManager.saveChat(prompt: chatPrompt) { _ in } } private var messageList: some View { ScrollViewReader { proxy in ScrollView { LazyVStack(alignment: .leading, spacing: 12) { + if messages.isEmpty { + Text("Start a new chat") + .font(.system(size: 16, weight: .medium)) + .foregroundStyle(.white.opacity(0.9)) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.vertical, 24) + } + ForEach(messages) { message in MessageBubbleView(message: message) } + if isAwaitingReply { + ThinkingBubbleView() + .transition(.opacity.combined(with: .scale(scale: 0.8))) + } Color.clear .frame(height: 8) .id(bottomAnchorId) @@ -121,41 +82,32 @@ struct ChatView: View { } private var inputBar: some View { - VStack { - HStack(alignment: .bottom, spacing: 12) { - Button(action: { showScanDocuments = true }) { - Image(systemName: "document.viewfinder") - .resizable() - .frame(width: 35, height: 35) - .fontWeight(.semibold) - .foregroundStyle(Color.white) - .shadow(radius: 8, x: 0, y: 8) - } - .padding(.bottom, 4) - - TextField(ChatPlaceholderText.placeholder(forSelectedLanguage: selectedLanguage), text: $inputText, axis: .vertical) - .textFieldStyle(.plain) - .padding(.horizontal, 12) - .padding(.vertical, 10) - .background(Color(.tertiarySystemGroupedBackground)) - .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) - .lineLimit(1...6) - - Button { - sendMessage() - } label: { - Image(systemName: isAwaitingReply ? "clock.arrow.circlepath" : "arrow.up.circle.fill") - .resizable() - .frame(width: 35, height: 35) - .foregroundStyle(inputText.isEmpty ? Color.white.opacity(0.6) : Color.white) - } - .disabled(inputText.isEmpty || isAwaitingReply) - .padding(.bottom, 4) - + HStack(alignment: .bottom, spacing: 12) { + TextField( + ChatPlaceholderText.placeholder(forSelectedLanguage: selectedLanguage), + text: $inputText, + axis: .vertical + ) + .textFieldStyle(.plain) + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background(Color(.tertiarySystemGroupedBackground)) + .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) + .lineLimit(1...6) + + Button { + sendMessage() + } label: { + Image(systemName: isAwaitingReply ? "clock.arrow.circlepath" : "arrow.up.circle.fill") + .resizable() + .frame(width: 35, height: 35) + .foregroundStyle(inputText.isEmpty ? Color.white.opacity(0.6) : Color.white) } - .padding(.horizontal, 4) - .padding(.top) + .disabled(inputText.isEmpty || isAwaitingReply) + .padding(.bottom, 4) } + .padding(.horizontal, 4) + .padding(.top) } private func sendMessage() { @@ -163,7 +115,9 @@ struct ChatView: View { guard ChatInputValidator.shouldSendMessage(inputText) else { return } inputText = "" + messages.append(ChatMessage(text: text, isFromUser: true)) + if let id = sessionID { vm?.updateSession(id: id, messages: messages) } Task { @MainActor in isAwaitingReply = true @@ -172,6 +126,7 @@ struct ChatView: View { do { let reply = try await generateAnswer(prompt: text, targetLanguage: selectedLanguage) messages.append(ChatMessage(text: reply, isFromUser: false)) + if let id = sessionID { vm?.updateSession(id: id, messages: messages) } } catch { messages.append( ChatMessage(text: ChatReplyErrorFormatter.replyErrorMessage(for: error), isFromUser: false) @@ -183,7 +138,6 @@ struct ChatView: View { private func generateAnswer(prompt: String, targetLanguage: String) async throws -> String { let callable = functions.httpsCallable("chat") - // Build chat history from previous messages (exclude the one we just added) let chatHistory: [[String: String]] = messages.dropLast().map { msg in ["role": msg.isFromUser ? "user" : "assistant", "content": msg.text] } @@ -194,7 +148,6 @@ struct ChatView: View { "language": targetLanguage, ]) - // Parse the response if let data = result.data as? [String: Any], let response = data["response"] as? String { return response @@ -207,10 +160,7 @@ struct ChatView: View { } } - -// MARK: - Message bubble private struct MessageBubbleView: View { - let message: ChatMessage var body: some View { @@ -228,9 +178,3 @@ private struct MessageBubbleView: View { } } } - -#Preview { - @Previewable @State var selectedLanguage = "English" - return ChatView(selectedLanguage: $selectedLanguage) - .environmentObject(FirebaseManager()) -} diff --git a/LexAI_iOS/LexAI_iOS/Views/Components/ThinkingBubbleView.swift b/LexAI_iOS/LexAI_iOS/Views/Components/ThinkingBubbleView.swift new file mode 100644 index 0000000..112666e --- /dev/null +++ b/LexAI_iOS/LexAI_iOS/Views/Components/ThinkingBubbleView.swift @@ -0,0 +1,38 @@ +// +// ThinkingBubbleView.swift +// LexAI_iOS +// +// Created by Hassan Alkhafaji on 4/13/26. +// +import SwiftUI + +struct ThinkingBubbleView: View { + @State private var animating = false + + var body: some View { + HStack(alignment: .top) { + HStack(spacing: 6) { + ForEach(0..<3) { index in + Circle() + .fill(Color.gray) + .frame(width: 8, height: 8) + .scaleEffect(animating ? 1.0 : 0.5) + .opacity(animating ? 1.0 : 0.4) + .animation( + .easeInOut(duration: 0.6) + .repeatForever(autoreverses: true) + .delay(Double(index) * 0.2), + value: animating + ) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 14) + .background(Color(.systemGray6)) + .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) + + Spacer(minLength: 48) + } + .onAppear { animating = true } + } +} diff --git a/LexAI_iOS/LexAI_iOS/Views/ContentView.swift b/LexAI_iOS/LexAI_iOS/Views/ContentView.swift index 64598c7..bc5abb4 100644 --- a/LexAI_iOS/LexAI_iOS/Views/ContentView.swift +++ b/LexAI_iOS/LexAI_iOS/Views/ContentView.swift @@ -9,16 +9,15 @@ import SwiftUI struct ContentView: View { - @StateObject var firebaseManager = FirebaseManager() + @StateObject var authManager = FirebaseManager() var body: some View { Group { - if firebaseManager.isAuthenticated { + if authManager.isAuthenticated { HomeView() - .environmentObject(firebaseManager) } else { AuthView() - .environmentObject(firebaseManager) + .environmentObject(authManager) } } } diff --git a/LexAI_iOS/LexAI_iOS/Views/HomeView.swift b/LexAI_iOS/LexAI_iOS/Views/HomeView.swift index 0a5372c..ca50e50 100644 --- a/LexAI_iOS/LexAI_iOS/Views/HomeView.swift +++ b/LexAI_iOS/LexAI_iOS/Views/HomeView.swift @@ -1,41 +1,43 @@ import SwiftUI struct HomeView: View { - + @State private var isSidebarOpen = false + @State private var showToolbar = true @AppStorage("selectedLanguage") private var selectedLanguage: String = "English" @State private var showLanguageDropdown = false + @StateObject private var sidebarVM = SidebarViewModel() + @State private var messages: [ChatMessage] = [] + @State private var chatViewResetID = UUID() + private let languages = ["English", "Spanish", "French", "Arabic", "German"] var body: some View { NavigationStack { GeometryReader { geo in ZStack(alignment: .leading) { - // MARK: Main content VStack { - ChatView(selectedLanguage: $selectedLanguage) + ChatView(messages: $messages, selectedLanguage: $selectedLanguage) + .id(chatViewResetID) } - .frame(maxWidth: .infinity, maxHeight: .infinity) - // MARK: Dim overlay — tap to close if isSidebarOpen { - Color.black - .opacity(0.4) + Color.black.opacity(0.3) .ignoresSafeArea() - .onTapGesture { closeSidebar() } - .transition(.opacity) - .zIndex(1) + .onTapGesture { isSidebarOpen = false } } - // MARK: Sidebar — 80% width, slides in from left - SideBarView(isOpen: $isSidebarOpen, vm: sidebarVM) - .frame(width: geo.size.width * 0.80) - .offset(x: isSidebarOpen ? 0 : -(geo.size.width * 0.80)) - .shadow(color: .black.opacity(isSidebarOpen ? 0.2 : 0), radius: 16, x: 4, y: 0) - .zIndex(2) + SideBarView(isOpen: $isSidebarOpen, vm: sidebarVM, onNewChat: { + // Reset both chat data and local ChatView state for a true fresh thread. + messages = [] + chatViewResetID = UUID() + }) + .frame(width: geo.size.width * 0.80) + .offset(x: isSidebarOpen ? 0 : -(geo.size.width * 0.80)) + .shadow(color: .black.opacity(isSidebarOpen ? 0.2 : 0), radius: 16, x: 4, y: 0) + .animation(.easeInOut(duration: 0.28), value: isSidebarOpen) - // MARK: Language dropdown if showLanguageDropdown { VStack(alignment: .leading, spacing: 0) { ForEach(languages, id: \.self) { language in @@ -63,23 +65,21 @@ struct HomeView: View { .padding(.top, 8) .padding(.trailing, 16) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing) - .zIndex(3) + .animation(.easeInOut(duration: 0.2), value: showLanguageDropdown) } } - .animation(.easeInOut(duration: 0.28), value: isSidebarOpen) } .toolbar { ToolbarItem(placement: .navigationBarLeading) { if !isSidebarOpen { - Button { openSidebar() } label: { + Button { isSidebarOpen = true } label: { Image(systemName: "line.horizontal.3") } } } ToolbarItem(placement: .navigationBarTrailing) { Button { showLanguageDropdown.toggle() } label: { - Image(systemName: "globe") - .foregroundColor(Color("grape")) + Image(systemName: "globe").foregroundColor(Color("grape")) } } } @@ -88,14 +88,6 @@ struct HomeView: View { } } } - - private func openSidebar() { - withAnimation(.easeInOut(duration: 0.28)) { isSidebarOpen = true } - } - - private func closeSidebar() { - withAnimation(.easeInOut(duration: 0.28)) { isSidebarOpen = false } - } } #Preview { diff --git a/LexAI_iOS/LexAI_iOS/Views/SideBarView.swift b/LexAI_iOS/LexAI_iOS/Views/SideBarView.swift index 7df0667..45c7ce1 100644 --- a/LexAI_iOS/LexAI_iOS/Views/SideBarView.swift +++ b/LexAI_iOS/LexAI_iOS/Views/SideBarView.swift @@ -1,4 +1,4 @@ -// // SideBarView.swift +// SideBarView.swift // LexAI_iOS // // Sprint 3 update (Sidebar UI refinement) — Sara Al-hachami 03/31/26 @@ -119,7 +119,9 @@ final class SidebarViewModel: ObservableObject { let startOf7Days = cal.date(byAdding: .day, value: -7, to: startOfToday)! let startOf30Days = cal.date(byAdding: .day, value: -30, to: startOfToday)! - let active = filteredActiveSessions.sorted { $0.createdAt > $1.createdAt } + let all = filteredActiveSessions.sorted { $0.createdAt > $1.createdAt } + let pinned = all.filter { $0.isPinned } + let rest = all.filter { !$0.isPinned } var today: [ChatSession] = [] var yesterday: [ChatSession] = [] @@ -127,7 +129,7 @@ final class SidebarViewModel: ObservableObject { var month: [ChatSession] = [] var older: [ChatSession] = [] - for s in active { + for s in rest { if s.createdAt >= startOfToday { today.append(s) } else if s.createdAt >= startOfYesterday { yesterday.append(s) } else if s.createdAt >= startOf7Days { week.append(s) } @@ -136,6 +138,7 @@ final class SidebarViewModel: ObservableObject { } var groups: [(label: String, items: [ChatSession])] = [] + if !pinned.isEmpty { groups.append(("Pinned", pinned)) } if !today.isEmpty { groups.append(("Today", today)) } if !yesterday.isEmpty { groups.append(("Yesterday", yesterday)) } if !week.isEmpty { groups.append(("Previous 7 Days", week)) } @@ -162,7 +165,7 @@ final class SidebarViewModel: ObservableObject { @discardableResult func newSession(tag: SessionTag? = nil) -> ChatSession { - let s = ChatSession(title: "New Conversation", preview: "", tag: tag) + let s = ChatSession(id: UUID(), title: "New Conversation", preview: "", tag: tag) sessions.insert(s, at: 0) activeSessionID = s.id return s @@ -211,9 +214,11 @@ final class SidebarViewModel: ObservableObject { func updateSession(id: UUID, messages: [ChatMessage]) { guard let i = sessions.firstIndex(where: { $0.id == id }) else { return } sessions[i].messages = messages + // Title: use the first user message as-is (truncated to 50 chars). + // Only overwrite while the session still carries the default placeholder. if sessions[i].title == "New Conversation", let firstUserMsg = messages.first(where: { $0.isFromUser }) { - sessions[i].title = generateTitle(from: firstUserMsg.text) + sessions[i].title = String(firstUserMsg.text.prefix(50)) } if let lastAI = messages.last(where: { !$0.isFromUser }) { sessions[i].preview = String(lastAI.text.prefix(60)) @@ -236,22 +241,15 @@ final class SidebarViewModel: ObservableObject { } private func generateTitle(from query: String) -> String { - let lower = query.lowercased() - let prefixes: [String] = [ - "is there any way to ", "how do i ", "how can i ", "can i ", "can my ", - "what counts as ", "what is ", "what are ", "am i going to ", "am i ", - "do i need to ", "should i ", "will i ", "i need help with ", - "i want to know about ", "tell me about ", "help me with ", - "what happens if ", "is it legal to ", "is it illegal to " - ] - var trimmed = lower - for prefix in prefixes { - if trimmed.hasPrefix(prefix) { trimmed = String(trimmed.dropFirst(prefix.count)); break } + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.count > 50 else { return trimmed } + // Truncate at the last word boundary before 50 chars + let index = trimmed.index(trimmed.startIndex, offsetBy: 50) + let cut = trimmed[.. Void)? = nil @State private var renamingSession: ChatSession? = nil @State private var renameText: String = "" @@ -290,22 +289,27 @@ struct SideBarView: View { // MARK: New Chat Button { vm.newSession() + onNewChat?() isOpen = false } label: { - HStack(spacing: 10) { + HStack(spacing: 8) { Image(systemName: "square.and.pencil") - .font(.system(size: 15)) - Text("New chat") - .font(.system(size: 15)) + .font(.system(size: 14, weight: .medium)) + Text("New Chat") + .font(.system(size: 15, weight: .medium)) Spacer() } - .foregroundStyle(.primary) - .padding(.horizontal, 12) - .padding(.vertical, 10) - .background(Color(.systemGray5), in: RoundedRectangle(cornerRadius: 10)) + .foregroundStyle(.white) + .padding(.horizontal, 14) + .padding(.vertical, 11) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color("grape")) + ) + .shadow(color: Color("grape").opacity(0.28), radius: 8, x: 0, y: 4) } .buttonStyle(.plain) - .padding(.horizontal, 12) + .padding(.horizontal, 16) .padding(.bottom, 8) // MARK: Search @@ -422,6 +426,12 @@ struct SideBarView: View { private func sessionRow(_ session: ChatSession) -> some View { HStack { + if session.isPinned { + Image(systemName: "pin.fill") + .font(.system(size: 10)) + .foregroundStyle(.secondary) + .rotationEffect(.degrees(45)) + } Text(session.title) .font(.system(size: 14)) .foregroundStyle(vm.activeSessionID == session.id ? .primary : Color(.label).opacity(0.8)) @@ -439,6 +449,12 @@ struct SideBarView: View { isOpen = false } .contextMenu { + Button { + withAnimation { vm.togglePin(session) } + } label: { + Label(session.isPinned ? "Unpin" : "Pin", + systemImage: session.isPinned ? "pin.slash" : "pin") + } Button { renameText = session.title renamingSession = session @@ -458,6 +474,15 @@ struct SideBarView: View { Label("Delete", systemImage: "trash") } } + .swipeActions(edge: .leading, allowsFullSwipe: false) { + Button { + withAnimation { vm.togglePin(session) } + } label: { + Label(session.isPinned ? "Unpin" : "Pin", + systemImage: session.isPinned ? "pin.slash" : "pin") + } + .tint(.orange) + } } } diff --git a/lexai-functions/.gitignore b/lexai-functions/.gitignore index 4d8ee00..24573d8 100644 --- a/lexai-functions/.gitignore +++ b/lexai-functions/.gitignore @@ -3,5 +3,6 @@ __pycache__/ # Python virtual environment venv/ +.venv_inspect/ *.local .env diff --git a/lexai-functions/inspect_pinecone_metadata.py b/lexai-functions/inspect_pinecone_metadata.py new file mode 100644 index 0000000..de87855 --- /dev/null +++ b/lexai-functions/inspect_pinecone_metadata.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +"""One-off: print Pinecone match metadata keys + sample dict for michigan-legislation. + +Run from repo root or lexai-functions: + cd lexai-functions && python inspect_pinecone_metadata.py + +Requires PINECONE_API_KEY in environment or .env in this directory. +""" +from __future__ import annotations + +import json +import os +import sys + +from dotenv import load_dotenv +from pinecone import Pinecone + +load_dotenv() + +INDEX_NAME = "michigan-legislation" +EMBED_MODEL = "multilingual-e5-large" +QUERY = "michigan landlord tenant law" + + +def main() -> int: + api_key = (os.environ.get("PINECONE_API_KEY") or "").strip() + if not api_key: + print("PINECONE_API_KEY is not set", file=sys.stderr) + return 1 + + pc = Pinecone(api_key=api_key) + index = pc.Index(INDEX_NAME) + + try: + stats = index.describe_index_stats() + print("--- describe_index_stats ---") + print(json.dumps(stats.to_dict() if hasattr(stats, "to_dict") else str(stats), indent=2, default=str)) + except Exception as e: + print(f"describe_index_stats failed: {e}", file=sys.stderr) + + emb = pc.inference.embed( + model=EMBED_MODEL, + inputs=[QUERY], + parameters={"input_type": "query", "truncate": "END"}, + ) + vector = emb[0]["values"] + + results = index.query( + vector=vector, + top_k=5, + include_metadata=True, + ) + + matches = results.get("matches") or [] + print(f"\n--- query top_k=5 (no filter), matches={len(matches)} ---") + for i, m in enumerate(matches): + meta = m.get("metadata") + keys = sorted(meta.keys()) if isinstance(meta, dict) else [] + print(f"\nmatch[{i}] id={m.get('id')} score={m.get('score')} metadata_keys={keys}") + if isinstance(meta, dict): + print(json.dumps(meta, indent=2, default=str)[:4000]) + if len(json.dumps(meta, default=str)) > 4000: + print("... (truncated)") + + # Same query with chunk filter (matches embed_and_store chunk vectors) + results2 = index.query( + vector=vector, + top_k=5, + include_metadata=True, + filter={"chunk_idx": {"$gte": 0}}, + ) + matches2 = results2.get("matches") or [] + print(f"\n--- query with filter chunk_idx>=0, matches={len(matches2)} ---") + if matches2: + m0 = matches2[0] + meta0 = m0.get("metadata") or {} + print("first match metadata keys:", sorted(meta0.keys()) if isinstance(meta0, dict) else meta0) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/lexai-functions/main.py b/lexai-functions/main.py index bc2ad84..d623e75 100644 --- a/lexai-functions/main.py +++ b/lexai-functions/main.py @@ -9,6 +9,7 @@ """ import os +import threading import time from typing import Any @@ -52,18 +53,22 @@ _PINECONE_CLIENT: Pinecone | None = None _PINECONE_INDEX: Any = None +_PINECONE_LOCK = threading.Lock() def _get_pinecone() -> tuple[Pinecone, Any]: - """Lazy Pinecone client so deploy-time discovery does not require API keys at import.""" + """Lazy Pinecone client; thread-safe init for concurrent invocations.""" global _PINECONE_CLIENT, _PINECONE_INDEX if _PINECONE_CLIENT is not None and _PINECONE_INDEX is not None: return _PINECONE_CLIENT, _PINECONE_INDEX - api_key = (os.environ.get("PINECONE_API_KEY") or "").strip() - if not api_key: - raise RuntimeError("PINECONE_API_KEY is not set") - _PINECONE_CLIENT = Pinecone(api_key=api_key) - _PINECONE_INDEX = _PINECONE_CLIENT.Index(INDEX_NAME) + with _PINECONE_LOCK: + if _PINECONE_CLIENT is not None and _PINECONE_INDEX is not None: + return _PINECONE_CLIENT, _PINECONE_INDEX + api_key = (os.environ.get("PINECONE_API_KEY") or "").strip() + if not api_key: + raise RuntimeError("PINECONE_API_KEY is not set") + _PINECONE_CLIENT = Pinecone(api_key=api_key) + _PINECONE_INDEX = _PINECONE_CLIENT.Index(INDEX_NAME) return _PINECONE_CLIENT, _PINECONE_INDEX @@ -77,14 +82,46 @@ def embed_query(query_text: str) -> list: return embeddings_response[0]["values"] +def _legislation_text_from_metadata(metadata: Any) -> str: + """Return first non-empty string from known Pinecone metadata text fields.""" + if not isinstance(metadata, dict): + return "" + for key in ( + "chunk_text", + "text", + "content", + "chunk", + "body", + "passage", + ): + val = metadata.get(key) + if isinstance(val, str) and val.strip(): + return val.strip() + return "" + + def query_pinecone(query_embedding: list, top_k: int = 5) -> list: _, index = _get_pinecone() - results = index.query( - vector=query_embedding, - top_k=top_k, - include_metadata=True, + # Prefer chunk vectors (embed_and_store sets chunk_idx). If index has no chunk_idx, fall back unfiltered. + for use_chunk_filter in (True, False): + kwargs: dict[str, Any] = { + "vector": query_embedding, + "top_k": top_k, + "include_metadata": True, + } + if use_chunk_filter: + kwargs["filter"] = {"chunk_idx": {"$gte": 0}} + results = index.query(**kwargs) + chunks: list[str] = [] + for match in results.get("matches", []): + text = _legislation_text_from_metadata(match.get("metadata")) + if text: + chunks.append(text) + if chunks: + return chunks + raise RuntimeError( + "No usable legislation text in Pinecone results (metadata missing text fields or index empty)." ) - return [match["metadata"]["chunk_text"] for match in results["matches"]] def call_runpod(messages: list) -> str: @@ -117,8 +154,8 @@ def call_runpod(messages: list) -> str: run_response.raise_for_status() job_id = run_response.json()["id"] - # Poll for completion, max 5 minutes - for _ in range(150): + # Poll for completion, max ~8 minutes (matches timeout_sec=540) + for _ in range(240): status_response = requests.get( f"https://api.runpod.ai/v2/{endpoint_id}/status/{job_id}", headers={"Authorization": f"Bearer {runpod_key}"}, @@ -129,7 +166,6 @@ def call_runpod(messages: list) -> str: if result["status"] == "COMPLETED": output = result.get("output", {}) - # Handle both dict and list output formats if isinstance(output, list): if len(output) > 0 and isinstance(output[0], dict): choices = output[0].get("choices", []) @@ -152,7 +188,7 @@ def call_runpod(messages: list) -> str: @https_fn.on_call( enforce_app_check=False, - timeout_sec=300, + timeout_sec=540, secrets=[OPENAI_API_KEY, PINECONE_API_KEY, RUNPOD_API_KEY], ) def chat(req: https_fn.CallableRequest) -> dict: