Archived
147 lines
4.7 KiB
Swift
147 lines
4.7 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
|
|
}
|
|
}
|
|
|
|
private var label: String {
|
|
switch state {
|
|
case .idle: "Idle"
|
|
case .connecting: "Connecting"
|
|
case .listening: "Listening"
|
|
case .speaking: "Speaking"
|
|
case .thinking: "Thinking"
|
|
case .failed: "Connection failed"
|
|
}
|
|
}
|
|
|
|
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 }
|
|
.accessibilityElement()
|
|
.accessibilityLabel("Conversation status")
|
|
.accessibilityValue(label)
|
|
}
|
|
}
|
|
|
|
struct TranscriptListView: View {
|
|
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*"#
|
|
|
|
private static let timestampFormatter: DateFormatter = {
|
|
let formatter = DateFormatter()
|
|
formatter.dateStyle = .none
|
|
formatter.timeStyle = .short
|
|
return formatter
|
|
}()
|
|
|
|
var body: some View {
|
|
ScrollViewReader { proxy in
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
HStack {
|
|
Text("Transcript")
|
|
.font(.caption.bold())
|
|
.foregroundStyle(.secondary)
|
|
Spacer()
|
|
Button(role: .destructive, action: onClear) {
|
|
Label("Clear", systemImage: "trash")
|
|
.font(.caption)
|
|
}
|
|
}
|
|
.padding(.horizontal)
|
|
.padding(.bottom, 4)
|
|
|
|
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) {
|
|
HStack {
|
|
Text(roleName)
|
|
.font(.caption2.bold())
|
|
.foregroundStyle(nameColor)
|
|
Spacer()
|
|
Text(Self.timestampFormatter.string(from: event.timestamp))
|
|
.font(.caption2)
|
|
.foregroundStyle(.tertiary)
|
|
}
|
|
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 {
|
|
text.replacingOccurrences(
|
|
of: Self.deliveryMarkers,
|
|
with: "",
|
|
options: .regularExpression
|
|
).trimmingCharacters(in: .whitespaces)
|
|
}
|
|
}
|