feat: conversation state machine, mic meter, history, wake lock (Phase 3)
This commit is contained in:
1 parent
4986b6d7dc
commit
5483e6ed48
8 files changed
+348
-29
No files matched your search
@@ -31,7 +31,11 @@ struct ConversationView: View {
|
||||
NavigationStack {
|
||||
VStack(spacing: 0) {
|
||||
statusHeader
|
||||
TranscriptListView(events: session.transcriptHandler.events)
|
||||
TranscriptListView(
|
||||
events: session.transcriptHandler.events,
|
||||
history: session.transcriptHandler.history,
|
||||
onClear: { session.transcriptHandler.clearHistory() }
|
||||
)
|
||||
composer
|
||||
}
|
||||
.navigationTitle("Hope Voice")
|
||||
@@ -49,6 +53,21 @@ struct ConversationView: View {
|
||||
OrbView(state: orbState)
|
||||
Text(statusText)
|
||||
.font(.headline)
|
||||
if session.state == .ready, session.stateModel.isThinkingChipVisible {
|
||||
Label("Thinking…", systemImage: "ellipsis")
|
||||
.font(.caption)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 4)
|
||||
.background(.orange.opacity(0.2), in: Capsule())
|
||||
}
|
||||
if session.state == .ready, !settings.textMode {
|
||||
micMeter
|
||||
}
|
||||
if session.state == .ready, settings.textMode {
|
||||
Text("Barge in anytime — type to reply")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if let agent = session.agent {
|
||||
Text("Agent: \(agent.name ?? agent.identity)")
|
||||
.font(.caption)
|
||||
@@ -63,10 +82,16 @@ struct ConversationView: View {
|
||||
Label(session.isMuted ? "Muted" : "Mic on", systemImage: session.isMuted ? "mic.slash" : "mic")
|
||||
}
|
||||
.toggleStyle(.button)
|
||||
Toggle("Text mode", isOn: $settings.textMode)
|
||||
Toggle("Text mode", isOn: textModeBinding)
|
||||
.toggleStyle(.button)
|
||||
}
|
||||
}
|
||||
if let micError = session.micError {
|
||||
Label(micError, systemImage: "exclamationmark.triangle")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.orange)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
if let connectError {
|
||||
Label(connectError, systemImage: "exclamationmark.triangle")
|
||||
.font(.footnote)
|
||||
@@ -76,6 +101,40 @@ struct ConversationView: View {
|
||||
.padding()
|
||||
}
|
||||
|
||||
private var micMeter: some View {
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .leading) {
|
||||
Capsule()
|
||||
.fill(Color(.systemFill))
|
||||
Capsule()
|
||||
.fill(Color.green.gradient)
|
||||
.frame(width: min(geo.size.width, geo.size.width * session.stateModel.micLevel * 2.5))
|
||||
}
|
||||
}
|
||||
.frame(height: 4)
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
private var composer: some View {
|
||||
HStack(spacing: 8) {
|
||||
switch session.state {
|
||||
@@ -118,20 +177,35 @@ struct ConversationView: View {
|
||||
|
||||
private var orbState: OrbView.OrbState {
|
||||
switch session.state {
|
||||
case .idle: .idle
|
||||
case .connecting, .waitingAgent: .connecting
|
||||
case .ready: settings.textMode ? .idle : .listening
|
||||
case .failed: .failed
|
||||
case .idle: return .idle
|
||||
case .connecting, .waitingAgent: return .connecting
|
||||
case .ready:
|
||||
guard !settings.textMode else { return .idle }
|
||||
switch session.stateModel.phase {
|
||||
case .idle: return .idle
|
||||
case .connecting: return .connecting
|
||||
case .listening: return .listening
|
||||
case .thinking: return .thinking
|
||||
case .speaking: return .speaking
|
||||
}
|
||||
case .failed: return .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)"
|
||||
case .idle: return "Ready to connect"
|
||||
case .connecting: return "Connecting…"
|
||||
case .waitingAgent: return "Waiting for agent…"
|
||||
case .ready:
|
||||
if settings.textMode { return "Text mode" }
|
||||
switch session.stateModel.phase {
|
||||
case .listening: return "Listening"
|
||||
case .thinking: return "Thinking it through"
|
||||
case .speaking: return "Hope is speaking"
|
||||
default: return "Ready when you are"
|
||||
}
|
||||
case .failed(let message): return "Failed: \(message)"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -49,20 +49,46 @@ struct OrbView: View {
|
||||
|
||||
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
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(events) { event in
|
||||
transcriptRow(event)
|
||||
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)
|
||||
}
|
||||
.onChange(of: events.count) { _, count in
|
||||
if let last = events.last {
|
||||
withAnimation { proxy.scrollTo(last.id, anchor: .bottom) }
|
||||
.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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -76,9 +102,15 @@ struct TranscriptListView: View {
|
||||
: 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)
|
||||
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)
|
||||
@@ -91,9 +123,8 @@ struct TranscriptListView: View {
|
||||
}
|
||||
|
||||
private func stripped(_ text: String) -> String {
|
||||
// Strip delivery-style markers like [appreciative], [pauses] etc.
|
||||
text.replacingOccurrences(
|
||||
of: #"\[[a-zA-Z ,'-]+\]"#,
|
||||
of: Self.deliveryMarkers,
|
||||
with: "",
|
||||
options: .regularExpression
|
||||
).trimmingCharacters(in: .whitespaces)
|
||||
|
||||
Reference in new issue
Block a user