import Cocoa import AVFoundation import Speech import SwiftUI import DynamicNotchKit import UserNotifications /// 应用主控制器,协调 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) startReminderTimer() requestNotificationPermission() beginPermissionFlow() print("[App] Ready") } // MARK: - Permissions private var permissionGuideWindow: PermissionGuideWindow? private func beginPermissionFlow() { // 辅助功能权限已就绪 → 直接启动(输入监控可选,KeyMonitor 有回退) if PermissionGuideWindow.checkAccessibility() { startKeyMonitor() return } // 弹出自定义权限引导窗口(内含轮询) let window = PermissionGuideWindow { print("[Permission] ✅ All permissions granted") self.startKeyMonitor() } permissionGuideWindow = window window.makeKeyAndOrderFront(nil) } private func showMicrophonePermissionAlert() { NSWorkspace.shared.open(URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone")!) } private func showSpeechPermissionAlert() { 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() } } 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 /// 调试日志:写入项目根目录 agent_debug.log private func remLog(_ msg: String) { let ts = ISO8601DateFormatter().string(from: Date()) let line = "[\(ts)] \(msg)\n" print(line, terminator: "") // 写入文件 let logPath = (Config.shared.agentWorkspace as NSString).appendingPathComponent("agent_debug.log") if let data = line.data(using: .utf8) { if FileManager.default.fileExists(atPath: logPath) { if let fh = try? FileHandle(forWritingTo: URL(fileURLWithPath: logPath)) { fh.seekToEndOfFile() fh.write(data) fh.closeFile() } } else { try? data.write(to: URL(fileURLWithPath: logPath), options: .atomic) } } } private func startReminderTimer() { remLog("⏱ startReminderTimer 启动 10s 定时器") reminderTimer = Timer.scheduledTimer(withTimeInterval: 10, repeats: true) { [weak self] _ in self?.checkReminders() } // 启动后延迟 3 秒首次检查(等 bridge 就绪) DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in self?.remLog("⏱ 首次 checkReminders(启动后 3s)") 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 markTodoCompleted(_ memoryId: String) { remLog("✅ markTodoCompleted id=\(memoryId)") var memories = loadMemories() guard let idx = memories.firstIndex(where: { ($0["id"] as? String) == memoryId }) else { remLog("⚠️ markTodoCompleted 未找到 id=\(memoryId)") return } let nowISO = ISO8601DateFormatter().string(from: Date()) memories[idx]["completed"] = true memories[idx]["completed_at"] = nowISO memories[idx].removeValue(forKey: "remind_at") memories[idx].removeValue(forKey: "reminded") saveMemories(memories) remLog("✅ markTodoCompleted 完成 id=\(memoryId)") } private func checkReminders() { let memories = loadMemories() let now = Date() remLog("🔍 checkReminders 开始 memoryCount=\(memories.count) triggeredSetSize=\(triggeredReminderIds.count)") // 支持三种格式:带毫秒Z、带Z、不带时区(本地时间) let isoFormatterZ = ISO8601DateFormatter() isoFormatterZ.formatOptions = [.withInternetDateTime, .withFractionalSeconds] let isoFormatterZNoMs = ISO8601DateFormatter() isoFormatterZNoMs.formatOptions = [.withInternetDateTime] let localFormatter: DateFormatter = { let f = DateFormatter() f.dateFormat = "yyyy-MM-dd'T'HH:mm:ss" f.timeZone = TimeZone.current f.locale = Locale(identifier: "en_US_POSIX") return f }() var foundDue = false for (i, mem) in memories.enumerated() { 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 } let completed = (mem["completed"] as? Bool) ?? false if completed { remLog(" skip[\(i)] completed=true id=\(memoryId)") continue } var remindAt = isoFormatterZ.date(from: remindAtStr) if remindAt == nil { remindAt = isoFormatterZNoMs.date(from: remindAtStr) } if remindAt == nil { remindAt = localFormatter.date(from: remindAtStr) } let reminded = (mem["reminded"] as? Bool) ?? false if remindAt == nil { remLog(" ⚠️ [\(i)] 无法解析 remind_at: \(remindAtStr)") continue } if remindAt! > now { remLog(" ⏳ [\(i)] 未到期 id=\(memoryId) remind=\(remindAtStr)") continue } if reminded { remLog(" 🔕 [\(i)] 已提醒 id=\(memoryId)") continue } if triggeredReminderIds.contains(memoryId) { remLog(" 🔁 [\(i)] 重复触发 id=\(memoryId)") continue } foundDue = true triggeredReminderIds.insert(memoryId) remLog(" 🎯 [\(i)] 到期! id=\(memoryId) content=\(content)") let df = DateFormatter() df.locale = Locale(identifier: "zh_CN") df.dateFormat = "yyyy年M月d日 H:mm" let timeStr = df.string(from: now) remLog("🚀 调用 triggerReminder id=\(memoryId)") triggerReminder(content: content, memoryId: memoryId, timeStr: timeStr) } if !foundDue && !memories.isEmpty { remLog("✅ checkReminders 完成,无到期提醒") } else if memories.isEmpty { remLog("✅ checkReminders 完成,无记忆数据") } } private func requestNotificationPermission() { remLog("🔔 requestNotificationPermission 开始") UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { granted, error in if let error = error { self.remLog("🔔 通知权限请求失败: \(error.localizedDescription)") } else { self.remLog("🔔 通知权限 granted=\(granted)") } } } private func sendSystemNotification(content: String, memoryId: String) { remLog("📬 sendSystemNotification id=\(memoryId) content=\(content)") let notification = UNMutableNotificationContent() notification.title = "⏰ 待办提醒" notification.body = content notification.sound = .default let request = UNNotificationRequest( identifier: "reminder-\(memoryId)", content: notification, trigger: nil ) UNUserNotificationCenter.current().add(request) { error in if let error = error { self.remLog("📬 系统通知发送失败: \(error.localizedDescription)") } else { self.remLog("📬 系统通知已发送 id=\(memoryId)") } } // 同步检查当前通知设置 UNUserNotificationCenter.current().getNotificationSettings { settings in self.remLog("📬 通知设置 authorizationStatus=\(settings.authorizationStatus.rawValue) alert=\(settings.alertSetting.rawValue)") } } private func triggerReminder(content: String, memoryId: String, timeStr: String) { let mode = Config.shared.agentReminderMode remLog("🎬 triggerReminder id=\(memoryId) mode=\(mode.rawValue)(\(mode.displayName))") // 系统通知模式 if mode == .system || mode == .both { remLog(" → 走系统通知分支") sendSystemNotification(content: content, memoryId: memoryId) if mode == .system { remLog(" → 纯系统通知模式,1s 后 markTodoCompleted 并 return") DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in self?.markTodoCompleted(memoryId) } return } remLog(" → both 模式,系统通知已发,继续走 Agent 分支") } else { remLog(" → 走智能体通知分支") } // 智能体通知模式 let reminderMsg = "[系统自动发送的消息] 现在是 \(timeStr),请提醒用户:\(content)\n(待办ID:\(memoryId))" DispatchQueue.main.async { [weak self] in guard let self = self else { self?.remLog("❌ triggerReminder self 已释放"); return } let isNotchVisible = self.agentNotch?.windowController?.window?.isVisible ?? false self.remLog(" 📍 主线程 isNotchVisible=\(isNotchVisible) agentActive=\(self.agentActive)") // 销毁旧弹窗(如果存在) if self.agentNotch != nil { self.remLog(" 🗑 销毁旧弹窗") 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 { self.remLog(" 📤 弹窗不可见,调用 showAgentNotchInternal") self.showAgentNotchInternal(isTextInput: false) self.remLog(" 📤 showAgentNotchInternal 完成") } if !self.agentActive { self.remLog(" 🔌 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 { self.remLog("❌ agentBridge.start 失败") self.agentTracker.fail("无法启动智能体进程") return } self.remLog(" 🔌 bridge 已启动") self.agentActive = true self.agentBridge.send(message: reminderMsg, workspace: Config.shared.agentWorkspace, allowMode: Config.shared.agentAllowMode, modelKey: nil) self.remLog(" 📨 bridge.send 已调用") } else { self.remLog(" 🔌 bridge 已在运行,追加消息") self.agentBridge.sendContinue(message: reminderMsg) self.remLog(" 📨 bridge.sendContinue 已调用") } DispatchQueue.main.asyncAfter(deadline: .now() + 1) { self.markTodoCompleted(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) } } }