156 lines
5.2 KiB
Swift
156 lines
5.2 KiB
Swift
//
|
|
// ChatView.swift
|
|
// oAI
|
|
//
|
|
// Main chat interface
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
struct ChatView: View {
|
|
@Environment(ChatViewModel.self) var viewModel
|
|
let onModelSelect: () -> Void
|
|
let onProviderChange: (Settings.Provider) -> Void
|
|
|
|
var body: some View {
|
|
@Bindable var viewModel = viewModel
|
|
VStack(spacing: 0) {
|
|
// Header
|
|
HeaderView(
|
|
provider: viewModel.currentProvider,
|
|
model: viewModel.selectedModel,
|
|
stats: viewModel.sessionStats,
|
|
onlineMode: viewModel.onlineMode,
|
|
mcpEnabled: viewModel.mcpEnabled,
|
|
mcpStatus: viewModel.mcpStatus,
|
|
onModelSelect: onModelSelect,
|
|
onProviderChange: onProviderChange
|
|
)
|
|
|
|
// Messages
|
|
ScrollViewReader { proxy in
|
|
ScrollView {
|
|
LazyVStack(alignment: .leading, spacing: 12) {
|
|
ForEach(viewModel.messages) { message in
|
|
MessageRow(message: message, viewModel: viewModel)
|
|
.id(message.id)
|
|
}
|
|
|
|
// Processing indicator
|
|
if viewModel.isGenerating && viewModel.messages.last?.isStreaming != true {
|
|
ProcessingIndicator()
|
|
.padding(.horizontal)
|
|
}
|
|
|
|
// Invisible bottom anchor for auto-scroll
|
|
Color.clear
|
|
.frame(height: 1)
|
|
.id("bottom")
|
|
}
|
|
.padding()
|
|
}
|
|
.background(Color.oaiBackground)
|
|
.onChange(of: viewModel.messages.count) {
|
|
withAnimation {
|
|
proxy.scrollTo("bottom", anchor: .bottom)
|
|
}
|
|
}
|
|
.onChange(of: viewModel.messages.last?.content) {
|
|
// Auto-scroll as streaming content arrives
|
|
if viewModel.isGenerating {
|
|
proxy.scrollTo("bottom", anchor: .bottom)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Auto-continue countdown banner
|
|
if viewModel.isAutoContinuing {
|
|
HStack(spacing: 12) {
|
|
ProgressView()
|
|
.progressViewStyle(.circular)
|
|
.scaleEffect(0.7)
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(ThinkingVerbs.random())
|
|
.font(.system(size: 13, weight: .medium))
|
|
.foregroundColor(.oaiPrimary)
|
|
Text("Continuing in \(viewModel.autoContinueCountdown)s")
|
|
.font(.system(size: 11))
|
|
.foregroundColor(.oaiSecondary)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
Button("Cancel") {
|
|
viewModel.cancelAutoContinue()
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.controlSize(.small)
|
|
.tint(.red)
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 10)
|
|
.background(Color.blue.opacity(0.1))
|
|
.overlay(
|
|
Rectangle()
|
|
.stroke(Color.blue.opacity(0.3), lineWidth: 1)
|
|
)
|
|
}
|
|
|
|
// Input bar
|
|
InputBar(
|
|
text: $viewModel.inputText,
|
|
isGenerating: viewModel.isGenerating,
|
|
mcpStatus: viewModel.mcpStatus,
|
|
onlineMode: viewModel.onlineMode,
|
|
onSend: viewModel.sendMessage,
|
|
onCancel: viewModel.cancelGeneration
|
|
)
|
|
|
|
// Footer
|
|
FooterView(stats: viewModel.sessionStats)
|
|
}
|
|
.background(Color.oaiBackground)
|
|
}
|
|
}
|
|
|
|
struct ProcessingIndicator: View {
|
|
@State private var animating = false
|
|
@State private var thinkingText = ThinkingVerbs.random()
|
|
|
|
var body: some View {
|
|
HStack(spacing: 8) {
|
|
Text(thinkingText)
|
|
.font(.system(size: 14, weight: .medium))
|
|
.foregroundColor(.oaiSecondary)
|
|
|
|
HStack(spacing: 4) {
|
|
ForEach(0..<3) { index in
|
|
Circle()
|
|
.fill(Color.oaiSecondary)
|
|
.frame(width: 6, height: 6)
|
|
.scaleEffect(animating ? 1.0 : 0.5)
|
|
.animation(
|
|
.easeInOut(duration: 0.6)
|
|
.repeatForever()
|
|
.delay(Double(index) * 0.2),
|
|
value: animating
|
|
)
|
|
}
|
|
}
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 12)
|
|
.background(Color.oaiSecondary.opacity(0.05))
|
|
.cornerRadius(8)
|
|
.onAppear {
|
|
animating = true
|
|
}
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
ChatView(onModelSelect: {}, onProviderChange: { _ in })
|
|
.environment(ChatViewModel())
|
|
}
|