VoiceInput/Sources/App/AppDelegate.swift

790 lines
32 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
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<AgentNotchView>?
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<String>() //
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)")
// ZZ
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)
}
}
}