status
This commit is contained in:
1 parent
c9c25f86eb
commit
40aaf4fea7
56 files changed
+5683
-179
No files matched your search
@@ -5,25 +5,47 @@ 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
|
||||
_session = State(initialValue: Self.makeSession(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)
|
||||
if case .success(let value) = config {
|
||||
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")
|
||||
}
|
||||
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 {
|
||||
@@ -32,8 +54,11 @@ struct ConversationView: View {
|
||||
statusHeader
|
||||
TranscriptListView(
|
||||
events: session.transcriptHandler.events,
|
||||
history: session.transcriptHandler.history,
|
||||
onClear: { session.transcriptHandler.clearHistory() }
|
||||
onClear: {
|
||||
// Atomically clear the canonical visible collection,
|
||||
// dedupe state, and persistence so Clear is truthful.
|
||||
session.transcriptHandler.clearAll()
|
||||
}
|
||||
)
|
||||
composer
|
||||
}
|
||||
@@ -45,17 +70,56 @@ struct ConversationView: View {
|
||||
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) { _, _ in
|
||||
guard session.state == .idle else { return }
|
||||
let oldSession = session
|
||||
.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
|
||||
await oldSession.disconnect()
|
||||
self.sessionID = UUID()
|
||||
self.session = Self.makeSession(settings: settings)
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -92,6 +156,8 @@ struct ConversationView: View {
|
||||
}
|
||||
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) } }
|
||||
@@ -99,6 +165,7 @@ struct ConversationView: View {
|
||||
Label(session.isMuted ? "Muted" : "Mic on", systemImage: session.isMuted ? "mic.slash" : "mic")
|
||||
}
|
||||
.toggleStyle(.button)
|
||||
.disabled(settings.textMode)
|
||||
Toggle("Text mode", isOn: textModeBinding)
|
||||
.toggleStyle(.button)
|
||||
}
|
||||
@@ -138,24 +205,7 @@ struct ConversationView: View {
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
$settings.textMode
|
||||
}
|
||||
|
||||
private var composer: some View {
|
||||
@@ -166,7 +216,7 @@ struct ConversationView: View {
|
||||
Label("Start", systemImage: "phone.fill")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
case .connecting, .waitingAgent:
|
||||
case .connecting, .waitingAgent, .tearingDown:
|
||||
ProgressView()
|
||||
case .ready:
|
||||
TextField(settings.textMode ? "Type a message…" : "Voice mode active",
|
||||
@@ -231,7 +281,7 @@ struct ConversationView: View {
|
||||
private var orbState: OrbView.OrbState {
|
||||
switch session.state {
|
||||
case .idle: return .idle
|
||||
case .connecting, .waitingAgent: return .connecting
|
||||
case .connecting, .waitingAgent, .tearingDown: return .connecting
|
||||
case .ready:
|
||||
guard !settings.textMode else { return .idle }
|
||||
switch session.stateModel.phase {
|
||||
@@ -250,6 +300,7 @@ struct ConversationView: View {
|
||||
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 {
|
||||
@@ -264,7 +315,8 @@ struct ConversationView: View {
|
||||
|
||||
private func startSession() {
|
||||
connectError = nil
|
||||
session.transcriptHandler.clear()
|
||||
// Starting a conversation preserves the restored last-50 history; only
|
||||
// the explicit Clear button erases it.
|
||||
Task { await session.connect() }
|
||||
}
|
||||
|
||||
|
||||
@@ -62,8 +62,10 @@ struct OrbView: View {
|
||||
}
|
||||
|
||||
struct TranscriptListView: View {
|
||||
/// The single canonical visible transcript (restored persisted finals plus
|
||||
/// current live events). The view renders exactly this collection — there is
|
||||
/// no second "restored" store, so the UI and persistence can never diverge.
|
||||
let events: [TranscriptEvent]
|
||||
@ObservedObject var history: TranscriptHistory
|
||||
var onClear: () -> Void
|
||||
|
||||
private static let deliveryMarkers = #"\[(?:appreciative|calm|concerned|curious|encouraging|excited|happy|joking|quiet|reassuring|reflective|sad|serious|surprised|sympathetic|urgent|whispering|laughter|sighing|breathing|neutral)\]\s*"#
|
||||
@@ -75,6 +77,8 @@ struct TranscriptListView: View {
|
||||
return formatter
|
||||
}()
|
||||
|
||||
private var visibleRows: [TranscriptEvent] { events }
|
||||
|
||||
var body: some View {
|
||||
ScrollViewReader { proxy in
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
@@ -93,14 +97,14 @@ struct TranscriptListView: View {
|
||||
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(events) { event in
|
||||
ForEach(visibleRows) { event in
|
||||
transcriptRow(event)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
.onChange(of: events.count) { _, count in
|
||||
if let last = events.last {
|
||||
.onChange(of: visibleRows.count) { _, _ in
|
||||
if let last = visibleRows.last {
|
||||
withAnimation { proxy.scrollTo(last.id, anchor: .bottom) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -446,6 +446,9 @@ struct MediaPanelView: View {
|
||||
}
|
||||
|
||||
private var serverConfig: ServerConfig {
|
||||
// Use the configured URL directly. If it's invalid, fall back to the
|
||||
// documented default (media operations are less critical than the main
|
||||
// session, and the Settings view will show a validation error).
|
||||
if case .success(let config) = ServerConfig.parse(settings.trimmedServerURL) {
|
||||
return config
|
||||
}
|
||||
|
||||
@@ -2,26 +2,53 @@ import SwiftUI
|
||||
|
||||
struct SettingsView: View {
|
||||
@ObservedObject var settings: SettingsStore
|
||||
var session: VoiceSession?
|
||||
/// The current voice session, directly observed so the Apply button's
|
||||
/// disabled state and help text update reactively when the session
|
||||
/// transitions (e.g. `.tearingDown` → `.failed`).
|
||||
@ObservedObject var session: VoiceSession
|
||||
/// Local draft of the server URL. Only written to `settings` when the user
|
||||
/// explicitly applies a value that passes validation — keystrokes never
|
||||
/// reach storage or the network layer.
|
||||
@State private var urlText: String = ""
|
||||
@State private var urlError: String?
|
||||
@State private var showVoicePicker = false
|
||||
@State private var showModelPicker = false
|
||||
|
||||
init(settings: SettingsStore, session: VoiceSession? = nil) {
|
||||
init(settings: SettingsStore, session: VoiceSession) {
|
||||
self.settings = settings
|
||||
self.session = session
|
||||
}
|
||||
|
||||
/// Whether the current session is safe to replace. Apply is permitted for
|
||||
/// `.idle` (no active call) and `.failed` (fully torn down after failure —
|
||||
/// the user needs to correct an unreachable server without relaunching).
|
||||
/// Disabled during `.tearingDown`, `.connecting`, `.waitingAgent`, and
|
||||
/// `.ready` to avoid disconnecting an active call or racing cleanup.
|
||||
private var canApplyServerURL: Bool {
|
||||
session.state == .idle || session.state.isFailed
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("Server") {
|
||||
TextField("https://192.168.86.2:9178", text: $urlText)
|
||||
TextField(SettingsStore.defaultServerURL, text: $urlText)
|
||||
.keyboardType(.URL)
|
||||
.autocorrectionDisabled()
|
||||
.onAppear { urlText = settings.serverURL }
|
||||
.onChange(of: urlText) { _, newValue in
|
||||
settings.serverURL = newValue
|
||||
}
|
||||
if let urlError {
|
||||
Text(urlError)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
Button("Apply server URL") { applyServerURL() }
|
||||
.disabled(!canApplyServerURL || urlText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
if !canApplyServerURL {
|
||||
Text(session.state == .tearingDown
|
||||
? "Waiting for failure cleanup to finish…"
|
||||
: "End the conversation before changing servers.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Section("Voice") {
|
||||
Button {
|
||||
@@ -31,14 +58,14 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
Section("Model") {
|
||||
if let session, !session.models.isEmpty {
|
||||
if session.isReady, !session.models.isEmpty {
|
||||
Button {
|
||||
showModelPicker = true
|
||||
} label: {
|
||||
LabeledContent("Model", value: session.selectedModel ?? settings.modelID ?? "—")
|
||||
}
|
||||
} else {
|
||||
Text(session != nil ? "Loading available models…" : "Start a conversation to load the model list.")
|
||||
Text("Start a conversation to load the model list.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
@@ -60,11 +87,35 @@ struct SettingsView: View {
|
||||
ModelPickerView(settings: settings, session: session)
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates the draft and persists it only on success. On failure the
|
||||
/// draft stays in the field with an inline error; `settings.serverURL` is
|
||||
/// left untouched so the active session keeps using its original URL.
|
||||
/// Guarded: only applies when the session is idle (no active call).
|
||||
private func applyServerURL() {
|
||||
guard canApplyServerURL else {
|
||||
urlError = "End the conversation before changing servers."
|
||||
return
|
||||
}
|
||||
let trimmed = urlText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else {
|
||||
urlError = "Enter a server URL."
|
||||
return
|
||||
}
|
||||
switch ServerConfig.parse(trimmed) {
|
||||
case .success:
|
||||
settings.serverURL = trimmed
|
||||
urlText = trimmed
|
||||
urlError = nil
|
||||
case .failure(let error):
|
||||
urlError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct VoicePickerView: View {
|
||||
@ObservedObject var settings: SettingsStore
|
||||
var session: VoiceSession?
|
||||
var session: VoiceSession
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
@@ -73,7 +124,7 @@ struct VoicePickerView: View {
|
||||
ForEach(Voices.all, id: \.id) { voice in
|
||||
Button {
|
||||
settings.voiceID = voice.id
|
||||
Task { await session?.selectVoice(voice.id) }
|
||||
Task { await session.selectVoice(voice.id) }
|
||||
dismiss()
|
||||
} label: {
|
||||
HStack {
|
||||
@@ -99,11 +150,10 @@ struct VoicePickerView: View {
|
||||
|
||||
struct ModelPickerView: View {
|
||||
@ObservedObject var settings: SettingsStore
|
||||
var session: VoiceSession?
|
||||
var session: VoiceSession
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
private var activeModelID: String? {
|
||||
guard let session else { return settings.modelID }
|
||||
if !session.models.isEmpty,
|
||||
let selected = session.selectedModel ?? settings.modelID,
|
||||
session.models.contains(where: { $0.id == selected }) {
|
||||
@@ -115,10 +165,10 @@ struct ModelPickerView: View {
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
ForEach(session?.models ?? [], id: \.id) { model in
|
||||
ForEach(session.models, id: \.id) { model in
|
||||
Button {
|
||||
settings.modelID = model.id
|
||||
Task { await session?.selectModel(model.id) }
|
||||
Task { await session.selectModel(model.id) }
|
||||
dismiss()
|
||||
} label: {
|
||||
HStack {
|
||||
@@ -143,7 +193,12 @@ struct ModelPickerView: View {
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
SettingsView(settings: SettingsStore())
|
||||
let previewSettings = SettingsStore()
|
||||
guard case .success(let previewConfig) = ServerConfig.parse(SettingsStore.defaultServerURL) else {
|
||||
return AnyView(Text("Invalid default server URL"))
|
||||
}
|
||||
let previewSession = VoiceSession(config: previewConfig, tokenClient: TokenClient(config: previewConfig), settings: previewSettings)
|
||||
return AnyView(NavigationStack {
|
||||
SettingsView(settings: previewSettings, session: previewSession)
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user