mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-22 23:11:10 +00:00
193 lines
7.1 KiB
Swift
193 lines
7.1 KiB
Swift
import Foundation
|
|
import SwiftUI
|
|
|
|
struct ChatTalkTranscriptStripModel: Equatable {
|
|
let recentTranscript: [String]
|
|
let partialTranscript: String
|
|
|
|
init(recentTranscript: [String], partialTranscript: String, limit: Int = 20) {
|
|
let finals = recentTranscript
|
|
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
|
.filter { !$0.isEmpty }
|
|
self.recentTranscript = Array(finals.suffix(max(0, limit)))
|
|
self.partialTranscript = partialTranscript.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
}
|
|
|
|
var hasTranscript: Bool {
|
|
!self.recentTranscript.isEmpty || !self.partialTranscript.isEmpty
|
|
}
|
|
|
|
var collapsedText: String? {
|
|
if !self.partialTranscript.isEmpty { return self.partialTranscript }
|
|
return self.recentTranscript.last
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
struct ChatTalkActivityStrip: View {
|
|
let control: OpenClawChatTalkControl
|
|
|
|
@State private var isExpanded = false
|
|
|
|
var body: some View {
|
|
let model = ChatTalkTranscriptStripModel(
|
|
recentTranscript: self.control.recentTranscript,
|
|
partialTranscript: self.control.partialTranscript)
|
|
|
|
if self.control.isEnabled {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
if model.hasTranscript {
|
|
Button {
|
|
withAnimation(.easeInOut(duration: 0.16)) {
|
|
self.isExpanded.toggle()
|
|
}
|
|
} label: {
|
|
self.header(model: model, showsDisclosure: true)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityLabel(self.isExpanded
|
|
? String(localized: "Hide talk transcript")
|
|
: String(localized: "Show talk transcript"))
|
|
} else {
|
|
self.header(model: model, showsDisclosure: false)
|
|
}
|
|
|
|
if self.isExpanded, model.hasTranscript {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 5) {
|
|
ForEach(Array(model.recentTranscript.enumerated()), id: \.offset) { _, line in
|
|
Text(line)
|
|
.font(OpenClawChatTypography.caption)
|
|
.foregroundStyle(.primary)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
if !model.partialTranscript.isEmpty {
|
|
Text(model.partialTranscript)
|
|
.font(OpenClawChatTypography.caption.italic())
|
|
.foregroundStyle(OpenClawChatTheme.accent)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
}
|
|
}
|
|
.frame(maxHeight: 104)
|
|
.transition(.opacity.combined(with: .move(edge: .top)))
|
|
}
|
|
}
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 7)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
|
.fill(OpenClawChatTheme.subtleCard))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
|
.strokeBorder(OpenClawChatTheme.composerBorder, lineWidth: 1))
|
|
.padding(.horizontal, 14)
|
|
.accessibilityIdentifier("chat-talk-activity")
|
|
}
|
|
}
|
|
|
|
private func header(model: ChatTalkTranscriptStripModel, showsDisclosure: Bool) -> some View {
|
|
HStack(spacing: 7) {
|
|
Circle()
|
|
.fill(OpenClawChatTheme.accent)
|
|
.frame(width: 7, height: 7)
|
|
Text(self.control.statusText)
|
|
.font(OpenClawChatTypography.captionSemiBold)
|
|
.lineLimit(1)
|
|
if let collapsedText = model.collapsedText, !self.isExpanded {
|
|
Text(collapsedText)
|
|
.font(OpenClawChatTypography.caption)
|
|
.foregroundStyle(model.partialTranscript.isEmpty ? .secondary : OpenClawChatTheme.accent)
|
|
.italic(!model.partialTranscript.isEmpty)
|
|
.lineLimit(1)
|
|
}
|
|
Spacer(minLength: 0)
|
|
if showsDisclosure {
|
|
Image(systemName: self.isExpanded ? "chevron.up" : "chevron.down")
|
|
.font(.caption2.weight(.semibold))
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
.contentShape(Rectangle())
|
|
}
|
|
}
|
|
|
|
struct ChatTalkButtonGlyph: View {
|
|
let control: OpenClawChatTalkControl
|
|
|
|
private static let activePalette = TalkWaveformPalette(
|
|
active: [.white, .white.opacity(0.78), .white.opacity(0.55)],
|
|
inactive: [.white.opacity(0.65)])
|
|
|
|
var body: some View {
|
|
if self.control.isEnabled {
|
|
TalkWaveformView(phase: self.waveformPhase, palette: Self.activePalette)
|
|
.frame(width: 23, height: 16)
|
|
.allowsHitTesting(false)
|
|
} else {
|
|
Image(systemName: "waveform")
|
|
}
|
|
}
|
|
|
|
private var waveformPhase: TalkWaveformPhase {
|
|
if self.control.isListening {
|
|
return .listening(
|
|
level: self.control.level,
|
|
speechActive: !self.control.partialTranscript.isEmpty)
|
|
}
|
|
if self.control.isSpeaking {
|
|
return .speaking(level: self.control.level)
|
|
}
|
|
return .thinking
|
|
}
|
|
}
|
|
|
|
private struct ChatTalkInputDeviceMenuModifier: ViewModifier {
|
|
let control: OpenClawChatTalkControl
|
|
|
|
func body(content: Content) -> some View {
|
|
if self.control.selectInputDevice != nil {
|
|
content.contextMenu {
|
|
Button {
|
|
self.control.selectInputDevice?(nil)
|
|
} label: {
|
|
self.menuLabel(
|
|
String(localized: "System Default"),
|
|
selected: self.control.selectedInputDeviceID == nil)
|
|
}
|
|
if !self.control.inputDevices.isEmpty {
|
|
Divider()
|
|
ForEach(self.control.inputDevices) { device in
|
|
Button {
|
|
self.control.selectInputDevice?(device.id)
|
|
} label: {
|
|
self.menuLabel(
|
|
device.name,
|
|
selected: self.control.selectedInputDeviceID == device.id)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
content
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func menuLabel(_ text: String, selected: Bool) -> some View {
|
|
if selected {
|
|
Label(text, systemImage: "checkmark")
|
|
.font(OpenClawChatTypography.body)
|
|
} else {
|
|
Text(text)
|
|
.font(OpenClawChatTypography.body)
|
|
}
|
|
}
|
|
}
|
|
|
|
extension View {
|
|
func chatTalkInputDeviceMenu(_ control: OpenClawChatTalkControl) -> some View {
|
|
self.modifier(ChatTalkInputDeviceMenuModifier(control: control))
|
|
}
|
|
}
|