=== 记忆系统 === - 新增 remember(记录)和 recall(搜索)工具,持久化到 ~/Library/Application Support/VoiceInput/memory/memory.json - 新增 set_identity 工具,独立管理助手名字/用户称呼/语气 - prompt 重写为个人助手身份,注入 macOS 能力 + 记忆规则 - prompt 中积极调用记忆工具,按需搜索、自然融入回答 - 禁止相对时间,待办必须绝对日期 === 对话持久化 === - 集成 conversation_store,done 前自动写入对话文件 - token 记录:prompt/completion 累加,total 覆盖为最新上下文长度 - 启动时加载最新对话继续(使用 eagent /resume 逻辑) - 去重:末尾连续 assistant 消息自动合并 - 修复消息加载顺序 bug(旧对话覆盖用户新消息) === 新建对话 === - 手动/自动两种模式(设置窗口可切换) - 自动模式:total > 阈值时隐藏按钮,下次输入自动新建 - 自动阈值在设置窗口可配置(默认 40000) === 弹窗 UI 统一 === - 语音输入按 Fn 即显示刘海弹窗(和打字输入统一) - 语音识别内容自动填入并发送(可关闭自动发送) - 工作期间(thinking/running)隐藏输入框,idle/done 时显示 - 工具显示三种模式:全部显示/最后隐藏/一直不显示 === 设置窗口增强 === - 助手名字 / 用户称呼 / 语气可配置 - 工具显示模式 / 新建对话模式 / 自动阈值 - 语音输入后自动发送开关(默认开) - 记忆管理窗口可视化编辑(Dock 可访问) - 窗口高度 840→860 === 菜单增强 === - Agent LLM 二级菜单切换语音/打字输入 - 助手设置和记忆管理入口(Dock 可访问) === 修复 === - Fn 按下时停止正在进行的朗读 - 弹窗已存在时不再覆盖 isTextInputMode - voiceInputText 加 @Published 以触发 UI 更新
310 lines
11 KiB
Swift
310 lines
11 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 && tracker.toolsMode != "never" {
|
||
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?() }
|
||
.onChange(of: tracker.voiceInputText) { _, newValue in
|
||
if let text = newValue, !text.isEmpty {
|
||
tracker.voiceInputText = nil
|
||
textInput = text
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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" {
|
||
if tracker.newConversationMode == "auto" {
|
||
if tracker.autoNewTriggered {
|
||
Text("已自动新建对话")
|
||
.font(.system(size: 9))
|
||
.foregroundColor(.white.opacity(0.4))
|
||
}
|
||
} else if 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 = ""
|
||
}
|
||
}
|