feat: voice and model pickers with sync-on-join (Phase 4)
- Voices.swift: 13 Azure voices, default en-US-Ava - SettingsStore: persist voiceID/modelID, fallback to default voice - VoiceSession: sync set_voice/set_model on agent_ready, selectVoice/selectModel, track models + model_changed - SettingsView: voice and model picker sheets - ControlChannel: publish via publish(data:) reliable data channel instead of sendText DataStream (agent listens on legacy data_received; DataStream text was dropped)
This commit is contained in:
1 parent
5483e6ed48
commit
ca2d26f171
8 files changed
+256
-14
No files matched your search
@@ -12,14 +12,16 @@ struct ConversationView: View {
|
||||
if case .success(let value) = config {
|
||||
_session = StateObject(wrappedValue: VoiceSession(
|
||||
config: value,
|
||||
tokenClient: TokenClient(config: value)
|
||||
tokenClient: TokenClient(config: value),
|
||||
settings: settings
|
||||
))
|
||||
} else {
|
||||
let fallback = ServerConfig.parse("https://192.168.86.2:9178")
|
||||
if case .success(let value) = fallback {
|
||||
_session = StateObject(wrappedValue: VoiceSession(
|
||||
config: value,
|
||||
tokenClient: TokenClient(config: value)
|
||||
tokenClient: TokenClient(config: value),
|
||||
settings: settings
|
||||
))
|
||||
} else {
|
||||
fatalError("Unreachable: default server URL is valid")
|
||||
@@ -73,6 +75,11 @@ struct ConversationView: View {
|
||||
.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(
|
||||
@@ -168,7 +175,7 @@ struct ConversationView: View {
|
||||
private var toolbarContent: some ToolbarContent {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
NavigationLink {
|
||||
SettingsView(settings: settings)
|
||||
SettingsView(settings: settings, session: session)
|
||||
} label: {
|
||||
Image(systemName: "gearshape")
|
||||
}
|
||||
|
||||
@@ -2,7 +2,15 @@ import SwiftUI
|
||||
|
||||
struct SettingsView: View {
|
||||
@ObservedObject var settings: SettingsStore
|
||||
var session: VoiceSession?
|
||||
@State private var urlText: String = ""
|
||||
@State private var showVoicePicker = false
|
||||
@State private var showModelPicker = false
|
||||
|
||||
init(settings: SettingsStore, session: VoiceSession? = nil) {
|
||||
self.settings = settings
|
||||
self.session = session
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
@@ -15,6 +23,26 @@ struct SettingsView: View {
|
||||
settings.serverURL = newValue
|
||||
}
|
||||
}
|
||||
Section("Voice") {
|
||||
Button {
|
||||
showVoicePicker = true
|
||||
} label: {
|
||||
LabeledContent("Voice", value: Voices.display(for: settings.voiceID) ?? settings.voiceID)
|
||||
}
|
||||
}
|
||||
Section("Model") {
|
||||
if let session, !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.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Section("Interaction") {
|
||||
Toggle("Text mode", isOn: $settings.textMode)
|
||||
}
|
||||
@@ -25,9 +53,97 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
.navigationTitle("Settings")
|
||||
.sheet(isPresented: $showVoicePicker) {
|
||||
VoicePickerView(settings: settings, session: session)
|
||||
}
|
||||
.sheet(isPresented: $showModelPicker) {
|
||||
ModelPickerView(settings: settings, session: session)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct VoicePickerView: View {
|
||||
@ObservedObject var settings: SettingsStore
|
||||
var session: VoiceSession?
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
ForEach(Voices.all, id: \.id) { voice in
|
||||
Button {
|
||||
settings.voiceID = voice.id
|
||||
Task { await session?.selectVoice(voice.id) }
|
||||
dismiss()
|
||||
} label: {
|
||||
HStack {
|
||||
Text(voice.displayName)
|
||||
if settings.voiceID == voice.id {
|
||||
Spacer()
|
||||
Image(systemName: "checkmark")
|
||||
.foregroundStyle(.blue)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Voice")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ModelPickerView: View {
|
||||
@ObservedObject var settings: SettingsStore
|
||||
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 }) {
|
||||
return selected
|
||||
}
|
||||
return session.models.first?.id
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
ForEach(session?.models ?? [], id: \.id) { model in
|
||||
Button {
|
||||
settings.modelID = model.id
|
||||
Task { await session?.selectModel(model.id) }
|
||||
dismiss()
|
||||
} label: {
|
||||
HStack {
|
||||
Text(model.name ?? model.id)
|
||||
if activeModelID == model.id {
|
||||
Spacer()
|
||||
Image(systemName: "checkmark")
|
||||
.foregroundStyle(.blue)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Model")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
SettingsView(settings: SettingsStore())
|
||||
NavigationStack {
|
||||
SettingsView(settings: SettingsStore())
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user