- 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
102 lines
3.0 KiB
Swift
102 lines
3.0 KiB
Swift
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)
|
|
}
|
|
}
|