- 新增 todo 工具:add/complete/delete/update,共享 memory.json - remember 去掉 todo 类型和 remind_at 字段 - recall 对 todo 条目显示完成状态(未完成/完成于时间) - 提醒触发后自动标记完成 + 清理 remind_at - 提醒消息加上 todo ID 便于关联 - 更新 system prompt 拆分记忆/待办指导 - UI 显示适配:待办 新增/完成/删除/更新 · 内容 · 时间
687 lines
27 KiB
Swift
687 lines
27 KiB
Swift
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<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()
|
||
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
|
||
|
||
private func startReminderTimer() {
|
||
reminderTimer = Timer.scheduledTimer(withTimeInterval: 30, repeats: true) { [weak self] _ in
|
||
self?.checkReminders()
|
||
}
|
||
// 启动后延迟 3 秒首次检查(等 bridge 就绪)
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||
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) {
|
||
var memories = loadMemories()
|
||
guard let idx = memories.firstIndex(where: { ($0["id"] as? String) == memoryId }) else { 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)
|
||
}
|
||
|
||
private func checkReminders() {
|
||
let memories = loadMemories()
|
||
let now = Date()
|
||
|
||
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 {
|
||
print("[Reminder] ⚠️ 无法解析 remind_at: \(remindAtStr)")
|
||
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)\n(待办ID:\(memoryId))"
|
||
|
||
print("[Reminder] ✅ 触发提醒 id=\(memoryId)")
|
||
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)")
|
||
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)")
|
||
|
||
// 销毁旧弹窗(如果存在)
|
||
if self.agentNotch != nil {
|
||
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.showAgentNotchInternal(isTextInput: false)
|
||
print("[Reminder] showAgentNotchInternal 完成")
|
||
}
|
||
|
||
if !self.agentActive {
|
||
// 需要启动 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 {
|
||
print("[Reminder] ❌ agentBridge.start 失败")
|
||
self.agentTracker.fail("无法启动智能体进程")
|
||
return
|
||
}
|
||
print("[Reminder] bridge 已启动")
|
||
self.agentActive = true
|
||
self.agentBridge.send(message: message, workspace: Config.shared.agentWorkspace, allowMode: Config.shared.agentAllowMode, modelKey: nil)
|
||
} else {
|
||
print("[Reminder] bridge 已在运行,追加消息")
|
||
self.agentBridge.sendContinue(message: message)
|
||
}
|
||
print("[Reminder] 消息已发送")
|
||
|
||
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)
|
||
}
|
||
}
|
||
}
|