Archived
344 lines
14 KiB
Swift
344 lines
14 KiB
Swift
import SwiftUI
|
|
import Combine
|
|
|
|
struct ConversationView: View {
|
|
@ObservedObject var settings: SettingsStore
|
|
@State private var sessionID = UUID()
|
|
@State private var session: VoiceSession
|
|
/// The exact server URL the current session was constructed with. Used to
|
|
/// avoid redundant session replacement when the applied URL hasn't actually
|
|
/// changed (e.g. re-applying the same value from Settings).
|
|
@State private var activeServerURL: String
|
|
/// Identity guard: prevents a second `.onChange` from starting a concurrent
|
|
/// session replacement while the first is still disconnecting the old one.
|
|
@State private var isReplacingSession = false
|
|
/// Latest accepted server URL that has not yet been applied to a session.
|
|
/// Coalesced: rapid Applies during an active replacement retain the newest
|
|
/// value rather than dropping it. A single retained worker loop drains
|
|
/// arbitrary pending URLs until none remain (Iteration 08 Item 2).
|
|
@State private var pendingServerURL: String?
|
|
@State private var composerText = ""
|
|
@State private var connectError: String?
|
|
@State private var tick = 0
|
|
|
|
init(settings: SettingsStore) {
|
|
self.settings = settings
|
|
let initialSession = Self.makeSession(settings: settings)
|
|
_session = State(initialValue: initialSession)
|
|
_activeServerURL = State(initialValue: settings.trimmedServerURL)
|
|
}
|
|
|
|
/// Creates a session from the configured server URL. `SettingsStore.init`
|
|
/// guarantees the stored value is always valid (legacy invalid values are
|
|
/// sanitized to the documented default), so the failure branch is a safety
|
|
/// net only.
|
|
private static func makeSession(settings: SettingsStore) -> VoiceSession {
|
|
let config = ServerConfig.parse(settings.trimmedServerURL)
|
|
switch config {
|
|
case .success(let value):
|
|
return VoiceSession(config: value, tokenClient: TokenClient(config: value), settings: settings)
|
|
case .failure:
|
|
// Defensive: should be unreachable because SettingsStore sanitizes.
|
|
let fallback = ServerConfig.parse(SettingsStore.defaultServerURL)
|
|
if case .success(let value) = fallback {
|
|
settings.serverURL = SettingsStore.defaultServerURL
|
|
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,
|
|
onClear: {
|
|
// Atomically clear the canonical visible collection,
|
|
// dedupe state, and persistence so Clear is truthful.
|
|
session.transcriptHandler.clearAll()
|
|
}
|
|
)
|
|
composer
|
|
}
|
|
.navigationTitle("Hope Voice")
|
|
.toolbar { toolbarContent }
|
|
.id(sessionID)
|
|
.onAppear {
|
|
if case .failure(let error) = ServerConfig.parse(settings.trimmedServerURL) {
|
|
connectError = error.localizedDescription
|
|
}
|
|
}
|
|
.onChange(of: settings.textMode) { _, newValue in
|
|
guard session.isReady else { return }
|
|
Task { await session.applyTextMode(newValue) }
|
|
}
|
|
}
|
|
.onReceive(session.objectWillChange.receive(on: RunLoop.main)) { _ in
|
|
tick &+= 1
|
|
}
|
|
.onChange(of: settings.serverURL) { _, newValue in
|
|
// The URL field now uses explicit Apply semantics (SettingsView),
|
|
// so this fires only when the user has applied a value that passed
|
|
// validation. Coalesce rapid accepted values: every accepted Apply
|
|
// writes `pendingServerURL` (latest wins). A single retained worker
|
|
// loop drains arbitrary pending URLs until none remain, covering
|
|
// A→B→C semantics where C arrives while B is disconnecting
|
|
// (Iteration 08 Item 2).
|
|
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty, case .success = ServerConfig.parse(trimmed) else { return }
|
|
|
|
pendingServerURL = trimmed
|
|
|
|
// Start the worker if not already active. The loop consumes
|
|
// pending values until empty, so a single Task handles any number
|
|
// of rapid Applies without nested tasks.
|
|
guard !isReplacingSession else { return }
|
|
isReplacingSession = true
|
|
Task { @MainActor [settings] in
|
|
while let url = self.pendingServerURL {
|
|
self.pendingServerURL = nil
|
|
guard url != self.activeServerURL else { continue }
|
|
let oldSession = self.session
|
|
await oldSession.disconnect()
|
|
self.activeServerURL = url
|
|
self.sessionID = UUID()
|
|
// Construct from the captured validated config, not by
|
|
// rereading settings.serverURL (which may have changed
|
|
// during disconnect).
|
|
switch ServerConfig.parse(url) {
|
|
case .success(let config):
|
|
self.session = VoiceSession(config: config, tokenClient: TokenClient(config: config), settings: settings)
|
|
case .failure:
|
|
let fallback = ServerConfig.parse(SettingsStore.defaultServerURL)
|
|
if case .success(let value) = fallback {
|
|
self.session = VoiceSession(config: value, tokenClient: TokenClient(config: value), settings: settings)
|
|
} else {
|
|
fatalError("Unreachable: default server URL is valid")
|
|
}
|
|
}
|
|
}
|
|
self.isReplacingSession = false
|
|
}
|
|
}
|
|
}
|
|
|
|
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) {
|
|
// The mic toggle is disabled in text mode to enforce the
|
|
// privacy invariant at the UI level as well (Finding 4).
|
|
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)
|
|
.disabled(settings.textMode)
|
|
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> {
|
|
$settings.textMode
|
|
}
|
|
|
|
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, .tearingDown:
|
|
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, .tearingDown: 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 .tearingDown: return "Cleaning up…"
|
|
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
|
|
// Starting a conversation preserves the restored last-50 history; only
|
|
// the explicit Clear button erases it.
|
|
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())
|
|
}
|