176 lines
6.0 KiB
Swift
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()
|
|
}
|
|
}
|
|
}
|