// 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() } } }