feat: LiveKit voice session, control channel, transcripts (Phase 2)

- VoiceSession: token->connect with fresh-room retry on timeout, agent
  discovery, agent_ready wait, mic enable/mute, text send over lk.chat
- ControlChannel: outbound set_voice/set_model/get_models/set_mode/
  media_state; inbound agent_ready/models/model_changed/task_event
- TranscriptHandler: transcription segments + transcript topic finals,
  partial/final dedupe per role
- ConversationView/OrbView: status orb, mic/text-mode toggles, composer
- ServerConfig: plain ws:// signaling for private IPs (self-signed TLS
  not injectable into SDK WebSocket); Info.plist local networking
This commit is contained in:
snowspeeder committed 2026-08-30 07:26:58 -04:00
1 parent 378b8cb36f
commit 4986b6d7dc
14 files changed
+900 -50

No files matched your search

@@ -2,33 +2,161 @@ import SwiftUI
struct ConversationView: View {
@ObservedObject var settings: SettingsStore
@StateObject private var session: VoiceSession
@State private var composerText = ""
@State private var connectError: String?
init(settings: SettingsStore) {
self.settings = settings
let config = ServerConfig.parse(settings.trimmedServerURL)
if case .success(let value) = config {
_session = StateObject(wrappedValue: VoiceSession(
config: value,
tokenClient: TokenClient(config: value)
))
} 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)
))
} else {
fatalError("Unreachable: default server URL is valid")
}
}
}
var body: some View {
NavigationStack {
VStack(spacing: 24) {
Spacer()
Circle()
.fill(.blue.gradient)
.frame(width: 96, height: 96)
Text("Hope Voice")
.font(.title.bold())
Text(settings.trimmedServerURL)
.font(.footnote)
.foregroundStyle(.secondary)
Spacer()
VStack(spacing: 0) {
statusHeader
TranscriptListView(events: session.transcriptHandler.events)
composer
}
.navigationTitle("Voice")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
NavigationLink {
SettingsView(settings: settings)
} label: {
Image(systemName: "gearshape")
}
.navigationTitle("Hope Voice")
.toolbar { toolbarContent }
.onAppear {
if case .failure(let error) = ServerConfig.parse(settings.trimmedServerURL) {
connectError = error.localizedDescription
}
}
}
}
private var statusHeader: some View {
VStack(spacing: 12) {
OrbView(state: orbState)
Text(statusText)
.font(.headline)
if let agent = session.agent {
Text("Agent: \(agent.name ?? agent.identity)")
.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: $settings.textMode)
.toggleStyle(.button)
}
}
if let connectError {
Label(connectError, systemImage: "exclamationmark.triangle")
.font(.footnote)
.foregroundStyle(.red)
}
}
.padding()
}
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)
}
.disabled(!settings.textMode || composerText.isEmpty)
Button(role: .destructive, action: endSession) {
Image(systemName: "phone.down.fill")
}
}
}
.padding()
}
private var toolbarContent: some ToolbarContent {
ToolbarItem(placement: .topBarTrailing) {
NavigationLink {
SettingsView(settings: settings)
} label: {
Image(systemName: "gearshape")
}
}
}
private var orbState: OrbView.OrbState {
switch session.state {
case .idle: .idle
case .connecting, .waitingAgent: .connecting
case .ready: settings.textMode ? .idle : .listening
case .failed: .failed
}
}
private var statusText: String {
switch session.state {
case .idle: "Ready to connect"
case .connecting: "Connecting…"
case .waitingAgent: "Waiting for agent…"
case .ready: settings.textMode ? "Text mode" : "Listening"
case .failed(let message): "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 {
+101
View File
@@ -0,0 +1,101 @@
import SwiftUI
struct OrbView: View {
enum OrbState {
case idle
case connecting
case listening
case speaking
case thinking
case failed
}
let state: OrbState
@State private var animating = false
private var baseColor: Color {
switch state {
case .idle, .connecting: .blue
case .listening: .green
case .speaking, .thinking: .orange
case .failed: .red
}
}
private var scale: CGFloat {
switch state {
case .connecting, .listening, .speaking, .thinking: 1.1
default: 1.0
}
}
var body: some View {
let shape = Circle()
.fill(baseColor.gradient)
.frame(width: 120, height: 120)
let animated = shape
.scaleEffect(animating ? scale : 0.95)
.opacity(state == .idle || state == .failed ? 0.7 : 1.0)
let anim: Animation? = state == .idle || state == .failed
? nil
: .easeInOut(duration: 0.8).repeatForever(autoreverses: true)
animated
.animation(anim, value: animating)
.onAppear { animating = true }
.onChange(of: state) { _, _ in animating = true }
}
}
struct TranscriptListView: View {
let events: [TranscriptEvent]
var body: some View {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: 8) {
ForEach(events) { event in
transcriptRow(event)
}
}
.padding(.horizontal)
}
.onChange(of: events.count) { _, count in
if let last = events.last {
withAnimation { proxy.scrollTo(last.id, anchor: .bottom) }
}
}
}
}
private func transcriptRow(_ event: TranscriptEvent) -> some View {
let roleName = event.role == .user ? "You" : "Hope"
let nameColor: Color = event.role == .user ? .secondary : .blue
let rowBackground: Color = event.role == .user
? Color(.secondarySystemBackground)
: Color.blue.opacity(0.08)
let textOpacity: Double = event.isFinal ? 1.0 : 0.55
return VStack(alignment: .leading, spacing: 2) {
Text(roleName)
.font(.caption2.bold())
.foregroundStyle(nameColor)
Text(stripped(event.text))
.font(.body)
.opacity(textOpacity)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(8)
.background(rowBackground)
.cornerRadius(10)
.id(event.id)
}
private func stripped(_ text: String) -> String {
// Strip delivery-style markers like [appreciative], [pauses] etc.
text.replacingOccurrences(
of: #"\[[a-zA-Z ,'-]+\]"#,
with: "",
options: .regularExpression
).trimmingCharacters(in: .whitespaces)
}
}