## Agent 核心功能 - **流式输出刘海弹窗**:实时展示模型思考内容,支持自动滚动 - **工具调用进度**:最多可见3条 + 可滚动查看全部历史 + 防抖并行滚动 - **打字/语音双输入**:Agent 模式可选手动打字或语音输入 - **快速/思考模式切换**:支持 thinking mode 开关 - **多轮对话连续性**:同一会话内持续对话,新建对话按钮 ⊕ 开启新会话 - **工作时间显示**:工作完成状态栏显示耗时,如「工作完成 · 00:42」 ## Bug 修复 - **SIGTERM 优雅退出**:bridge.js 处理 SIGTERM,避免 code 15 误报异常 - **弹窗复用不重建**:输入内容继续对话时弹窗内部刷新,不再闪出闪进 - **关闭按钮立刻生效**:ignoreMouse=true 绕过 DynamicNotchKit 延迟 - **设置窗口 Agent UI 构建时机**:从延迟构建改为 init 中直接构建 - **模型编辑器**:修复 grid column nil 崩溃 + 改为独立 KeyEquivalentPanel 窗口支持 Cmd+A/C/V/X - **双击 Control 三向轮换**:语音输入 ↔ AI助手 ↔ 智能体 - **Fn 长按冲突处理**:非 Agent 模式下自动关闭 Agent 刘海弹窗 - **Save 后刷新下拉**:保存配置后默认模型下拉框即时同步 ## UI 交互优化 - **工具列表无灰色背景**:工具执行期间不再有底色变化 - **工作时隐藏新建对话按钮**:thinking/running 期间 ⊕ 自动隐藏 - **工具调用去 timeout 显示**:运行指令行不再显示 (timeout=30s) ## 文件拆分(单文件≤500行) - AgentNotchView → AgentProgressTracker + AppKitTextField + AgentNotchView - SettingsWindow → AgentModelEditor + KeyEquivalentSecureTextField + KeyEquivalentPanel + SettingsWindow ## 底层改动 - DynamicNotchKit: 新增 contentVersion + notifyContentChanged 机制 - NotchView: 动画绑定 contentVersion 支持弹窗内容变化弹性动画
299 lines
10 KiB
Swift
299 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 {
|
||
let hasTools = !tracker.toolEntries.isEmpty
|
||
|
||
return 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(height: hasTools ? maxStreamingHeight : nil)
|
||
.frame(maxHeight: maxStreamingHeight)
|
||
.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
|
||
// 并行工具调用防抖:0.1s 内连续出现只滚一次到底
|
||
scrollWorkItem?.cancel()
|
||
let workItem = DispatchWorkItem {
|
||
withAnimation {
|
||
if let lastID = entries.last?.id {
|
||
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 = ""
|
||
}
|
||
}
|