diff --git a/LexAI_iOS/LexAI_iOS/LexAI_iOSApp.swift b/LexAI_iOS/LexAI_iOS/LexAI_iOSApp.swift index 2d4e971..fd5a7fe 100644 --- a/LexAI_iOS/LexAI_iOS/LexAI_iOSApp.swift +++ b/LexAI_iOS/LexAI_iOS/LexAI_iOSApp.swift @@ -15,6 +15,9 @@ import FirebaseAppCheck class AppDelegate: NSObject, UIApplicationDelegate { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { + if ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" { + return true + } #if DEBUG AppCheck.setAppCheckProviderFactory(AppCheckDebugProviderFactory()) #endif diff --git a/LexAI_iOS/LexAI_iOS/Managers/FirebaseManager.swift b/LexAI_iOS/LexAI_iOS/Managers/FirebaseManager.swift index b6a4cd4..ded28f2 100644 --- a/LexAI_iOS/LexAI_iOS/Managers/FirebaseManager.swift +++ b/LexAI_iOS/LexAI_iOS/Managers/FirebaseManager.swift @@ -8,6 +8,7 @@ import Foundation import Combine +import FirebaseCore import FirebaseAuth import FirebaseFirestore @@ -19,13 +20,19 @@ class FirebaseManager: ObservableObject { @Published var isLoading = false private var authStateListener: AuthStateDidChangeListenerHandle? - private let db = Firestore.firestore() + private lazy var db = Firestore.firestore() + private let isPreview: Bool + + init(isPreview: Bool = false) { + let runningInPreviews = ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" + self.isPreview = isPreview || runningInPreviews + guard !self.isPreview else { return } + guard FirebaseApp.app() != nil else { return } - init() { authStateListener = Auth.auth().addStateDidChangeListener { [weak self] _, user in Task { @MainActor in self?.user = user - self?.isAuthenticated = user != nil + self?.isAuthenticated = (user?.isAnonymous == false) } } if Auth.auth().currentUser == nil { @@ -163,7 +170,7 @@ class FirebaseManager: ObservableObject { // MARK: - Legacy: single-prompt chat history (kept for backwards compatibility) - func saveChat(prompt: ChatPrompt, completion: @escaping (Bool) -> Void) { + func saveChat(prompt: ChatPrompt, completion: @escaping (String?) -> Void) { let data: [String: Any] = [ "prompt": prompt.prompt, "documents": prompt.documents, @@ -172,8 +179,13 @@ class FirebaseManager: ObservableObject { "user": prompt.user, "timestamp": FieldValue.serverTimestamp() ] - db.collection("chatHistory").addDocument(data: data) { error in - completion(error == nil) + var ref: DocumentReference? + ref = db.collection("chatHistory").addDocument(data: data) { error in + if error != nil { + completion(nil) + return + } + completion(ref?.documentID) } } @@ -184,6 +196,7 @@ class FirebaseManager: ObservableObject { let chats: [ChatPrompt] = snapshot?.documents.compactMap { doc in let d = doc.data() return ChatPrompt( + id: doc.documentID, prompt: d["prompt"] as? String ?? "", documents: d["documents"] as? [String] ?? [], location: d["location"] as? String ?? "", @@ -269,9 +282,31 @@ class FirebaseManager: ObservableObject { // MARK: - Models struct ChatPrompt { + var id: String? let prompt: String let documents: [String] let location: String let language: String let user: String + + var previewTitle: String { + let firstLine = prompt + .split(separator: "\n", maxSplits: 1, omittingEmptySubsequences: false) + .first + .map(String.init) ?? prompt + + let cleaned: String + if firstLine.hasPrefix("User: ") { + cleaned = String(firstLine.dropFirst("User: ".count)) + } else { + cleaned = firstLine + } + + let words = cleaned.split(whereSeparator: { $0.isWhitespace }) + guard !words.isEmpty else { return "Conversation" } + + let maxWords = 8 + let title = words.prefix(maxWords).joined(separator: " ") + return words.count > maxWords ? title + "..." : title + } } diff --git a/LexAI_iOS/LexAI_iOS/Models/ChatPlaceholderText.swift b/LexAI_iOS/LexAI_iOS/Models/ChatPlaceholderText.swift index f8c4192..293dd04 100644 --- a/LexAI_iOS/LexAI_iOS/Models/ChatPlaceholderText.swift +++ b/LexAI_iOS/LexAI_iOS/Models/ChatPlaceholderText.swift @@ -10,15 +10,15 @@ enum ChatPlaceholderText { static func placeholder(forSelectedLanguage language: String) -> String { switch language { case "Spanish": - return "Mensaje..." + return "Haz una pregunta sobre una ley..." case "French": - return "Message..." + return "Posez une question sur une loi..." case "Arabic": - return "رسالة..." + return "اطرح سؤالا حول قانون..." case "German": - return "Nachricht..." + return "Stellen Sie eine Frage zu einem Gesetz..." default: - return "Message..." + return "Ask a question about a law..." } } } diff --git a/LexAI_iOS/LexAI_iOS/Views/AuthView.swift b/LexAI_iOS/LexAI_iOS/Views/AuthView.swift index 55c82d0..00b3021 100644 --- a/LexAI_iOS/LexAI_iOS/Views/AuthView.swift +++ b/LexAI_iOS/LexAI_iOS/Views/AuthView.swift @@ -226,7 +226,7 @@ struct AuthView: View { #Preview { AuthView() - .environmentObject(FirebaseManager()) + .environmentObject(FirebaseManager(isPreview: true)) } diff --git a/LexAI_iOS/LexAI_iOS/Views/ChatView.swift b/LexAI_iOS/LexAI_iOS/Views/ChatView.swift index fdb5b22..9783e84 100644 --- a/LexAI_iOS/LexAI_iOS/Views/ChatView.swift +++ b/LexAI_iOS/LexAI_iOS/Views/ChatView.swift @@ -3,18 +3,28 @@ import SwiftUI import FirebaseFunctions +import UniformTypeIdentifiers +import PDFKit +import FirebaseAuth private let bottomAnchorId = "bottom" struct ChatView: View { @Binding var messages: [ChatMessage] @Binding var selectedLanguage: String + @Binding var activeChatDocumentID: String? + @Binding var hasSavedBeforeLeaving: Bool var vm: SidebarViewModel? = nil var sessionID: UUID? = nil + var onChatPersisted: ((String) -> Void)? = nil @State private var inputText: String = "" + @State private var showScanDocuments = false + @State private var showFilePicker = false @State private var isAwaitingReply = false + @EnvironmentObject var firebaseManager: FirebaseManager + @Environment(\.scenePhase) private var scenePhase private let functions = Functions.functions() var body: some View { @@ -42,6 +52,36 @@ 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 + } + .fileImporter( + isPresented: $showFilePicker, + allowedContentTypes: [.plainText, .pdf], + allowsMultipleSelection: false + ) { result in + handleFileImport(result) + } + .onDisappear { + saveChatIfNeeded() + } + .onChange(of: scenePhase) { _, newPhase in + if newPhase == .background || newPhase == .inactive { + saveChatIfNeeded() + } + } } private var messageList: some View { @@ -82,37 +122,141 @@ struct ChatView: View { } private var inputBar: some View { - 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) + VStack(spacing: 10) { + HStack(spacing: 10) { + actionButton(icon: "document.viewfinder", label: "Scan Document") { + showScanDocuments = true + } + actionButton(icon: "arrow.up.doc", label: "Upload Document") { + showFilePicker = true + } + Spacer() + } + .padding(.horizontal, 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) + } + .disabled(inputText.isEmpty || isAwaitingReply) + .padding(.bottom, 4) } - .disabled(inputText.isEmpty || isAwaitingReply) - .padding(.bottom, 4) } .padding(.horizontal, 4) .padding(.top) } + private func actionButton(icon: String, label: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + HStack(spacing: 6) { + Image(systemName: icon) + .font(.system(size: 13, weight: .semibold)) + Text(label) + .font(.system(size: 12, weight: .semibold)) + } + .foregroundStyle(.white) + .padding(.horizontal, 10) + .padding(.vertical, 8) + .background(Color.white.opacity(0.22), in: Capsule()) + } + .buttonStyle(.plain) + } + + private func handleFileImport(_ result: Result<[URL], Error>) { + switch result { + case .success(let urls): + guard let url = urls.first else { return } + guard url.startAccessingSecurityScopedResource() else { return } + defer { url.stopAccessingSecurityScopedResource() } + + let extracted: String? + if url.pathExtension.lowercased() == "pdf" { + extracted = extractTextFromPDF(url: url) + } else { + extracted = try? String(contentsOf: url, encoding: .utf8) + } + + if let text = extracted, !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + messages.append(ChatMessage(text: text.trimmingCharacters(in: .whitespacesAndNewlines), isFromUser: true)) + } + + case .failure(let error): + print("File import error: \(error)") + } + } + + private func extractTextFromPDF(url: URL) -> String? { + guard let pdf = PDFDocument(url: url) else { return nil } + var text = "" + for i in 0.. [ChatMessage] { + let lines = transcript.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) + var parsed: [ChatMessage] = [] + var currentSpeakerIsUser: Bool? + var currentText: String = "" + + func flushCurrent() { + guard let isUser = currentSpeakerIsUser else { return } + let text = currentText.trimmingCharacters(in: .whitespacesAndNewlines) + if !text.isEmpty { + parsed.append(ChatMessage(text: text, isFromUser: isUser)) + } + currentSpeakerIsUser = nil + currentText = "" + } + + for line in lines { + if line.hasPrefix("User: ") { + flushCurrent() + currentSpeakerIsUser = true + currentText = String(line.dropFirst("User: ".count)) + } else if line.hasPrefix("LexAI: ") { + flushCurrent() + currentSpeakerIsUser = false + currentText = String(line.dropFirst("LexAI: ".count)) + } else if currentSpeakerIsUser != nil { + currentText += currentText.isEmpty ? line : "\n" + line + } + } + + flushCurrent() + return parsed + } + + private func saveCurrentConversation(completion: @escaping () -> Void) { + guard !messages.isEmpty else { + completion() + return + } + guard let userId = firebaseManager.user?.uid, !userId.isEmpty else { + completion() + return + } + + let transcript = messages + .map { ($0.isFromUser ? "User" : "LexAI") + ": " + $0.text } + .joined(separator: "\n") + + if let existingID = activeChatDocumentID, !existingID.isEmpty { + firebaseManager.updateChat(chatId: existingID, newPrompt: transcript) { _ in + hasSavedBeforeLeaving = true + completion() + } + return + } + + let prompt = ChatPrompt( + id: nil, + prompt: transcript, + documents: [], + location: "", + language: selectedLanguage, + user: userId + ) + firebaseManager.saveChat(prompt: prompt) { newID in + if let newID { + activeChatDocumentID = newID + hasSavedBeforeLeaving = true + } + completion() + } } } #Preview { HomeView() + .environmentObject(FirebaseManager(isPreview: true)) } diff --git a/LexAI_iOS/LexAI_iOS/Views/SideBarView.swift b/LexAI_iOS/LexAI_iOS/Views/SideBarView.swift index 45c7ce1..fc323a5 100644 --- a/LexAI_iOS/LexAI_iOS/Views/SideBarView.swift +++ b/LexAI_iOS/LexAI_iOS/Views/SideBarView.swift @@ -214,11 +214,10 @@ 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. + // Title: use the first user message for context. if sessions[i].title == "New Conversation", let firstUserMsg = messages.first(where: { $0.isFromUser }) { - sessions[i].title = String(firstUserMsg.text.prefix(50)) + sessions[i].title = generateTitle(from: firstUserMsg.text) } if let lastAI = messages.last(where: { !$0.isFromUser }) { sessions[i].preview = String(lastAI.text.prefix(60)) @@ -242,14 +241,13 @@ final class SidebarViewModel: ObservableObject { private func generateTitle(from query: String) -> String { 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[.. maxWords ? title + "..." : title } } @@ -260,18 +258,28 @@ final class SidebarViewModel: ObservableObject { struct SideBarView: View { @Binding var isOpen: Bool @ObservedObject var vm: SidebarViewModel + var onSelectSession: ((ChatSession) -> Void)? = nil var onNewChat: (() -> Void)? = nil + var onDeleteSession: ((ChatSession) -> Void)? = nil + var onSidebarAppear: (() -> Void)? = nil + @EnvironmentObject var firebaseManager: FirebaseManager @State private var renamingSession: ChatSession? = nil @State private var renameText: String = "" + @State private var showSignOutAlert = false var body: some View { VStack(alignment: .leading, spacing: 0) { // MARK: Header HStack(spacing: 10) { - Image(systemName: "scale.3d") - .font(.system(size: 16, weight: .semibold)) + Button { + showSignOutAlert = true + } label: { + Image(systemName: "person.circle") + .font(.system(size: 20, weight: .semibold)) + .foregroundStyle(.secondary) + } Text("LexAI") .font(.system(size: 17, weight: .semibold)) Spacer() @@ -288,7 +296,6 @@ struct SideBarView: View { // MARK: New Chat Button { - vm.newSession() onNewChat?() isOpen = false } label: { @@ -420,6 +427,17 @@ struct SideBarView: View { } Button("Cancel", role: .cancel) { renamingSession = nil } } + .alert("Sign Out", isPresented: $showSignOutAlert) { + Button("Cancel", role: .cancel) {} + Button("Sign Out", role: .destructive) { + firebaseManager.signOut() + } + } message: { + Text("Are you sure you want to sign out?") + } + .onAppear { + onSidebarAppear?() + } } // MARK: Session Row @@ -446,6 +464,7 @@ struct SideBarView: View { .contentShape(Rectangle()) .onTapGesture { vm.activeSessionID = session.id + onSelectSession?(session) isOpen = false } .contextMenu { @@ -462,14 +481,20 @@ struct SideBarView: View { Label("Rename", systemImage: "pencil") } Button(role: .destructive) { - withAnimation { vm.delete(session) } + withAnimation { + vm.delete(session) + onDeleteSession?(session) + } } label: { Label("Delete", systemImage: "trash") } } .swipeActions(edge: .trailing, allowsFullSwipe: true) { Button(role: .destructive) { - withAnimation { vm.delete(session) } + withAnimation { + vm.delete(session) + onDeleteSession?(session) + } } label: { Label("Delete", systemImage: "trash") } @@ -499,6 +524,7 @@ struct SideBarView: View { .onTapGesture { isOpen = false } } SideBarView(isOpen: $isOpen, vm: vm) + .environmentObject(FirebaseManager(isPreview: true)) .frame(width: geo.size.width * 0.80) .offset(x: isOpen ? 0 : -(geo.size.width * 0.80)) .shadow(color: .black.opacity(0.2), radius: 16, x: 4, y: 0) diff --git a/LexAI_iOS/LexAI_iOSTests/ChatPlaceholderTextTests.swift b/LexAI_iOS/LexAI_iOSTests/ChatPlaceholderTextTests.swift index 0950e93..1289d18 100644 --- a/LexAI_iOS/LexAI_iOSTests/ChatPlaceholderTextTests.swift +++ b/LexAI_iOS/LexAI_iOSTests/ChatPlaceholderTextTests.swift @@ -3,11 +3,11 @@ import XCTest final class ChatPlaceholderTextTests: XCTestCase { func testPlaceholderForSelectedLanguage_returnsExpectedStrings() { - XCTAssertEqual(ChatPlaceholderText.placeholder(forSelectedLanguage: "English"), "Message...") - XCTAssertEqual(ChatPlaceholderText.placeholder(forSelectedLanguage: "Spanish"), "Mensaje...") - XCTAssertEqual(ChatPlaceholderText.placeholder(forSelectedLanguage: "French"), "Message...") - XCTAssertEqual(ChatPlaceholderText.placeholder(forSelectedLanguage: "Arabic"), "رسالة...") - XCTAssertEqual(ChatPlaceholderText.placeholder(forSelectedLanguage: "German"), "Nachricht...") - XCTAssertEqual(ChatPlaceholderText.placeholder(forSelectedLanguage: "Unknown"), "Message...") + XCTAssertEqual(ChatPlaceholderText.placeholder(forSelectedLanguage: "English"), "Ask a question about a law...") + XCTAssertEqual(ChatPlaceholderText.placeholder(forSelectedLanguage: "Spanish"), "Haz una pregunta sobre una ley...") + XCTAssertEqual(ChatPlaceholderText.placeholder(forSelectedLanguage: "French"), "Posez une question sur une loi...") + XCTAssertEqual(ChatPlaceholderText.placeholder(forSelectedLanguage: "Arabic"), "اطرح سؤالا حول قانون...") + XCTAssertEqual(ChatPlaceholderText.placeholder(forSelectedLanguage: "German"), "Stellen Sie eine Frage zu einem Gesetz...") + XCTAssertEqual(ChatPlaceholderText.placeholder(forSelectedLanguage: "Unknown"), "Ask a question about a law...") } } diff --git a/LexAI_iOS/LexAI_iOSTests/FirebaseManagerTests.swift b/LexAI_iOS/LexAI_iOSTests/FirebaseManagerTests.swift index ba55e7d..d991d1b 100644 --- a/LexAI_iOS/LexAI_iOSTests/FirebaseManagerTests.swift +++ b/LexAI_iOS/LexAI_iOSTests/FirebaseManagerTests.swift @@ -7,6 +7,7 @@ final class FirebaseManagerTests: XCTestCase { let manager = FirebaseManager() let chat = ChatPrompt( + id: nil, prompt: "Test", documents: ["doc1"], location: "United States", @@ -16,8 +17,8 @@ final class FirebaseManagerTests: XCTestCase { let expectation = self.expectation(description: "Save chat completes.") - manager.saveChat(prompt: chat) { success in - XCTAssertNotNil(success) // check + manager.saveChat(prompt: chat) { chatID in + XCTAssertNotNil(chatID) // check expectation.fulfill() } diff --git a/lexai-functions/main.py b/lexai-functions/main.py index d623e75..089dd69 100644 --- a/lexai-functions/main.py +++ b/lexai-functions/main.py @@ -100,6 +100,30 @@ def _legislation_text_from_metadata(metadata: Any) -> str: return "" +def _extract_matches(results: Any) -> list[Any]: + """Support both dict-like and object-like Pinecone query responses.""" + if isinstance(results, dict): + return results.get("matches", []) or [] + matches = getattr(results, "matches", None) + if matches is not None: + return list(matches) + if hasattr(results, "to_dict"): + return (results.to_dict() or {}).get("matches", []) or [] + return [] + + +def _extract_metadata(match: Any) -> Any: + """Support both dict-like and object-like Pinecone match records.""" + if isinstance(match, dict): + return match.get("metadata") + metadata = getattr(match, "metadata", None) + if metadata is not None: + return metadata + if hasattr(match, "to_dict"): + return (match.to_dict() or {}).get("metadata") + return None + + def query_pinecone(query_embedding: list, top_k: int = 5) -> list: _, index = _get_pinecone() # Prefer chunk vectors (embed_and_store sets chunk_idx). If index has no chunk_idx, fall back unfiltered. @@ -111,10 +135,16 @@ def query_pinecone(query_embedding: list, top_k: int = 5) -> list: } if use_chunk_filter: kwargs["filter"] = {"chunk_idx": {"$gte": 0}} - results = index.query(**kwargs) + try: + results = index.query(**kwargs) + except Exception: + if use_chunk_filter: + # Some indexes don't support this metadata filter shape; retry unfiltered. + continue + raise chunks: list[str] = [] - for match in results.get("matches", []): - text = _legislation_text_from_metadata(match.get("metadata")) + for match in _extract_matches(results): + text = _legislation_text_from_metadata(_extract_metadata(match)) if text: chunks.append(text) if chunks: