Switches the project from AGPL to a source-available license that restricts commercial use — selling oAI or any part of it, standalone or bundled into another product/service, now requires a separate commercial license from the copyright holder. Noncommercial use, study, modification, and sharing remain fully permitted. Updates: LICENSE (canonical PolyForm Noncommercial 1.0.0 text + commercial licensing contact note), SPDX headers and file-header boilerplate across all Swift source files, the in-app About dialog's license link (+ its localization catalog entry), README.md and DEVELOPMENT.md license sections. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
176 lines
6.1 KiB
Swift
176 lines
6.1 KiB
Swift
//
|
|
// ChatView.swift
|
|
// oAI
|
|
//
|
|
// Main chat interface
|
|
//
|
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
|
// Copyright (C) 2026 Rune Olsen
|
|
//
|
|
// This file is part of oAI.
|
|
//
|
|
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
|
|
// You may use, study, modify, and share it for any noncommercial
|
|
// purpose. Commercial use — including selling oAI or any part of
|
|
// it, standalone or bundled into another product or service —
|
|
// requires a separate commercial license from the copyright holder.
|
|
//
|
|
// See the LICENSE file or
|
|
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
|
// the full license text. For commercial licensing, contact Rune
|
|
// Olsen via <https://mac.oai.pm>.
|
|
|
|
|
|
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,
|
|
onModelSelect: onModelSelect,
|
|
onProviderChange: onProviderChange,
|
|
conversationName: viewModel.currentConversationName,
|
|
hasUnsavedChanges: viewModel.hasUnsavedChanges,
|
|
onQuickSave: viewModel.quickSave
|
|
)
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Input bar
|
|
InputBar(
|
|
text: $viewModel.inputText,
|
|
isGenerating: viewModel.isGenerating,
|
|
onlineMode: viewModel.onlineMode,
|
|
onSend: viewModel.sendMessage,
|
|
onCancel: viewModel.cancelGeneration,
|
|
onToggleOnline: {
|
|
viewModel.onlineMode.toggle()
|
|
SettingsService.shared.onlineMode = viewModel.onlineMode
|
|
}
|
|
)
|
|
|
|
// Footer
|
|
FooterView(
|
|
stats: viewModel.sessionStats,
|
|
conversationName: viewModel.currentConversationName,
|
|
hasUnsavedChanges: viewModel.hasUnsavedChanges,
|
|
onQuickSave: viewModel.quickSave,
|
|
onlineMode: viewModel.onlineMode,
|
|
mcpEnabled: viewModel.mcpEnabled
|
|
)
|
|
}
|
|
.background(Color.oaiBackground)
|
|
.sheet(isPresented: $viewModel.showShortcuts) {
|
|
ShortcutsView()
|
|
}
|
|
.sheet(isPresented: $viewModel.showSkills) {
|
|
AgentSkillsView()
|
|
}
|
|
.sheet(isPresented: $viewModel.showJarvis) {
|
|
JarvisView()
|
|
}
|
|
.sheet(item: Binding(
|
|
get: { MCPService.shared.pendingBashCommand },
|
|
set: { _ in }
|
|
)) { pending in
|
|
BashApprovalSheet(
|
|
pending: pending,
|
|
onApprove: { forSession in MCPService.shared.approvePendingBashCommand(forSession: forSession) },
|
|
onDeny: { MCPService.shared.denyPendingBashCommand() }
|
|
)
|
|
}
|
|
.sheet(item: Binding(
|
|
get: { MCPService.shared.pendingPersonalDataAction },
|
|
set: { _ in }
|
|
)) { pending in
|
|
PersonalDataApprovalSheet(
|
|
pending: pending,
|
|
onApprove: { forSession in MCPService.shared.approvePendingPersonalDataAction(forSession: forSession) },
|
|
onDeny: { MCPService.shared.denyPendingPersonalDataAction() }
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
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())
|
|
}
|