HuggingChat-Mac/Views/ConversationView.swift (309 lines of code) (raw):

// // ConversationView.swift // HuggingChat-Mac // // Created by Cyril Zakka on 12/13/24. // import SwiftUI enum FocusedField { case localInput case serverInput } struct SidebarContent: View { @Environment(MenuViewModel.self) private var menuModel @Environment(ConversationViewModel.self) private var conversationModel private let sectionOrder = ["Today", "This Week", "This Month", "Older"] var body: some View { List(selection: Binding( get: { menuModel.currentConversationId }, set: { menuModel.currentConversationId = $0 } )) { ForEach(sectionOrder, id: \.self) { section in Section(section) { ForEach(menuModel.conversations[section] ?? []) { conversation in Text(conversation.title) .tag(conversation.serverId) .lineLimit(1) } } } } .listStyle(.sidebar) .task { menuModel.getConversations() menuModel.refreshState() } } } struct DetailContent: View { @Environment(ModelManager.self) private var modelManager @Environment(MenuViewModel.self) private var menuModel @Environment(ConversationViewModel.self) private var conversationModel @Environment(AudioModelManager.self) private var audioModelManager @Environment(\.colorScheme) private var colorScheme @AppStorage("appearance") private var appearance: Appearance = .auto @AppStorage("inlineCodeHiglight") private var inlineCodeHiglight: AccentColorOption = .blue @AppStorage("lightCodeBlockTheme") private var lightCodeBlockTheme: String = "xcode" @AppStorage("darkCodeBlockTheme") private var darkCodeBlockTheme: String = "monokai-sublime" @AppStorage("selectedTheme") private var selectedTheme: String = "Default" @AppStorage("localModel") private var selectedLocalModel: String = "None" @AppStorage("isLocalGeneration") private var isLocalGeneration: Bool = false // Theme @AppStorage("isAppleClassicUnlocked") var isAppleClassicUnlocked: Bool = false @AppStorage("isChromeDinoUnlocked") var isChromeDinoUnlocked: Bool = false // Audio @AppStorage("selectedAudioModel") private var selectedAudioModel: String = "None" @AppStorage("selectedAudioInput") private var selectedAudioInput: String = "None" @AppStorage("smartDictation") private var smartDictation: Bool = false @AppStorage("useContext") private var useContext: Bool = false // Animation @State var cardIndex: Int = 0 // Text field @State private var prompt: String = "" @FocusState private var focusedField: FocusedField? @State private var isMainTextFieldVisible: Bool = true @State private var isSecondaryTextFieldVisible: Bool = false @State private var animatablePrompt: String = "" @State private var startLoadingAnimation: Bool = false // Chat history handling @AppStorage("chatClearInterval") private var chatClearInterval: String = "never" @State private var lastChatTime: Date = Date() // File handling @State private var allAttachments: [LLMAttachment] = [] // Error @State var errorAttempts: Int = 0 @State private var errorSize: CGSize = CGSize(width: 0, height: 100) // Response @State var meshSpeed: CGFloat = 0.4 @State private var responseSize: CGSize = CGSize(width: 0, height: 320) // STT @State private var isTranscribing: Bool = false var isLocal: Bool var body: some View { NavigationStack { if !isLocal { ScrollViewReader { proxy in ScrollView(.vertical) { LazyVStack(spacing: 15) { ForEach(conversationModel.messages) { message in MessageView(message: message) } } }.overlay { if conversationModel.messages.isEmpty { ZStack { Image("huggy") .resizable() .aspectRatio(contentMode: .fit) .symbolRenderingMode(.none) .foregroundStyle(.tertiary) .frame(width: 45, height: 45) } .frame(maxHeight: .infinity, alignment: .center) } } .contentMargins(.horizontal, 20, for: .scrollContent) .contentMargins(.top, 10, for: .scrollContent) .contentMargins(.bottom, -40, for: .scrollContent) .scrollIndicators(.hidden) .safeAreaInset(edge: .bottom, content: { if selectedLocalModel != "None" { CardStack([ AnyView(localInputView.focused($focusedField, equals: .localInput)), // It physically pains me to do type erasure like this AnyView(serverInputView.focused($focusedField, equals: .serverInput)), ], selectedIndex: $cardIndex) } else { serverInputView.focused($focusedField, equals: .serverInput) } }) .defaultScrollAnchor(.bottom) } } } // Prevent content from causing layout shifts .frame(maxWidth: .infinity, maxHeight: .infinity) .onAppear { if isLocalGeneration { cardIndex = 0 focusedField = .localInput } else { cardIndex = 1 focusedField = .serverInput } conversationModel.getActiveModel() checkAndClearChat() } .onChange(of: menuModel.currentConversationId) { if let conversation = menuModel.getConversation(withServerId: menuModel.currentConversationId) { conversationModel.loadConversation(conversation) } } .onChange(of: conversationModel.state) { if conversationModel.state == .error { prompt = animatablePrompt isChromeDinoUnlocked = true withAnimation(.default) { self.errorAttempts += 1 } } } .onChange(of: modelManager.loadState.isError) { if modelManager.loadState.isError { prompt = animatablePrompt isChromeDinoUnlocked = true withAnimation(.default) { self.errorAttempts += 1 } } } .preferredColorScheme(colorScheme(for: appearance)) .onChange(of: cardIndex) { if cardIndex == 0 { focusedField = .localInput } else if cardIndex == 1{ focusedField = .serverInput } } // MARK: STT .onChange(of: isTranscribing) { if isTranscribing { if selectedAudioModel != "None" && selectedAudioInput != "None" && audioModelManager.modelState == .loaded { audioModelManager.resetState() audioModelManager.startRecording(true, source: .chat) } } else { audioModelManager.stopRecording(false) } } .onChange(of: audioModelManager.isTranscriptionComplete) { old, new in if audioModelManager.isTranscriptionComplete && audioModelManager.transcriptionSource == .chat { prompt += audioModelManager.getFullTranscript() } } } @ViewBuilder private var localInputView: some View { InputView( isLocal: true, prompt: $prompt, isSecondaryTextFieldVisible: $isSecondaryTextFieldVisible, animatablePrompt: $animatablePrompt, isMainTextFieldVisible: $isMainTextFieldVisible, allAttachments: $allAttachments, startLoadingAnimation: $startLoadingAnimation, isTranscribing: $isTranscribing ) .padding(.vertical, 7) .background(.regularMaterial) .overlay(content: { if startLoadingAnimation { ZStack { AnimatedMeshGradient(colors: ThemingEngine.shared.currentTheme.animatedMeshMainColors, speed: $meshSpeed) .mask { RoundedRectangle(cornerRadius: 20, style: .continuous) .stroke(lineWidth: 6.0) } } .transition(.opacity) .allowsHitTesting(false) } else { RoundedRectangle(cornerRadius: 20, style: .continuous) .stroke(.secondary.opacity(0.5), lineWidth: 1.0) } }) .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) // .fixedSize(horizontal: false, vertical: true) .padding([.bottom, .horizontal], 15) .padding(.top, 5) } @ViewBuilder private var serverInputView: some View { InputView( prompt: $prompt, isSecondaryTextFieldVisible: $isSecondaryTextFieldVisible, animatablePrompt: $animatablePrompt, isMainTextFieldVisible: $isMainTextFieldVisible, allAttachments: $allAttachments, startLoadingAnimation: $startLoadingAnimation, isTranscribing: $isTranscribing ) .padding(.vertical, 7) .background { RoundedRectangle(cornerRadius: 20.0) .fill(.ultraThickMaterial) // .opacity(0.25) .shadow(radius: 10.0) } // .background(.quinary) .overlay(content: { if startLoadingAnimation { ZStack { AnimatedMeshGradient(colors: ThemingEngine.shared.currentTheme.animatedMeshMainColors, speed: $meshSpeed) .mask { RoundedRectangle(cornerRadius: 20, style: .continuous) .stroke(lineWidth: 6.0) } } .transition(.opacity) .allowsHitTesting(false) } else { RoundedRectangle(cornerRadius: 20, style: .continuous) .stroke(.secondary.opacity(0.5), lineWidth: 1.0) } }) .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) // .fixedSize(horizontal: false, vertical: true) .padding([.bottom, .horizontal], 15) .padding(.top, 5) } private func colorScheme(for appearance: Appearance) -> ColorScheme? { switch appearance { case .light: return .light case .dark: return .dark case .auto: return nil } } private func checkAndClearChat() { let currentTime = Date() let timeInterval = currentTime.timeIntervalSince(lastChatTime) switch chatClearInterval { case "15min": if timeInterval >= 15 * 60 { clearChat() } case "1hour": if timeInterval >= 60 * 60 { clearChat() } case "1day": if timeInterval >= 24 * 60 * 60 { clearChat() } case "never": // Do nothing break default: // Handle unexpected values print("Unexpected chat clear interval: \(chatClearInterval)") } } private func clearChat() { allAttachments.removeAll() conversationModel.stopGenerating() conversationModel.reset() modelManager.clearText() conversationModel.message = nil prompt = "" animatablePrompt = "" } } struct ConversationView: View { @Environment(ConversationViewModel.self) private var conversationModel @Environment(ModelManager.self) private var modelManager @Environment(MenuViewModel.self) private var menuModel @AppStorage("inlineCodeHiglight") private var inlineCodeHiglight: AccentColorOption = .blue @AppStorage("lightCodeBlockTheme") private var lightCodeBlockTheme: String = "xcode" @AppStorage("darkCodeBlockTheme") private var darkCodeBlockTheme: String = "monokai-sublime" // @Binding var responseSize: CGSize @Binding var columnVisibility: NavigationSplitViewVisibility var isLocal: Bool = false var body: some View { NavigationSplitView(columnVisibility: $columnVisibility, preferredCompactColumn: .constant(.sidebar), sidebar: { SidebarContent() .navigationSplitViewColumnWidth(150) }, detail: { // Color.green DetailContent(isLocal: isLocal) }) // .background(.ultraThickMaterial) // .overlay { // RoundedRectangle(cornerRadius: 16, style: .continuous) // .stroke(.secondary.opacity(0.5), lineWidth: 1.0) // } // .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) } } //#Preview { // ConversationView(isResponseVisible: .constant(true), responseSize: .constant(CGSize(width: 300, height: 500))) // .environment(ModelManager()) // .environment(ConversationViewModel()) //} #Preview("dark") { ZStack(alignment: .top) { ChatView() .frame(width: 400, height: 400) .environment(ModelManager()) .environment(ConversationViewModel()) .environment(AudioModelManager()) .environment(MenuViewModel()) // .colorScheme(.dark) } }