=== 记忆系统 === - 新增 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 更新
435 lines
15 KiB
Swift
435 lines
15 KiB
Swift
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
|
||
private let agentVoiceInputItem: NSMenuItem
|
||
private let agentTextInputItem: NSMenuItem
|
||
private let memoryWindow: MemoryWindow
|
||
|
||
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)
|
||
memoryWindow = MemoryWindow()
|
||
agentLLMEnabledItem = NSMenuItem(
|
||
title: "Enable",
|
||
action: #selector(toggleAgentLLM),
|
||
keyEquivalent: ""
|
||
)
|
||
agentVoiceInputItem = NSMenuItem(
|
||
title: "🎤 语音输入",
|
||
action: #selector(selectAgentVoiceInput),
|
||
keyEquivalent: ""
|
||
)
|
||
agentTextInputItem = NSMenuItem(
|
||
title: "⌨️ 打字输入",
|
||
action: #selector(selectAgentTextInput),
|
||
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()
|
||
|
||
agentLLMSubmenu.addItem(.separator())
|
||
|
||
agentVoiceInputItem.target = self
|
||
agentVoiceInputItem.state = Config.shared.agentInputMode == "voice" ? .on : .off
|
||
agentLLMSubmenu.addItem(agentVoiceInputItem)
|
||
|
||
agentTextInputItem.target = self
|
||
agentTextInputItem.state = Config.shared.agentInputMode == "text" ? .on : .off
|
||
agentLLMSubmenu.addItem(agentTextInputItem)
|
||
|
||
agentLLMSubmenu.addItem(.separator())
|
||
|
||
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 assistantSettingsItem = NSMenuItem(
|
||
title: "⚙️ 助手设置...",
|
||
action: #selector(openAgentSettings),
|
||
keyEquivalent: ""
|
||
)
|
||
assistantSettingsItem.target = self
|
||
menu.addItem(assistantSettingsItem)
|
||
|
||
// 记忆管理
|
||
let memoryItem = NSMenuItem(
|
||
title: "🧠 记忆管理...",
|
||
action: #selector(openMemoryManager),
|
||
keyEquivalent: ""
|
||
)
|
||
memoryItem.target = self
|
||
menu.addItem(memoryItem)
|
||
|
||
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() {
|
||
showWindowWithDock(inputSettingsWindow)
|
||
}
|
||
|
||
@objc private func openAssistSettings() {
|
||
showWindowWithDock(assistSettingsWindow)
|
||
}
|
||
|
||
@objc private func openAgentSettings() {
|
||
print("[MenuBar] openAgentSettings called")
|
||
agentSettingsWindow.refreshAgentModelTable()
|
||
showWindowWithDock(agentSettingsWindow)
|
||
}
|
||
|
||
@objc private func openMemoryManager() {
|
||
showWindowWithDock(memoryWindow)
|
||
}
|
||
|
||
@objc private func selectAgentVoiceInput() {
|
||
Config.shared.agentInputMode = "voice"
|
||
agentVoiceInputItem.state = .on
|
||
agentTextInputItem.state = .off
|
||
}
|
||
|
||
@objc private func selectAgentTextInput() {
|
||
Config.shared.agentInputMode = "text"
|
||
agentVoiceInputItem.state = .off
|
||
agentTextInputItem.state = .on
|
||
}
|
||
|
||
/// 显示窗口并临时启用 Dock 图标
|
||
private var dockVisibleWindowCount = 0
|
||
|
||
private func showWindowWithDock(_ window: NSWindow) {
|
||
dockVisibleWindowCount += 1
|
||
if NSApp.activationPolicy() != .regular {
|
||
NSApp.setActivationPolicy(.regular)
|
||
}
|
||
window.delegate = self
|
||
window.makeKeyAndOrderFront(nil)
|
||
NSApp.activate(ignoringOtherApps: true)
|
||
}
|
||
|
||
@objc private func quit() {
|
||
NSApplication.shared.terminate(nil)
|
||
}
|
||
}
|
||
|
||
// MARK: - NSWindowDelegate(Dock 回收)
|
||
|
||
extension MenuBarController: NSWindowDelegate {
|
||
func windowWillClose(_ notification: Notification) {
|
||
dockVisibleWindowCount -= 1
|
||
if dockVisibleWindowCount <= 0 {
|
||
dockVisibleWindowCount = 0
|
||
NSApp.setActivationPolicy(.accessory)
|
||
}
|
||
}
|
||
}
|