Files
hope-voice-api/Sources/Features/Conversation/ConversationView.swift
T

292 lines
11 KiB
Swift

import SwiftUI
import Combine
struct ConversationView: View {
@ObservedObject var settings: SettingsStore
@State private var sessionID = UUID()
@State private var session: VoiceSession
@State private var composerText = ""
@State private var connectError: String?
@State private var tick = 0
init(settings: SettingsStore) {
self.settings = settings
_session = State(initialValue: Self.makeSession(settings: settings))
}
private static func makeSession(settings: SettingsStore) -> VoiceSession {
let config = ServerConfig.parse(settings.trimmedServerURL)
if case .success(let value) = config {
return VoiceSession(config: value, tokenClient: TokenClient(config: value), settings: settings)
}
let fallback = ServerConfig.parse("https://192.168.86.2:9178")
if case .success(let value) = fallback {
return VoiceSession(config: value, tokenClient: TokenClient(config: value), settings: settings)
}
fatalError("Unreachable: default server URL is valid")
}
var body: some View {
NavigationStack {
VStack(spacing: 0) {
statusHeader
TranscriptListView(
events: session.transcriptHandler.events,
history: session.transcriptHandler.history,
onClear: { session.transcriptHandler.clearHistory() }
)
composer
}
.navigationTitle("Hope Voice")
.toolbar { toolbarContent }
.id(sessionID)
.onAppear {
if case .failure(let error) = ServerConfig.parse(settings.trimmedServerURL) {
connectError = error.localizedDescription
}
}
}
.onReceive(session.objectWillChange.receive(on: RunLoop.main)) { _ in
tick &+= 1
}
.onChange(of: settings.serverURL) { _, _ in
guard session.state == .idle else { return }
let oldSession = session
Task { @MainActor [settings] in
await oldSession.disconnect()
self.sessionID = UUID()
self.session = Self.makeSession(settings: settings)
}
}
}
private var statusHeader: some View {
VStack(spacing: 12) {
OrbView(state: orbState)
Text(statusText)
.font(.headline)
if session.state == .ready, session.stateModel.isThinkingChipVisible {
Label("Thinking…", systemImage: "ellipsis")
.font(.caption)
.padding(.horizontal, 10)
.padding(.vertical, 4)
.background(.orange.opacity(0.2), in: Capsule())
}
if session.state == .ready, !settings.textMode {
micMeter
}
if session.state == .ready, settings.textMode {
Text("Barge in anytime — type to reply")
.font(.caption)
.foregroundStyle(.secondary)
}
if let agent = session.agent {
Text("Agent: \(agent.name ?? agent.identity)")
.font(.caption)
.foregroundStyle(.secondary)
}
if session.state == .ready {
Text("\(Voices.display(for: settings.voiceID) ?? settings.voiceID) · \(session.selectedModel ?? settings.modelID ?? "—")")
.font(.caption)
.foregroundStyle(.secondary)
}
if session.state == .ready {
HStack(spacing: 16) {
Toggle(isOn: Binding(
get: { !session.isMuted },
set: { newValue in Task { await session.setMuted(!newValue) } }
)) {
Label(session.isMuted ? "Muted" : "Mic on", systemImage: session.isMuted ? "mic.slash" : "mic")
}
.toggleStyle(.button)
Toggle("Text mode", isOn: textModeBinding)
.toggleStyle(.button)
}
}
if session.state == .ready, session.isReconnecting {
Label("Connection lost — reconnecting…", systemImage: "arrow.triangle.2.circlepath")
.font(.footnote)
.foregroundStyle(.orange)
.frame(maxWidth: .infinity, alignment: .leading)
}
if let micError = session.micError {
Label(micError, systemImage: "exclamationmark.triangle")
.font(.footnote)
.foregroundStyle(.orange)
.frame(maxWidth: .infinity, alignment: .leading)
}
if let connectError {
Label(connectError, systemImage: "exclamationmark.triangle")
.font(.footnote)
.foregroundStyle(.red)
}
}
.padding()
}
private var micMeter: some View {
GeometryReader { geo in
ZStack(alignment: .leading) {
Capsule()
.fill(Color(.systemFill))
Capsule()
.fill(Color.green.gradient)
.frame(width: min(geo.size.width, geo.size.width * session.stateModel.micLevel * 2.5))
}
}
.frame(height: 4)
}
private var textModeBinding: Binding<Bool> {
Binding(
get: { settings.textMode },
set: { newValue in
settings.textMode = newValue
Task { await applyTextMode(newValue) }
}
)
}
private func applyTextMode(_ enabled: Bool) async {
guard session.isReady else { return }
if enabled {
await session.setMuted(true)
await session.setMode("text")
} else {
await session.setMode("voice")
await session.enableMicrophone()
}
}
private var composer: some View {
HStack(spacing: 8) {
switch session.state {
case .idle, .failed:
Button(action: startSession) {
Label("Start", systemImage: "phone.fill")
}
.buttonStyle(.borderedProminent)
case .connecting, .waitingAgent:
ProgressView()
case .ready:
TextField(settings.textMode ? "Type a message…" : "Voice mode active",
text: $composerText,
axis: .vertical)
.textFieldStyle(.roundedBorder)
.lineLimit(1...4)
.disabled(!settings.textMode)
Button(action: sendComposer) {
Image(systemName: "arrow.up.circle.fill")
.font(.title2)
}
.accessibilityLabel("Send message")
.disabled(!settings.textMode || composerText.isEmpty)
Button(role: .destructive, action: endSession) {
Image(systemName: "phone.down.fill")
}
.accessibilityLabel("End conversation")
}
}
.padding()
}
@ToolbarContentBuilder
private var toolbarContent: some ToolbarContent {
ToolbarItem(placement: .topBarTrailing) {
NavigationLink {
TaskPanelView(taskStore: session.taskStore)
} label: {
Image(systemName: "tray.full")
.overlay(alignment: .topTrailing) {
if session.taskStore.activeCount > 0 {
Text("\(session.taskStore.activeCount)")
.font(.caption2.weight(.bold))
.foregroundStyle(.white)
.padding(3)
.background(Color.red, in: Circle())
.offset(x: 6, y: -6)
}
}
}
.accessibilityLabel("Background tasks")
}
ToolbarItem(placement: .topBarTrailing) {
NavigationLink {
MediaPanelView(playerController: session.mediaController, settings: settings)
} label: {
Image(systemName: "music.note")
}
.accessibilityLabel("Media player")
}
ToolbarItem(placement: .topBarTrailing) {
NavigationLink {
SettingsView(settings: settings, session: session)
} label: {
Image(systemName: "gearshape")
}
.accessibilityLabel("Settings")
}
}
private var orbState: OrbView.OrbState {
switch session.state {
case .idle: return .idle
case .connecting, .waitingAgent: return .connecting
case .ready:
guard !settings.textMode else { return .idle }
switch session.stateModel.phase {
case .idle: return .idle
case .connecting: return .connecting
case .listening: return .listening
case .thinking: return .thinking
case .speaking: return .speaking
}
case .failed: return .failed
}
}
private var statusText: String {
switch session.state {
case .idle: return "Ready to connect"
case .connecting: return "Connecting…"
case .waitingAgent: return "Waiting for agent…"
case .ready:
if settings.textMode { return "Text mode" }
switch session.stateModel.phase {
case .listening: return "Listening"
case .thinking: return "Thinking it through"
case .speaking: return "Hope is speaking"
default: return "Ready when you are"
}
case .failed(let message): return "Failed: \(message)"
}
}
private func startSession() {
connectError = nil
session.transcriptHandler.clear()
Task { await session.connect() }
}
private func endSession() {
Task { await session.disconnect() }
}
private func sendComposer() {
let text = composerText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return }
composerText = ""
Task {
do {
try await session.sendText(text)
} catch {
connectError = error.localizedDescription
}
}
}
}
#Preview {
ConversationView(settings: SettingsStore())
}