296 lines
10 KiB
Swift
296 lines
10 KiB
Swift
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 = ""
|
||
}
|
||
}
|