HuggingChat-Mac/Views/ChatView.swift (192 lines of code) (raw):
//import Models
import UniformTypeIdentifiers
import MarkdownView
import SwiftUI
import WhisperKit
struct ChatView: View {
@Environment(ModelManager.self) private var modelManager
@Environment(ConversationViewModel.self) private var conversationModel
@Environment(AudioModelManager.self) private var audioModelManager
@Environment(MenuViewModel.self) private var menuModel
@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
// STT
@State private var isTranscribing: Bool = false
// Nav
@State private var columnVisibility = NavigationSplitViewVisibility.detailOnly
// Ripple animation vars
// @State var counter: Int = 0
// @State var origin: CGPoint = .init(x: 0.5, y: 0.5)
var body: some View {
ZStack(alignment: .bottom) {
VStack(alignment: .leading, spacing: 0) {
HStack(alignment: .lastTextBaseline, spacing: 0) {
Button(action: {
withAnimation {
columnVisibility = columnVisibility == .detailOnly ? .automatic : .detailOnly
}
}) {
Image(systemName: "sidebar.left")
.foregroundColor(.secondary)
}
.buttonStyle(HighlightButtonStyle())
.help("Toggle Sidebar")
Spacer()
Button(action: {
}) {
Image(systemName: "pip.enter")
.foregroundColor(.secondary)
}
.buttonStyle(HighlightButtonStyle())
.help("Enter Focus Mode")
Button(action: {
}) {
Image(systemName: "square.and.pencil")
.foregroundColor(.secondary)
.imageScale(.medium)
}
.buttonStyle(HighlightButtonStyle())
.help("New Chat")
}
.padding(.horizontal, 15)
.padding(.vertical, 10)
.fontDesign(.rounded)
.fontWeight(.semibold)
.background(Color(NSColor.windowBackgroundColor).opacity(0.3))
Divider()
.overlay( colorScheme == .dark ? Color.black:.clear)
// Response View
ConversationView(columnVisibility: $columnVisibility, isLocal: isLocalGeneration)
// ResponseView(isResponseVisible: $isResponseVisible, responseSize: $responseSize, isLocal: isLocalGeneration)
// ErrorView
// if conversationModel.state == .error || modelManager.loadState.isError {
// if cardIndex == 0 && modelManager.loadState.isError {
// // Local
// if selectedLocalModel != "None" {
// switch modelManager.loadState {
// case .error(let error):
// ScrollView {
// Text(error)
// .padding(20)
// .onGeometryChange(for: CGRect.self) { proxy in
// proxy.frame(in: .global)
// } action: { newValue in
// errorSize.width = newValue.width
// errorSize.height = min(max(newValue.height, 20), 100)
// }
// }
// .frame(maxWidth: .infinity, alignment: .leading)
//
// .frame(height: errorSize.height)
// .background(.ultraThickMaterial)
// .overlay {
// RoundedRectangle(cornerRadius: 16, style: .continuous)
// .stroke(.secondary.opacity(0.5), lineWidth: 1.0)
// }
// .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
// default:
// EmptyView()
// }
//
// }
//
// }
//
// if cardIndex == 1 && conversationModel.state == .error {
// // Server
// ScrollView {
// Text(conversationModel.error?.description ?? "")
// .padding(20)
// .onGeometryChange(for: CGRect.self) { proxy in
// proxy.frame(in: .global)
// } action: { newValue in
// errorSize.width = newValue.width
// errorSize.height = min(max(newValue.height, 20), 100)
// }
// }
// .frame(maxWidth: .infinity, alignment: .leading)
//
// .frame(height: errorSize.height)
// .background(.ultraThickMaterial)
// .overlay {
// RoundedRectangle(cornerRadius: 16, style: .continuous)
// .stroke(.secondary.opacity(0.5), lineWidth: 1.0)
// }
// .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
// }
// }
// 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)
// }
}
.frame(maxHeight: .infinity, alignment: .bottom)
}
.background(.thickMaterial)
.overlay(content: {
RoundedRectangle(cornerRadius: 17, style: .continuous)
.stroke(.secondary.opacity(0.5), lineWidth: 1.0)
})
.clipShape(RoundedRectangle(cornerRadius: 17, style: .continuous))
// .modifier(Shake(animatableData: CGFloat(errorAttempts)))
// .padding()
// .padding(.horizontal, 10) // Allows for shake animation
// .onAppear {
// if isLocalGeneration {
// cardIndex = 0
// focusedField = .localInput
// } else {
// cardIndex = 1
// focusedField = .serverInput
// }
// conversationModel.getActiveModel()
// checkAndClearChat()
// }
}
@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(.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)
}
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 = ""
}
}
#Preview("dark") {
ChatView()
.frame(width: 300, height: 400)
.environment(ModelManager())
.environment(ConversationViewModel())
.environment(AudioModelManager())
.environment(MenuViewModel())
// .colorScheme(.dark)
}