CxLLM-IOS/CxLLMStudio/Views/Chat/ChatView.swift
2026-05-16 10:52:04 -05:00

176 lines
6.0 KiB
Swift

// CxLLM Studio iOS Application
// Views/Chat/ChatView.swift iOS chat interface
import SwiftUI
import CxCode
import CxAWS
struct ChatView: View {
@Environment(AppState.self) private var appState
@Environment(GatewayService.self) private var gateway
@State private var inputText = ""
@State private var isStreaming = false
@State private var streamingText = ""
var body: some View {
VStack(spacing: 0) {
if let conv = appState.activeConversation {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: 12) {
ForEach(conv.messages) { message in
MessageRow(message: message)
.id(message.id)
}
if isStreaming {
MessageRow(message: Message(role: .assistant, content: streamingText, isStreaming: true))
.id("streaming")
}
}
.padding()
}
.onChange(of: conv.messages.count) {
if let lastId = conv.messages.last?.id {
proxy.scrollTo(lastId, anchor: .bottom)
}
}
}
Divider()
// Input
HStack(spacing: 12) {
TextField("Message...", text: $inputText, axis: .vertical)
.textFieldStyle(.roundedBorder)
.lineLimit(1...5)
Button {
Task { await sendMessage() }
} label: {
Image(systemName: "arrow.up.circle.fill")
.font(.title2)
}
.disabled(inputText.isEmpty || isStreaming)
}
.padding()
} else {
// Welcome
VStack(spacing: 20) {
Spacer()
Image(systemName: "sparkles")
.font(.system(size: 48))
.foregroundStyle(.purple)
Text("CxLLM Studio")
.font(.title.bold())
Text("Tap + to start a conversation")
.foregroundStyle(.secondary)
Button("New Chat") {
appState.createConversation()
}
.buttonStyle(.borderedProminent)
Spacer()
}
}
}
.toolbar {
ToolbarItem(placement: .primaryAction) {
Menu {
ForEach(CxModelSlot.allCases) { slot in
Button {
appState.selectedModel = slot
appState.createConversation()
} label: {
Label(slot.codename, systemImage: slot.icon)
}
}
} label: {
Image(systemName: "plus")
}
}
ToolbarItem(placement: .principal) {
HStack(spacing: 6) {
Circle()
.fill(gateway.isConnected ? .green : .red)
.frame(width: 8, height: 8)
Text(appState.selectedModel.codename)
.font(.caption)
}
}
}
}
private func sendMessage() async {
let text = inputText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return }
inputText = ""
if appState.activeConversationId == nil {
appState.createConversation()
}
guard var conv = appState.activeConversation else { return }
conv.messages.append(.user(text))
appState.activeConversation = conv
if conv.sessionId == nil {
do {
let sessionId = try await gateway.createSession(model: appState.selectedModel.rawValue)
conv.sessionId = sessionId
appState.activeConversation = conv
} catch { return }
}
guard let sessionId = conv.sessionId else { return }
isStreaming = true
streamingText = ""
do {
let response = try await gateway.sendMessage(sessionId: sessionId, message: text, model: appState.selectedModel.rawValue)
conv.messages.append(.assistant(response.reply, model: appState.selectedModel.codename))
appState.activeConversation = conv
} catch {
conv.messages.append(.assistant("Error: \(error.localizedDescription)"))
appState.activeConversation = conv
}
isStreaming = false
}
}
struct MessageRow: View {
let message: Message
var body: some View {
HStack(alignment: .top, spacing: 10) {
Circle()
.fill(message.role == .user ? .blue : .purple)
.frame(width: 28, height: 28)
.overlay {
Image(systemName: message.role == .user ? "person.fill" : "sparkles")
.font(.caption2)
.foregroundStyle(.white)
}
VStack(alignment: .leading, spacing: 4) {
if message.isStreaming {
HStack {
Text(message.content)
ProgressView()
}
} else {
Text(LocalizedStringKey(message.content))
.textSelection(.enabled)
}
if let model = message.model {
Text(model)
.font(.caption2)
.foregroundStyle(.secondary)
}
}
Spacer()
}
}
}