import Cocoa import AVFoundation import Speech import SwiftUI import DynamicNotchKit /// 应用主控制器,协调 Fn 键(长按录音)和 Control 键(双击切换模式) final class AppDelegate: NSObject, NSApplicationDelegate, NSSpeechSynthesizerDelegate { /// 防止 ARC 优化释放(NSApplication.delegate 是 weak) private static var retainedInstance: AppDelegate? private let keyMonitor = KeyMonitor() private let speechRecognizer = SpeechRecognizer() private let audioMonitor = AudioLevelMonitor() private let recordingPanel = RecordingPanel() private let menuBarController = MenuBarController() private let llmRefiner = LLMRefiner() private let speechSynthesizer = NSSpeechSynthesizer() /// 当前正在朗读的模式,用于朗读完成后关闭对应弹窗 private var speakingMode: LLMMode? = nil /// 朗读完成后的延迟关闭任务,重新输入时取消 private var speakAutoHideWorkItem: DispatchWorkItem? = nil private let agentBridge: AgentBridge private let agentTracker = AgentProgressTracker() private var agentNotch: DynamicNotch? private var isRecording = false private var finalTranscription: String = "" private var isTextInputActive = false private var agentActive = false private var agentIsTextMode = false private var agentExitingCleanly = false // 主动关闭标记,避免误报退出错误 private var autoHideWorkItem: DispatchWorkItem? private var reminderTimer: Timer? private var triggeredReminderIds = Set() // 防止重复弹窗 override init() { agentBridge = AgentBridge(agentDir: Config.shared.agentDir) super.init() Self.retainedInstance = self speechSynthesizer.delegate = self } func applicationDidFinishLaunching(_ notification: Notification) { NSApp.setActivationPolicy(.accessory) // 启动诊断日志 reminderLog("======== VoiceInput 启动 ========") reminderLog("bundlePath: \(Bundle.main.bundlePath)") reminderLog("resourcePath: \(Bundle.main.resourcePath ?? "nil")") reminderLog("agentDir: \(Config.shared.agentDir)") reminderLog("memoryPath: \(Config.shared.memoryPath)") reminderLog("memoryExists: \(FileManager.default.fileExists(atPath: Config.shared.memoryPath))") reminderLog("logDir: \(logDir)") reminderLog("agentModelsPath: \(Config.shared.userAgentModelsPath)") reminderLog("agentModelsExists: \(FileManager.default.fileExists(atPath: Config.shared.userAgentModelsPath))") startReminderTimer() beginPermissionFlow() print("[App] Ready") } // MARK: - Permissions private func beginPermissionFlow() { let hasAccessibility = CGPreflightListenEventAccess() let hasInputMonitoring = canCreateHIDEventTapSimple() // 两个权限都 OK → 直接启动 if hasAccessibility && hasInputMonitoring { startKeyMonitor() return } // 打开系统设置隐私主页 NSWorkspace.shared.open(URL(string: "x-apple.systempreferences:com.apple.preference.security")!) // 列出缺失的权限 var missing: [String] = [] if !hasAccessibility { missing.append("辅助功能") } if !hasInputMonitoring { missing.append("输入监控") } let alert = NSAlert() alert.messageText = "需要系统权限" alert.informativeText = "VoiceInput 需要以下权限才能正常工作:\n\n• \(missing.joined(separator: "\n• "))\n\n请在左侧列表中找到对应项目,点击 + 添加 VoiceInput,然后打开开关。" alert.alertStyle = .informational alert.addButton(withTitle: "已授权,继续") alert.addButton(withTitle: "退出") guard alert.runModal() == .alertFirstButtonReturn else { NSApp.terminate(nil); return } // 轮询检测 Timer.scheduledTimer(withTimeInterval: 2.0, repeats: true) { [weak self] timer in guard let self = self else { return } let ok1 = CGPreflightListenEventAccess() let ok2 = self.canCreateHIDEventTapSimple() if ok1 && ok2 { timer.invalidate() DispatchQueue.main.async { print("[Permission] ✅ All permissions granted") self.startKeyMonitor() self.showReadyAlert() } } } } /// 简单检测输入监控(仅用于引导,KeyMonitor 内部有 HID→Session 回退) private func canCreateHIDEventTapSimple() -> Bool { let mask = (1 << CGEventType.flagsChanged.rawValue) guard let tap = CGEvent.tapCreate(tap: .cghidEventTap, place: .headInsertEventTap, options: .defaultTap, eventsOfInterest: CGEventMask(mask), callback: { _, _, _, _ in return nil }, userInfo: nil) else { return false } CFMachPortInvalidate(tap) return true } private func showReadyAlert() { let alert = NSAlert() alert.messageText = "权限已就绪" alert.informativeText = "Voice Input 正在运行。首次使用时还需授权麦克风和语音识别。" alert.alertStyle = .informational alert.runModal() } private func showMicrophonePermissionAlert() { let alert = NSAlert() alert.messageText = "需要麦克风权限" alert.informativeText = "请前往 系统设置 > 隐私与安全性 > 麦克风,开启 VoiceInput 的权限。" alert.alertStyle = .warning alert.addButton(withTitle: "打开系统设置") alert.addButton(withTitle: "取消") if alert.runModal() == .alertFirstButtonReturn { NSWorkspace.shared.open(URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone")!) } } private func showSpeechPermissionAlert() { let alert = NSAlert() alert.messageText = "需要语音识别权限" alert.informativeText = "请前往 系统设置 > 隐私与安全性 > 语音识别,开启 VoiceInput 的权限。" alert.alertStyle = .warning alert.addButton(withTitle: "打开系统设置") alert.addButton(withTitle: "取消") if alert.runModal() == .alertFirstButtonReturn { NSWorkspace.shared.open(URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_SpeechRecognition")!) } } // MARK: - Key Monitor private func startKeyMonitor() { guard keyMonitor.start() else { print("[App] Failed to start key monitor"); return } keyMonitor.onFnDown = { [weak self] in self?.startRecording() } keyMonitor.onFnUp = { [weak self] in self?.stopRecording() } keyMonitor.onControlDoubleClick = { [weak self] in self?.toggleModeViaDoubleClick() } } private func toggleModeViaDoubleClick() { let newMode: TriggerMode switch Config.shared.triggerMode { case .input: newMode = .assist case .assist: newMode = .agent case .agent: newMode = .input } Config.shared.triggerMode = newMode menuBarController.refreshModeMenu() print("[App] Control double-click: mode switched to \(newMode)") } // MARK: - Recording private func startRecording() { guard !isRecording else { return } // 正在朗读 → 停止 if speechSynthesizer.isSpeaking { speechSynthesizer.stopSpeaking() speakingMode = nil speakAutoHideWorkItem?.cancel() } // 如果当前模式不是智能体但刘海弹窗还在 → 关闭 if agentNotch != nil && Config.shared.triggerMode != .agent { hideAgentNotch() } // 智能体模式 → 统一显示刘海弹窗 if Config.shared.triggerMode == .agent { // done 后再次按 Fn:不清空内容,只显示输入框 if agentTracker.mode == "done" { agentTracker.isTextInputMode = true showAgentNotchInternal(isTextInput: true) } else { agentTracker.resetForInput() agentTracker.isTextInputMode = true agentTracker.toolsMode = Config.shared.agentToolsMode agentTracker.newConversationMode = Config.shared.agentNewConversationMode showAgentNotchInternal(isTextInput: true) } // 打字输入:不录音,等待用户输入 if Config.shared.agentInputMode == "text" { isTextInputActive = true recordingPanel.updateText("Type your question...") recordingPanel.showAnimated() return } // 语音输入:继续录音流程 } isRecording = true finalTranscription = "" let micStatus = AVCaptureDevice.authorizationStatus(for: .audio) if micStatus == .denied || micStatus == .restricted { showMicrophonePermissionAlert(); isRecording = false; return } let speechStatus = SFSpeechRecognizer.authorizationStatus() if speechStatus == .denied || speechStatus == .restricted { showSpeechPermissionAlert(); isRecording = false; return } if speechStatus == .notDetermined { SFSpeechRecognizer.requestAuthorization { [weak self] status in DispatchQueue.main.async { self?.isRecording = false guard status == .authorized else { self?.showSpeechPermissionAlert(); return } self?.startRecording() } } return } recordingPanel.showAnimated() let promptText: String = { switch Config.shared.triggerMode { case .input: return "Listening..." case .assist: return "Ask AI..." case .agent: return "Ask Agent..." } }() recordingPanel.updateText(promptText) let language = Config.shared.language.rawValue Task { @MainActor in let audioOK = audioMonitor.start() if audioOK { audioMonitor.onLevelUpdate = { [weak self] level in self?.recordingPanel.waveformView.updateLevel(level) } } let speechOK = await speechRecognizer.start(language: language) if !speechOK { recordingPanel.updateText("Recognition error") DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in self?.cancelRecording() } return } speechRecognizer.onResult = { [weak self] text in guard let self, isRecording else { return } finalTranscription = text recordingPanel.updateText(text) } speechRecognizer.onError = { error in let ns = error as NSError if ns.domain == "kAFAssistantErrorDomain", ns.code == 1110 { return } print("[App] Speech error: \(error)") } } } private func stopRecording() { // 打字模式:仅隐藏录音胶囊,刘海保持打开等待输入 if isTextInputActive { isTextInputActive = false recordingPanel.hideAnimated() return } guard isRecording else { return } isRecording = false audioMonitor.stop() _ = speechRecognizer.stop() let text = finalTranscription.trimmingCharacters(in: .whitespacesAndNewlines) guard !text.isEmpty else { recordingPanel.hideAnimated(); finalTranscription = ""; return } switch Config.shared.triggerMode { case .input: handleInputMode(text: text, config: Config.shared.inputLLM) case .assist: handleAssistMode(text: text, config: Config.shared.assistLLM) case .agent: if Config.shared.agentVoiceAutoSend { handleTextInput(text) } else { recordingPanel.hideAnimated() agentTracker.voiceInputText = text } } finalTranscription = "" } private func cancelRecording() { audioMonitor.stop() speechRecognizer.cancel() recordingPanel.hideAnimated() isRecording = false finalTranscription = "" } // MARK: - Input Mode private func handleInputMode(text: String, config: LLMConfig) { guard config.enabled, config.isConfigured else { recordingPanel.hideAnimated { TextInjector.inject(text) } return } recordingPanel.showRefining() Task { @MainActor in do { let result = try await llmRefiner.refine(text: text, config: config) recordingPanel.hideAnimated { TextInjector.inject(result.text) } } catch { print("[App] LLM refine error: \(error)") recordingPanel.hideAnimated { TextInjector.inject(text) } } } } // MARK: - Assist Mode private func handleAssistMode(text: String, config: LLMConfig) { guard config.enabled, config.isConfigured else { recordingPanel.updateText("AI 助手未配置") DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in self?.recordingPanel.hideAnimated() } return } recordingPanel.showThinking() Task { @MainActor in do { let result = try await llmRefiner.chat(text: text, config: config) if Config.shared.assistOutputMode == .notch { recordingPanel.hideAnimated() if Config.shared.assistSpeakEnabled { NotchDisplayManager.shared.show(text: result.text, autoHideAfter: nil) speakingMode = .assist speechSynthesizer.startSpeaking(result.text) } else { NotchDisplayManager.shared.show(text: result.text) } } else { recordingPanel.hideAnimated { TextInjector.inject(result.text) } } } catch { print("[App] LLM chat error: \(error)") recordingPanel.updateText("请求失败,请检查配置") DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in self?.recordingPanel.hideAnimated() } } } } // MARK: - Agent Mode private func handleAgentMode(text: String) { speakAutoHideWorkItem?.cancel() speechSynthesizer.stopSpeaking() speakingMode = nil let modelsFile = Config.shared.loadAgentModels() let validModels = modelsFile.models.filter { !$0.url.isEmpty && !$0.name.isEmpty && !$0.apikey.isEmpty && !$0.modes.isEmpty } let defaultModel = modelsFile.defaultModel.isEmpty ? validModels.first?.name : modelsFile.defaultModel guard !validModels.isEmpty, defaultModel != nil else { recordingPanel.updateText("智能体未配置") DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in self?.recordingPanel.hideAnimated() } return } recordingPanel.hideAnimated() // 新建对话(自动超限 或 手动点击) let needsNewConversation = agentTracker.autoNewTriggered || agentTracker.manualNewRequested if needsNewConversation { agentBridge.stop() agentActive = false agentTracker.autoNewTriggered = false agentTracker.manualNewRequested = false } // 如果对话已在进行中 → 追加消息 if agentActive { autoHideWorkItem?.cancel() agentTracker.start() showAgentNotchInternal(isTextInput: agentIsTextMode) agentBridge.sendContinue(message: text) return } agentActive = true agentIsTextMode = Config.shared.agentInputMode == "text" agentTracker.start() showAgentNotchInternal(isTextInput: agentIsTextMode) agentBridge.onEvent = { [weak self] event in DispatchQueue.main.async { self?.handleAgentEvent(event) } } agentBridge.onExit = { [weak self] code in DispatchQueue.main.async { guard let self = self else { return } self.agentLog("[App] onExit code=\(code) currentMode=\(self.agentTracker.mode)") self.agentActive = false self.agentIsTextMode = false if !self.agentExitingCleanly && code != 0 && self.agentTracker.mode != "done" { self.agentTracker.fail("进程异常退出 (code \(code))") } self.agentExitingCleanly = false } } guard agentBridge.start() else { agentActive = false agentTracker.fail("无法启动智能体进程") return } agentBridge.send(message: text, workspace: Config.shared.agentWorkspace, allowMode: Config.shared.agentAllowMode, modelKey: defaultModel!, newConversation: needsNewConversation) } private func hideAgentNotch() { agentNotch?.hide(ignoreMouse: true) agentNotch = nil } private func handleAgentEvent(_ event: AgentEvent) { switch event { case .status: break // no-op,内容切换由 toolPhaseActive 控制 case .toolStart(let tool, let display): agentLog("[App] handleEvent toolStart tool=\(tool)") agentTracker.addTool(tool, display: display) case .toolEnd(let tool, let resultLines): agentLog("[App] handleEvent toolEnd tool=\(tool) lines=\(resultLines.count)") agentTracker.updateToolResult(tool, resultLines: resultLines) case .assistantChunk(let content): agentTracker.appendChunk(content) case .done(let content, _, let totalTokens): agentLog("[App] handleEvent done contentLen=\(content.count) totalTokens=\(totalTokens)") agentTracker.finish(content: content, totalTokens: totalTokens) let willSpeak = Config.shared.agentSpeakEnabled && !content.isEmpty if willSpeak { speakingMode = .agent speechSynthesizer.startSpeaking(content) } autoHideWorkItem?.cancel() // 打字模式下,输入栏有内容时不自动收回;朗读模式下由朗读完成回调处理 if !agentIsTextMode && !willSpeak { let workItem = DispatchWorkItem { [weak self] in self?.hideAgentNotch() } autoHideWorkItem = workItem DispatchQueue.main.asyncAfter(deadline: .now() + 20, execute: workItem) } case .error(let message): agentLog("[App] handleEvent error message=\(message)") agentTracker.fail(message) case .reminderTriggered(let memoryId, let content): agentLog("[App] handleEvent reminderTriggered id=\(memoryId) content=\(content)") // bridge 快速通道已注入消息到对话,无需额外处理 } } /// 日志根目录(~/Library/Logs/VoiceInput/) private var logDir: String { let lib = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first! let dir = lib.appendingPathComponent("Logs/VoiceInput").path if !FileManager.default.fileExists(atPath: dir) { try? FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true) } return dir } private func agentLog(_ msg: String) { let logPath = logDir + "/agent_app.log" let ts = ISO8601DateFormatter().string(from: Date()) let line = "[\(ts)] \(msg)\n" if let handle = FileHandle(forWritingAtPath: logPath) { handle.seekToEndOfFile() handle.write(line.data(using: .utf8)!) handle.closeFile() } } /// Reminder 专用文件日志,写入 ~/Library/Logs/VoiceInput/reminder.log private func reminderLog(_ msg: String) { let logPath = logDir + "/reminder.log" let ts = ISO8601DateFormatter().string(from: Date()) let line = "[\(ts)] \(msg)\n" if let handle = FileHandle(forWritingAtPath: logPath) { handle.seekToEndOfFile() handle.write(line.data(using: .utf8)!) handle.closeFile() } else { // 文件不存在时创建 try? line.write(toFile: logPath, atomically: true, encoding: .utf8) } } private func showAgentNotch() { showAgentNotchInternal(isTextInput: false) } private func showTextInputNotch() { // 重置显示状态,但不进入 thinking(等用户发送后再开始) agentTracker.resetForInput() agentIsTextMode = true showAgentNotchInternal(isTextInput: true) } private func showAgentNotchInternal(isTextInput: Bool) { // 弹窗已存在 → 复用,只刷新显示(不覆盖已有状态) if agentNotch != nil { agentNotch?.show() return } agentTracker.isTextInputMode = isTextInput agentTracker.toolsMode = Config.shared.agentToolsMode agentTracker.newConversationMode = Config.shared.agentNewConversationMode // 创建新弹窗 let view = AgentNotchView( tracker: agentTracker, onCopy: { NSPasteboard.general.clearContents() NSPasteboard.general.setString(self.agentTracker.finalContent, forType: .string) }, onStop: { [weak self] in self?.speakingMode = nil self?.speakAutoHideWorkItem?.cancel() self?.speechSynthesizer.stopSpeaking() self?.autoHideWorkItem?.cancel() self?.agentExitingCleanly = true self?.agentBridge.stop() self?.hideAgentNotch() }, onClose: { [weak self] in self?.speakingMode = nil self?.speakAutoHideWorkItem?.cancel() self?.speechSynthesizer.stopSpeaking() self?.autoHideWorkItem?.cancel() self?.hideAgentNotch() }, onNewConversation: { [weak self] in self?.speakingMode = nil self?.speakAutoHideWorkItem?.cancel() self?.speechSynthesizer.stopSpeaking() self?.autoHideWorkItem?.cancel() self?.agentExitingCleanly = true self?.agentBridge.stop() self?.agentActive = false self?.agentIsTextMode = false self?.agentTracker.resetForInput() self?.agentTracker.manualNewRequested = true }, onSendText: { [weak self] text in self?.handleTextInput(text) }, onContentSizeChanged: { [weak self] in self?.agentNotch?.notifyContentChanged() } ) agentNotch = DynamicNotch(style: .notch) { view } agentNotch?.show() // 兜底:强制窗口置前(accessory 应用可能需要) if let window = agentNotch?.windowController?.window { window.orderFrontRegardless() window.makeKey() print("[Notch] 强制 orderFrontRegardless makeKey=\(window.isKeyWindow)") } print("[Notch] show() 完成 window=\(agentNotch?.windowController?.window != nil ? "存在" : "nil")") } private func handleTextInput(_ text: String) { handleAgentMode(text: text) } // MARK: - Reminders private func startReminderTimer() { reminderLog("startReminderTimer() 启动定时器,间隔30秒") reminderTimer = Timer.scheduledTimer(withTimeInterval: 30, repeats: true) { [weak self] _ in self?.checkReminders() } // 启动后延迟 3 秒首次检查(等 bridge 就绪) DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in self?.reminderLog("首次延迟检查 (3秒后)") self?.checkReminders() } } private func loadMemories() -> [[String: Any]] { let path = Config.shared.memoryPath guard FileManager.default.fileExists(atPath: path), let data = try? Data(contentsOf: URL(fileURLWithPath: path)), let json = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { return [] } return json } private func saveMemories(_ memories: [[String: Any]]) { let path = Config.shared.memoryPath let dir = (path as NSString).deletingLastPathComponent try? FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true) guard let data = try? JSONSerialization.data(withJSONObject: memories, options: .prettyPrinted) else { return } try? data.write(to: URL(fileURLWithPath: path), options: .atomic) } private func markMemoryReminded(_ memoryId: String) { var memories = loadMemories() guard let idx = memories.firstIndex(where: { ($0["id"] as? String) == memoryId }) else { return } memories[idx]["reminded"] = true saveMemories(memories) } private func checkReminders() { let memories = loadMemories() let now = Date() reminderLog("checkReminders() 开始: 共\(memories.count)条记忆, now=\(now)") let isoFormatter = ISO8601DateFormatter() isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] let isoFormatterNoMs = ISO8601DateFormatter() isoFormatterNoMs.formatOptions = [.withInternetDateTime] var foundDue = false for mem in memories { guard let type = mem["type"] as? String, type == "todo", let remindAtStr = mem["remind_at"] as? String, let content = mem["content"] as? String, let memoryId = mem["id"] as? String else { continue } var remindAt = isoFormatter.date(from: remindAtStr) if remindAt == nil { remindAt = isoFormatterNoMs.date(from: remindAtStr) } let reminded = (mem["reminded"] as? Bool) ?? false if remindAt == nil { let msg = "⚠️ 无法解析 remind_at: \(remindAtStr)" print("[Reminder] \(msg)") reminderLog("checkReminders \(msg)") continue } if remindAt! > now { print("[Reminder] 未到期 id=\(memoryId) remind=\(remindAtStr) now=\(now)") continue } if reminded { print("[Reminder] 已提醒 id=\(memoryId)") continue } if triggeredReminderIds.contains(memoryId) { print("[Reminder] 重复 id=\(memoryId)") continue } foundDue = true triggeredReminderIds.insert(memoryId) let df = DateFormatter() df.locale = Locale(identifier: "zh_CN") df.dateFormat = "yyyy年M月d日 H:mm" let timeStr = df.string(from: now) let reminderMsg = "[系统提醒] 现在是 \(timeStr),请提醒用户:\(content)" let msg = "✅ 触发提醒 id=\(memoryId)" print("[Reminder] \(msg)") reminderLog("checkReminders \(msg)") triggerReminder(message: reminderMsg, memoryId: memoryId) } if !foundDue && !memories.isEmpty { print("[Reminder] 检查完成,无到期提醒 (共\(memories.count)条)") } } private func triggerReminder(message: String, memoryId: String) { print("[Reminder] triggerReminder 开始 id=\(memoryId)") reminderLog("triggerReminder() 开始 id=\(memoryId) message=\(message.prefix(100))") DispatchQueue.main.async { [weak self] in guard let self = self else { return } let isNotchVisible = self.agentNotch?.windowController?.window?.isVisible ?? false print("[Reminder] 主线程 isNotchVisible=\(isNotchVisible) agentActive=\(self.agentActive)") self.reminderLog("triggerReminder 主线程 isNotchVisible=\(isNotchVisible) agentActive=\(self.agentActive)") // 销毁旧弹窗(如果存在) if self.agentNotch != nil { self.reminderLog("triggerReminder 销毁旧弹窗") self.agentNotch?.hide() self.agentNotch = nil } // 完全重置 UI 状态 self.agentTracker.resetForInput() self.agentTracker.start() self.agentTracker.isTextInputMode = false self.agentTracker.toolsMode = Config.shared.agentToolsMode self.agentTracker.newConversationMode = Config.shared.agentNewConversationMode if !isNotchVisible { // 弹窗不可见 → 弹出弹窗 print("[Reminder] 弹窗不可见,弹出") self.reminderLog("triggerReminder 弹出弹窗") self.showAgentNotchInternal(isTextInput: false) print("[Reminder] showAgentNotchInternal 完成") self.reminderLog("triggerReminder showAgentNotchInternal 完成") } if !self.agentActive { // 需要启动 bridge self.reminderLog("triggerReminder agentActive=false,即将启动 bridge") self.agentBridge.onEvent = { [weak self] event in DispatchQueue.main.async { self?.handleAgentEvent(event) } } self.agentBridge.onExit = { [weak self] code in DispatchQueue.main.async { self?.agentActive = false self?.agentIsTextMode = false } } guard self.agentBridge.start() else { let errMsg = "❌ agentBridge.start 失败" print("[Reminder] \(errMsg)") self.reminderLog("triggerReminder \(errMsg)") self.agentTracker.fail("无法启动智能体进程") return } print("[Reminder] bridge 已启动") self.reminderLog("triggerReminder bridge 启动成功") self.agentActive = true self.agentBridge.send(message: message, workspace: Config.shared.agentWorkspace, allowMode: Config.shared.agentAllowMode, modelKey: nil) self.reminderLog("triggerReminder 消息已发送到 bridge") } else { print("[Reminder] bridge 已在运行,追加消息") self.reminderLog("triggerReminder bridge 已在运行,追加消息") self.agentBridge.sendContinue(message: message) } print("[Reminder] 消息已发送") DispatchQueue.main.asyncAfter(deadline: .now() + 1) { self.markMemoryReminded(memoryId) self.reminderLog("triggerReminder 标记 memoryId=\(memoryId) 已提醒") } } } func applicationWillTerminate(_ notification: Notification) { speechSynthesizer.stopSpeaking() keyMonitor.stop() audioMonitor.stop() speechRecognizer.cancel() recordingPanel.close() } // MARK: - NSSpeechSynthesizerDelegate func speechSynthesizer(_ sender: NSSpeechSynthesizer, didFinishSpeaking finishedSpeaking: Bool) { guard finishedSpeaking else { return } DispatchQueue.main.async { [weak self] in guard let self, let mode = self.speakingMode else { return } self.speakingMode = nil // 朗读完成后等待 5 秒再带动画关闭弹窗 let workItem = DispatchWorkItem { [weak self] in switch mode { case .assist: NotchDisplayManager.shared.extendAutoHide(to: 0.1) case .agent: // 打字模式下不自动关闭,用户可能还在输入框中 if self?.agentIsTextMode != true { self?.agentNotch?.show(for: 0.1) } default: break } } self.speakAutoHideWorkItem = workItem DispatchQueue.main.asyncAfter(deadline: .now() + 5, execute: workItem) } } }