VoiceInput/Sources/UI/AgentNotchView.swift

296 lines
10 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import SwiftUI
import DynamicNotchKit
// MARK: - AgentNotchView
struct AgentNotchView: View {
@ObservedObject var tracker: AgentProgressTracker
var onCopy: () -> Void
var onStop: () -> Void
var onClose: () -> Void
var onNewConversation: (() -> Void)? = nil
var onSendText: ((String) -> Void)? = nil
var onContentSizeChanged: (() -> Void)? = nil
private let maxHeight: CGFloat = 250
private let maxStreamingHeight: CGFloat = 120
@State private var textInput: String = ""
@State private var scrollWorkItem: DispatchWorkItem?
var body: some View {
VStack(alignment: .leading, spacing: 0) {
statusHeader
if !tracker.streamingContent.isEmpty {
streamingContentView
} else if !tracker.isTextInputMode && tracker.mode == "thinking" && tracker.toolEntries.isEmpty {
thinkingView
}
if !tracker.toolEntries.isEmpty {
toolProgressList
}
if tracker.mode == "done" {
doneView
} else if tracker.mode == "error" {
errorView
}
if tracker.isTextInputMode && tracker.mode != "thinking" && tracker.mode != "running" {
textInputBar
}
}
.frame(width: 340)
.colorScheme(.dark)
.animation(.spring(response: 0.35, dampingFraction: 0.65), value: tracker.mode)
.animation(.spring(response: 0.35, dampingFraction: 0.65), value: tracker.toolEntries.count)
.animation(.spring(response: 0.35, dampingFraction: 0.65), value: tracker.isTextInputMode)
.onChange(of: tracker.mode) { _, _ in onContentSizeChanged?() }
.onChange(of: tracker.toolEntries.count) { _, _ in onContentSizeChanged?() }
.onChange(of: tracker.isTextInputMode) { _, _ in onContentSizeChanged?() }
.onChange(of: tracker.streamingContent.isEmpty) { _, _ in onContentSizeChanged?() }
}
// MARK: -
private var statusHeader: some View {
HStack(spacing: 6) {
Image(systemName: statusIcon)
.font(.system(size: 10))
.foregroundColor(statusColor)
.opacity(tracker.mode == "thinking" ? 1 : (tracker.mode == "running" ? 0.6 : 0.4))
Text(tracker.summary)
.font(.system(size: 11, weight: .medium))
.foregroundColor(.white.opacity(0.7))
Spacer()
if tracker.mode == "running" || tracker.mode == "thinking" {
Button("停止") {
onStop()
}
.font(.system(size: 10))
.foregroundColor(.white.opacity(0.6))
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(.white.opacity(0.1))
.clipShape(Capsule())
.buttonStyle(.plain)
}
if tracker.mode != "thinking" && tracker.mode != "running",
let onNew = onNewConversation {
Button(action: onNew) {
Image(systemName: "plus.bubble")
.font(.system(size: 9, weight: .medium))
.foregroundColor(.white.opacity(0.5))
.frame(width: 20, height: 20)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.help("新建对话")
.padding(.leading, 2)
}
Button(action: onClose) {
Image(systemName: "xmark")
.font(.system(size: 9, weight: .medium))
.foregroundColor(.white.opacity(0.5))
.frame(width: 20, height: 20)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.padding(.leading, 2)
}
.padding(.horizontal, 14)
.padding(.vertical, 8)
}
// MARK: -
private var thinkingView: some View {
HStack(spacing: 6) {
ProgressView()
.scaleEffect(0.6)
Text("正在分析你的问题...")
.font(.system(size: 11))
.foregroundColor(.white.opacity(0.4))
}
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 12)
}
// MARK: -
private var streamingContentView: some View {
ScrollViewReader { proxy in
ScrollView(.vertical, showsIndicators: false) {
Text(tracker.streamingContent)
.font(.system(size: 12))
.foregroundColor(.white.opacity(0.85))
.lineSpacing(3)
.frame(maxWidth: .infinity, alignment: .leading)
.id("streaming_bottom")
}
.frame(minHeight: 40, maxHeight: maxStreamingHeight)
.layoutPriority(1)
.padding(.horizontal, 14)
.padding(.vertical, 6)
.onChange(of: tracker.streamingContent) { _, _ in
withAnimation {
proxy.scrollTo("streaming_bottom", anchor: .bottom)
}
}
}
}
// MARK: - 3
private var toolProgressList: some View {
ScrollViewReader { proxy in
ScrollView(.vertical, showsIndicators: false) {
VStack(alignment: .leading, spacing: 0) {
ForEach(Array(tracker.toolEntries.enumerated()), id: \.element.id) { idx, entry in
compactToolRow(entry: entry)
.id(entry.id)
.transition(
.asymmetric(
insertion: .move(edge: .bottom).combined(with: .opacity),
removal: .move(edge: .top).combined(with: .opacity)
)
)
.zIndex(Double(-idx))
}
}
}
.frame(maxHeight: 60)
.clipped()
.padding(.horizontal, 4)
.onReceive(tracker.$toolEntries) { entries in
scrollWorkItem?.cancel()
let workItem = DispatchWorkItem {
if let lastID = entries.last?.id {
withAnimation(nil) {
proxy.scrollTo(lastID, anchor: .bottom)
}
}
}
scrollWorkItem = workItem
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: workItem)
}
}
}
private func compactToolRow(entry: AgentProgressTracker.ToolEntry) -> some View {
let hasResult = !entry.resultLines.isEmpty
let isActive = !hasResult && entry.id == tracker.toolEntries.last?.id && tracker.mode == "running"
return HStack(spacing: 4) {
Circle()
.fill(isActive ? Color.white : Color.white.opacity(0.3))
.frame(width: 5, height: 5)
Text(entry.display)
.font(.system(size: 10, design: .monospaced))
.foregroundColor(hasResult ? .white.opacity(0.6) : .white.opacity(0.85))
.lineLimit(1)
.truncationMode(.tail)
if isActive {
Circle()
.fill(.white.opacity(0.5))
.frame(width: 3, height: 3)
.opacity(blinkingOpacity)
}
Spacer()
}
.padding(.horizontal, 10)
.padding(.vertical, 4)
}
@State private var blinkToggle = false
private var blinkingOpacity: Double {
blinkToggle ? 1 : 0.2
}
// MARK: -
private var doneView: some View {
EmptyView()
}
// MARK: -
private var errorView: some View {
Text("\(tracker.errorMessage)")
.font(.system(size: 12))
.foregroundColor(.red.opacity(0.8))
.padding(.horizontal, 14)
.padding(.vertical, 10)
}
// MARK: -
private var statusIcon: String {
switch tracker.mode {
case "thinking": return "brain"
case "running": return "gearshape.2"
case "done": return "checkmark.circle"
case "error": return "xmark.circle"
default: return "circle"
}
}
private var statusColor: Color {
switch tracker.mode {
case "thinking": return .blue
case "running": return .orange
case "done": return .green
case "error": return .red
default: return .gray
}
}
// MARK: -
private var textInputBar: some View {
let isBusy = tracker.mode == "thinking" || tracker.mode == "running"
return VStack(spacing: 0) {
Divider()
.overlay(.white.opacity(0.1))
.padding(.horizontal, 10)
HStack(spacing: 8) {
AppKitTextField(
text: $textInput,
placeholder: isBusy ? "等待回复中..." : "输入你的问题...",
isEditable: !isBusy,
onSubmit: { if !isBusy { sendText() } }
)
.frame(height: 28)
.opacity(isBusy ? 0.5 : 1)
Button(action: { if !isBusy { sendText() } }) {
Image(systemName: "arrow.up.circle.fill")
.font(.system(size: 20))
.foregroundColor(!isBusy && !textInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
? .blue : .white.opacity(0.2))
}
.buttonStyle(.plain)
.disabled(isBusy || textInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
.padding(.horizontal, 14)
.padding(.vertical, 8)
}
}
private func sendText() {
let text = textInput.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return }
onSendText?(text)
textInput = ""
}
}