VoiceInput/Sources/App/MenuBarController.swift
JOJO 94b05c28b3 feat: Agent 模式重大更新 — 稳定多轮对话、流式刘海、模型管理、交互优化
## 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 支持弹窗内容变化弹性动画
2026-04-29 16:42:46 +08:00

351 lines
12 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 Cocoa
///
final class MenuBarController: NSObject {
private let statusItem: NSStatusItem
private let menu = NSMenu()
private var languageMenuItems: [NSMenuItem] = []
private var modeMenuItems: [NSMenuItem] = []
// LLM
private let inputLLMSubmenu = NSMenu()
private let inputLLMEnabledItem: NSMenuItem
private let assistLLMSubmenu = NSMenu()
private let assistLLMEnabledItem: NSMenuItem
private let inputSettingsWindow: SettingsWindow
private let assistSettingsWindow: SettingsWindow
private let agentSettingsWindow: SettingsWindow
private let agentLLMEnabledItem: NSMenuItem
override init() {
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
inputLLMEnabledItem = NSMenuItem(
title: "Enable",
action: #selector(toggleInputLLM),
keyEquivalent: ""
)
assistLLMEnabledItem = NSMenuItem(
title: "Enable",
action: #selector(toggleAssistLLM),
keyEquivalent: ""
)
inputSettingsWindow = SettingsWindow(mode: .input)
assistSettingsWindow = SettingsWindow(mode: .assist)
agentSettingsWindow = SettingsWindow(mode: .agent)
agentLLMEnabledItem = NSMenuItem(
title: "Enable",
action: #selector(toggleAgentLLM),
keyEquivalent: ""
)
super.init()
configureStatusItem()
buildMenu()
}
private func configureStatusItem() {
if let button = statusItem.button {
button.image = NSImage(
systemSymbolName: "mic.fill",
accessibilityDescription: "Voice Input"
)
button.toolTip = "Voice Input — 按住 Fn 键录音"
}
}
private func buildMenu() {
//
let currentLanguageItem = NSMenuItem(
title: "Language: \(Config.shared.language.displayName)",
action: nil,
keyEquivalent: ""
)
currentLanguageItem.isEnabled = false
menu.addItem(currentLanguageItem)
menu.addItem(.separator())
//
let languageMenu = NSMenu()
for lang in RecognitionLanguage.allCases {
let item = NSMenuItem(
title: lang.displayName,
action: #selector(selectLanguage(_:)),
keyEquivalent: ""
)
item.target = self
item.representedObject = lang
item.state = lang == Config.shared.language ? .on : .off
languageMenu.addItem(item)
languageMenuItems.append(item)
}
// autoenablesItems
languageMenu.autoenablesItems = false
let languageSubmenuItem = NSMenuItem()
languageSubmenuItem.title = "Language"
languageSubmenuItem.submenu = languageMenu
menu.addItem(languageSubmenuItem)
//
for item in languageMenuItems {
print("[Menu Debug] Language item: '\(item.title)' enabled=\(item.isEnabled) target=\(String(describing: item.target)) action=\(String(describing: item.action))")
}
menu.addItem(.separator())
// ===== =====
let modeMenu = NSMenu()
let inputModeItem = NSMenuItem(
title: "🎤 语音输入",
action: #selector(selectMode(_:)),
keyEquivalent: ""
)
inputModeItem.target = self
inputModeItem.representedObject = TriggerMode.input
inputModeItem.state = Config.shared.triggerMode == .input ? .on : .off
modeMenuItems.append(inputModeItem)
modeMenu.addItem(inputModeItem)
let assistModeItem = NSMenuItem(
title: "💬 AI 助手",
action: #selector(selectMode(_:)),
keyEquivalent: ""
)
assistModeItem.target = self
assistModeItem.representedObject = TriggerMode.assist
assistModeItem.state = Config.shared.triggerMode == .assist ? .on : .off
modeMenuItems.append(assistModeItem)
modeMenu.addItem(assistModeItem)
let agentModeItem = NSMenuItem(
title: "🤖 智能体",
action: #selector(selectMode(_:)),
keyEquivalent: ""
)
agentModeItem.target = self
agentModeItem.representedObject = TriggerMode.agent
agentModeItem.state = Config.shared.triggerMode == .agent ? .on : .off
modeMenuItems.append(agentModeItem)
modeMenu.addItem(agentModeItem)
// autoenablesItems
modeMenu.autoenablesItems = false
let modeSubmenuItem = NSMenuItem()
modeSubmenuItem.title = "Mode"
modeSubmenuItem.submenu = modeMenu
menu.addItem(modeSubmenuItem)
//
for item in modeMenuItems {
print("[Menu Debug] Mode item: '\(item.title)' enabled=\(item.isEnabled) target=\(String(describing: item.target)) action=\(String(describing: item.action))")
}
menu.addItem(.separator())
// ===== Input LLM =====
inputLLMSubmenu.addItem(inputLLMEnabledItem)
inputLLMEnabledItem.target = self
inputLLMEnabledItem.action = #selector(toggleInputLLM)
updateInputLLMMenuState()
let inputSettingsItem = NSMenuItem(
title: "Settings...",
action: #selector(openInputSettings),
keyEquivalent: ""
)
inputSettingsItem.target = self
inputLLMSubmenu.addItem(inputSettingsItem)
let inputSubmenuItem = NSMenuItem()
inputSubmenuItem.title = "🎤 Input LLM"
inputSubmenuItem.submenu = inputLLMSubmenu
menu.addItem(inputSubmenuItem)
menu.addItem(.separator())
// ===== Assist LLM =====
assistLLMSubmenu.addItem(assistLLMEnabledItem)
assistLLMEnabledItem.target = self
assistLLMEnabledItem.action = #selector(toggleAssistLLM)
updateAssistLLMMenuState()
let assistSettingsItem = NSMenuItem(
title: "Settings...",
action: #selector(openAssistSettings),
keyEquivalent: ""
)
assistSettingsItem.target = self
assistLLMSubmenu.addItem(assistSettingsItem)
let assistSubmenuItem = NSMenuItem()
assistSubmenuItem.title = "💬 Assist LLM"
assistSubmenuItem.submenu = assistLLMSubmenu
menu.addItem(assistSubmenuItem)
menu.addItem(.separator())
// ===== Agent LLM =====
let agentLLMSubmenu = NSMenu()
agentLLMEnabledItem.target = self
agentLLMSubmenu.addItem(agentLLMEnabledItem)
updateAgentLLMMenuState()
let agentSettingsItem = NSMenuItem(
title: "Settings...",
action: #selector(openAgentSettings),
keyEquivalent: ""
)
agentSettingsItem.target = self
agentSettingsItem.isEnabled = true
agentLLMSubmenu.addItem(agentSettingsItem)
let agentSubmenuItem = NSMenuItem()
agentSubmenuItem.title = "🤖 Agent LLM"
agentSubmenuItem.submenu = agentLLMSubmenu
menu.addItem(agentSubmenuItem)
menu.addItem(.separator())
// 退
let quitItem = NSMenuItem(
title: "Quit",
action: #selector(quit),
keyEquivalent: "q"
)
quitItem.target = self
menu.addItem(quitItem)
//
menu.autoenablesItems = false
languageMenu.autoenablesItems = false
modeMenu.autoenablesItems = false
inputLLMSubmenu.autoenablesItems = false
assistLLMSubmenu.autoenablesItems = false
agentLLMSubmenu.autoenablesItems = false
print("[Menu Debug] Agent Settings item: title=\(agentSettingsItem.title) enabled=\(agentSettingsItem.isEnabled) target=\(String(describing: agentSettingsItem.target)) action=\(String(describing: agentSettingsItem.action)) submenu=\(String(describing: agentSettingsItem.menu))")
statusItem.menu = menu
}
// MARK: - NSMenuItemValidation
///
@objc func validateMenuItem(_ menuItem: NSMenuItem) -> Bool {
// target/action
return true
}
func refreshModeMenu() {
updateModeMenuState()
}
private func updateInputLLMMenuState() {
inputLLMEnabledItem.state = Config.shared.inputLLM.enabled ? .on : .off
}
private func updateAssistLLMMenuState() {
assistLLMEnabledItem.state = Config.shared.assistLLM.enabled ? .on : .off
}
private func updateAgentLLMMenuState() {
let modelsFile = Config.shared.loadAgentModels()
let validModels = modelsFile.models.filter { !$0.url.isEmpty && !$0.name.isEmpty && !$0.apikey.isEmpty && !$0.modes.isEmpty }
agentLLMEnabledItem.state = validModels.isEmpty ? .off : .on
}
private func updateModeMenuState() {
for item in modeMenuItems {
guard let mode = item.representedObject as? TriggerMode else { continue }
item.state = mode == Config.shared.triggerMode ? .on : .off
}
}
private func updateLanguageMenuState() {
for item in languageMenuItems {
guard let lang = item.representedObject as? RecognitionLanguage else { continue }
item.state = lang == Config.shared.language ? .on : .off
}
if let first = menu.items.first {
first.title = "Language: \(Config.shared.language.displayName)"
}
}
@objc private func selectLanguage(_ sender: NSMenuItem) {
print("[Menu Debug] selectLanguage called! sender.title=\(sender.title) sender.representedObject=\(String(describing: sender.representedObject))")
guard let lang = sender.representedObject as? RecognitionLanguage else {
print("[Menu Debug] selectLanguage FAILED: representedObject is not RecognitionLanguage, actual type: \(type(of: sender.representedObject))")
return
}
Config.shared.language = lang
updateLanguageMenuState()
print("[Menu] Language switched to: \(lang.displayName)")
}
@objc private func selectMode(_ sender: NSMenuItem) {
print("[Menu Debug] selectMode called! sender.title=\(sender.title) sender.representedObject=\(String(describing: sender.representedObject))")
guard let mode = sender.representedObject as? TriggerMode else {
print("[Menu Debug] selectMode FAILED: representedObject is not TriggerMode, actual type: \(type(of: sender.representedObject))")
return
}
Config.shared.triggerMode = mode
updateModeMenuState()
print("[Menu] Mode switched to: \(mode)")
}
@objc private func toggleInputLLM() {
var config = Config.shared.inputLLM
config.enabled.toggle()
Config.shared.inputLLM = config
updateInputLLMMenuState()
print("[Menu] Input LLM: \(config.enabled ? "ON" : "OFF")")
}
@objc private func toggleAssistLLM() {
var config = Config.shared.assistLLM
config.enabled.toggle()
Config.shared.assistLLM = config
updateAssistLLMMenuState()
print("[Menu] Assist LLM: \(config.enabled ? "ON" : "OFF")")
}
@objc private func toggleAgentLLM() {
let modelsFile = Config.shared.loadAgentModels()
let validModels = modelsFile.models.filter { !$0.url.isEmpty && !$0.name.isEmpty && !$0.apikey.isEmpty && !$0.modes.isEmpty }
// Settings
if validModels.isEmpty {
openAgentSettings()
}
}
@objc private func openInputSettings() {
inputSettingsWindow.makeKeyAndOrderFront(nil)
NSApplication.shared.activate(ignoringOtherApps: true)
}
@objc private func openAssistSettings() {
assistSettingsWindow.makeKeyAndOrderFront(nil)
NSApplication.shared.activate(ignoringOtherApps: true)
}
@objc private func openAgentSettings() {
print("[MenuBar] openAgentSettings called")
agentSettingsWindow.refreshAgentModelTable()
agentSettingsWindow.makeKeyAndOrderFront(nil)
NSApplication.shared.activate(ignoringOtherApps: true)
}
@objc private func quit() {
NSApplication.shared.terminate(nil)
}
}