diff --git a/Agent/doc/tools.json b/Agent/doc/tools.json index 2327483..621e58d 100644 --- a/Agent/doc/tools.json +++ b/Agent/doc/tools.json @@ -295,7 +295,7 @@ }, "remind_at": { "type": "string", - "description": "提醒触发时间,ISO 8601 格式,例如 2026-05-01T14:45:00.000Z。仅 add 和 update 时有效。add 时可选,不提供则不设置提醒。update 时传 null 可取消已有提醒。" + "description": "提醒触发时间。设置提醒前必须先执行 date '+%Y-%m-%dT%H:%M:%S' 获取当前精确本地时间,再据此计算。使用本地时间不带时区后缀,格式如 2026-05-01T14:45:00,禁止使用带 Z 或 +08:00 的格式。仅 add 和 update 时有效。add 时可选,不提供则不设置提醒。update 时传 null 可取消已有提醒。" }, "keywords": { "type": "array", diff --git a/Agent/prompts/system.txt b/Agent/prompts/system.txt index 37dd0f3..0804064 100644 --- a/Agent/prompts/system.txt +++ b/Agent/prompts/system.txt @@ -146,7 +146,7 @@ set_identity 和 remember 的本质区别: 用户提到需要完成的任务时,使用 todo add。 - content 必须包含明确的绝对日期(如「2026年5月1日下午3点开会」),禁止使用「明天」「下周三」「月底」等相对时间。当前时间是 {current_time},请据此将相对时间转换为绝对日期。 -- 如果用户希望被提醒,设置 remind_at(ISO 8601 格式,例如 2026-05-01T14:45:00.000Z)。如果用户只说任务没要求提醒,不设置 remind_at。 +- 如果用户希望被提醒,设置 remind_at。如果用户只说任务没要求提醒,不设置 remind_at。 - 可以附加 keywords 便于后续搜索。 2) 何时完成待办(todo complete): diff --git a/Agent/src/tools/todo.js b/Agent/src/tools/todo.js index f7b6ab7..5202ca6 100644 --- a/Agent/src/tools/todo.js +++ b/Agent/src/tools/todo.js @@ -36,6 +36,11 @@ function generateId() { return crypto.randomUUID().slice(0, 8); } +function formatLocalISO(d) { + const pad = (n) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; +} + function todoTool(args) { const { action, content, id, remind_at, keywords } = args; @@ -50,7 +55,7 @@ function todoTool(args) { } const ts = new Date(remind_at).getTime(); if (isNaN(ts)) { - return { success: false, error: 'remind_at 必须是有效的 ISO 8601 时间字符串,例如 2026-05-01T14:45:00.000Z' }; + return { success: false, error: 'remind_at 必须是有效的 ISO 8601 时间字符串,例如 2026-05-01T14:45:00' }; } } @@ -73,7 +78,7 @@ function todoTool(args) { updated_at: now, }; if (remind_at !== undefined && remind_at !== null) { - entry.remind_at = new Date(remind_at).toISOString(); + entry.remind_at = formatLocalISO(new Date(remind_at)); entry.reminded = false; } memories.push(entry); @@ -139,7 +144,7 @@ function todoTool(args) { delete memories[idx].remind_at; delete memories[idx].reminded; } else { - memories[idx].remind_at = new Date(remind_at).toISOString(); + memories[idx].remind_at = formatLocalISO(new Date(remind_at)); memories[idx].reminded = false; } } diff --git a/Package.swift b/Package.swift index 68236a8..35a9457 100644 --- a/Package.swift +++ b/Package.swift @@ -18,7 +18,8 @@ let package = Package( .linkedFramework("Carbon"), .linkedFramework("CoreGraphics"), .linkedFramework("AppKit"), - .linkedFramework("Foundation") + .linkedFramework("Foundation"), + .linkedFramework("UserNotifications") ] ) ] diff --git a/Sources/App/AppDelegate.swift b/Sources/App/AppDelegate.swift index f39a82b..ca4619f 100644 --- a/Sources/App/AppDelegate.swift +++ b/Sources/App/AppDelegate.swift @@ -3,6 +3,7 @@ import AVFoundation import Speech import SwiftUI import DynamicNotchKit +import UserNotifications /// 应用主控制器,协调 Fn 键(长按录音)和 Control 键(双击切换模式) final class AppDelegate: NSObject, NSApplicationDelegate, NSSpeechSynthesizerDelegate { @@ -45,6 +46,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSSpeechSynthesizerDel func applicationDidFinishLaunching(_ notification: Notification) { NSApp.setActivationPolicy(.accessory) startReminderTimer() + requestNotificationPermission() beginPermissionFlow() print("[App] Ready") } @@ -493,12 +495,34 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSSpeechSynthesizerDel // 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() { - reminderTimer = Timer.scheduledTimer(withTimeInterval: 30, repeats: true) { [weak self] _ in + 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() } } @@ -522,27 +546,41 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSSpeechSynthesizerDel } private func markTodoCompleted(_ memoryId: String) { + remLog("✅ markTodoCompleted id=\(memoryId)") var memories = loadMemories() - guard let idx = memories.firstIndex(where: { ($0["id"] as? String) == memoryId }) else { return } + 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)") - let isoFormatter = ISO8601DateFormatter() - isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - let isoFormatterNoMs = ISO8601DateFormatter() - isoFormatterNoMs.formatOptions = [.withInternetDateTime] + // 支持三种格式:带毫秒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 mem in memories { + 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, @@ -550,56 +588,121 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSSpeechSynthesizerDel continue } - var remindAt = isoFormatter.date(from: remindAtStr) - if remindAt == nil { - remindAt = isoFormatterNoMs.date(from: remindAtStr) + 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 { - print("[Reminder] ⚠️ 无法解析 remind_at: \(remindAtStr)") + remLog(" ⚠️ [\(i)] 无法解析 remind_at: \(remindAtStr)") continue } if remindAt! > now { - print("[Reminder] 未到期 id=\(memoryId) remind=\(remindAtStr) now=\(now)") + remLog(" ⏳ [\(i)] 未到期 id=\(memoryId) remind=\(remindAtStr)") continue } if reminded { - print("[Reminder] 已提醒 id=\(memoryId)") + remLog(" 🔕 [\(i)] 已提醒 id=\(memoryId)") continue } if triggeredReminderIds.contains(memoryId) { - print("[Reminder] 重复 id=\(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) - let reminderMsg = "[系统自动发送的消息] 现在是 \(timeStr),请提醒用户:\(content)\n(待办ID:\(memoryId))" - print("[Reminder] ✅ 触发提醒 id=\(memoryId)") - triggerReminder(message: reminderMsg, memoryId: memoryId) + remLog("🚀 调用 triggerReminder id=\(memoryId)") + triggerReminder(content: content, memoryId: memoryId, timeStr: timeStr) } if !foundDue && !memories.isEmpty { - print("[Reminder] 检查完成,无到期提醒 (共\(memories.count)条)") + remLog("✅ checkReminders 完成,无到期提醒") + } else if memories.isEmpty { + remLog("✅ checkReminders 完成,无记忆数据") } } - private func triggerReminder(message: String, memoryId: String) { - print("[Reminder] triggerReminder 开始 id=\(memoryId)") + 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 { return } + guard let self = self else { self?.remLog("❌ triggerReminder self 已释放"); return } let isNotchVisible = self.agentNotch?.windowController?.window?.isVisible ?? false - print("[Reminder] 主线程 isNotchVisible=\(isNotchVisible) agentActive=\(self.agentActive)") + self.remLog(" 📍 主线程 isNotchVisible=\(isNotchVisible) agentActive=\(self.agentActive)") // 销毁旧弹窗(如果存在) if self.agentNotch != nil { + self.remLog(" 🗑 销毁旧弹窗") self.agentNotch?.hide() self.agentNotch = nil } @@ -612,14 +715,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSSpeechSynthesizerDel self.agentTracker.newConversationMode = Config.shared.agentNewConversationMode if !isNotchVisible { - // 弹窗不可见 → 弹出弹窗 - print("[Reminder] 弹窗不可见,弹出") + self.remLog(" 📤 弹窗不可见,调用 showAgentNotchInternal") self.showAgentNotchInternal(isTextInput: false) - print("[Reminder] showAgentNotchInternal 完成") + self.remLog(" 📤 showAgentNotchInternal 完成") } if !self.agentActive { - // 需要启动 bridge + self.remLog(" 🔌 bridge 未运行,启动中...") self.agentBridge.onEvent = { [weak self] event in DispatchQueue.main.async { self?.handleAgentEvent(event) } } @@ -630,18 +732,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSSpeechSynthesizerDel } } guard self.agentBridge.start() else { - print("[Reminder] ❌ agentBridge.start 失败") + self.remLog("❌ agentBridge.start 失败") self.agentTracker.fail("无法启动智能体进程") return } - print("[Reminder] bridge 已启动") + self.remLog(" 🔌 bridge 已启动") self.agentActive = true - self.agentBridge.send(message: message, workspace: Config.shared.agentWorkspace, allowMode: Config.shared.agentAllowMode, modelKey: nil) + self.agentBridge.send(message: reminderMsg, workspace: Config.shared.agentWorkspace, allowMode: Config.shared.agentAllowMode, modelKey: nil) + self.remLog(" 📨 bridge.send 已调用") } else { - print("[Reminder] bridge 已在运行,追加消息") - self.agentBridge.sendContinue(message: message) + self.remLog(" 🔌 bridge 已在运行,追加消息") + self.agentBridge.sendContinue(message: reminderMsg) + self.remLog(" 📨 bridge.sendContinue 已调用") } - print("[Reminder] 消息已发送") DispatchQueue.main.asyncAfter(deadline: .now() + 1) { self.markTodoCompleted(memoryId) diff --git a/Sources/UI/SettingsWindow.swift b/Sources/UI/SettingsWindow.swift index 74627bb..befdfbb 100644 --- a/Sources/UI/SettingsWindow.swift +++ b/Sources/UI/SettingsWindow.swift @@ -25,6 +25,9 @@ final class SettingsWindow: NSWindow { // Agent 模式专属:语音自动发送开关 private let voiceAutoSendCheckbox: NSButton + // Agent 模式专属:通知方式 + private let reminderModePopup: NSPopUpButton + // Agent 模式专属:助手名字/语气 private let assistantNameField: NSTextField private let assistantTonePopup: NSPopUpButton @@ -69,6 +72,7 @@ final class SettingsWindow: NSWindow { speakCheckbox = NSButton(checkboxWithTitle: "朗读 AI 回复", target: nil, action: nil) agentSpeakCheckbox = NSButton(checkboxWithTitle: "任务完成时朗读 AI 最终输出", target: nil, action: nil) voiceAutoSendCheckbox = NSButton(checkboxWithTitle: "语音输入后自动发送", target: nil, action: nil) + reminderModePopup = NSPopUpButton(frame: .zero, pullsDown: false) assistantNameField = NSTextField(frame: .zero) assistantTonePopup = NSPopUpButton(frame: .zero, pullsDown: false) userNameField = NSTextField(frame: .zero) @@ -87,7 +91,7 @@ final class SettingsWindow: NSWindow { editModelButton = NSButton(title: "编辑", target: nil, action: nil) deleteModelButton = NSButton(title: "删除", target: nil, action: nil) - let height: CGFloat = mode == .agent ? 860 : (mode == .assist ? 400 : 320) + let height: CGFloat = mode == .agent ? 890 : (mode == .assist ? 400 : 320) let width: CGFloat = mode == .agent ? 500 : 420 let windowRect = NSRect(x: 0, y: 0, width: width, height: height) super.init( @@ -424,6 +428,9 @@ final class SettingsWindow: NSWindow { let currentConvMode = Config.shared.agentNewConversationMode newConversationModePopup.selectItem(at: convModeMap.firstIndex(of: currentConvMode) ?? 0) autoNewTokenThresholdField.stringValue = String(Config.shared.agentAutoNewTokenThreshold) + let reminderModes = AgentReminderMode.allCases + let currentReminderMode = Config.shared.agentReminderMode + reminderModePopup.selectItem(at: reminderModes.firstIndex(of: currentReminderMode) ?? 0) } private func buildAgentUI() { @@ -493,6 +500,14 @@ final class SettingsWindow: NSWindow { voiceAutoSendCheckbox.target = self voiceAutoSendCheckbox.action = #selector(agentVoiceAutoSendToggled) + // 通知方式 + let reminderModeLabel = makeLabel("通知方式:") + reminderModePopup.translatesAutoresizingMaskIntoConstraints = false + reminderModePopup.addItems(withTitles: AgentReminderMode.allCases.map { $0.displayName }) + let reminderModes = AgentReminderMode.allCases + let currentReminderMode = Config.shared.agentReminderMode + reminderModePopup.selectItem(at: reminderModes.firstIndex(of: currentReminderMode) ?? 0) + // 助手名字 let assistantNameLabel = makeLabel("助手名字:") assistantNameField.placeholderString = "小助手" @@ -615,6 +630,8 @@ final class SettingsWindow: NSWindow { content.addSubview(autoNewTokenThresholdField) content.addSubview(agentSpeakCheckbox) content.addSubview(voiceAutoSendCheckbox) + content.addSubview(reminderModeLabel) + content.addSubview(reminderModePopup) content.addSubview(assistantNameLabel) content.addSubview(assistantNameField) content.addSubview(userNameLabel) @@ -689,9 +706,17 @@ final class SettingsWindow: NSWindow { voiceAutoSendCheckbox.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 113), voiceAutoSendCheckbox.topAnchor.constraint(equalTo: agentSpeakCheckbox.bottomAnchor, constant: 6), + // 通知方式 + reminderModeLabel.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 20), + reminderModeLabel.topAnchor.constraint(equalTo: voiceAutoSendCheckbox.bottomAnchor, constant: 14), + reminderModeLabel.widthAnchor.constraint(equalToConstant: 85), + reminderModePopup.leadingAnchor.constraint(equalTo: reminderModeLabel.trailingAnchor, constant: 8), + reminderModePopup.centerYAnchor.constraint(equalTo: reminderModeLabel.centerYAnchor), + reminderModePopup.widthAnchor.constraint(equalToConstant: 200), + // 助手名字 assistantNameLabel.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 20), - assistantNameLabel.topAnchor.constraint(equalTo: voiceAutoSendCheckbox.bottomAnchor, constant: 14), + assistantNameLabel.topAnchor.constraint(equalTo: reminderModeLabel.bottomAnchor, constant: 14), assistantNameLabel.widthAnchor.constraint(equalToConstant: 85), assistantNameField.leadingAnchor.constraint(equalTo: assistantNameLabel.trailingAnchor, constant: 8), assistantNameField.centerYAnchor.constraint(equalTo: assistantNameLabel.centerYAnchor), @@ -828,6 +853,12 @@ final class SettingsWindow: NSWindow { // 保存自动新建阈值 let thresholdVal = Int(autoNewTokenThresholdField.stringValue) ?? 40000 Config.shared.agentAutoNewTokenThreshold = max(1000, thresholdVal) + // 保存通知方式 + let reminderModes = AgentReminderMode.allCases + let reminderIdx = reminderModePopup.indexOfSelectedItem + if reminderIdx >= 0, reminderIdx < reminderModes.count { + Config.shared.agentReminderMode = reminderModes[reminderIdx] + } refreshAgentModelTable() statusLabel.stringValue = "✅ Agent 配置已保存" statusLabel.textColor = .systemGreen diff --git a/Sources/Utils/Config.swift b/Sources/Utils/Config.swift index c8d3203..32396a5 100644 --- a/Sources/Utils/Config.swift +++ b/Sources/Utils/Config.swift @@ -69,6 +69,20 @@ enum TriggerMode: String { case agent = "agent" // 智能体调用 } +enum AgentReminderMode: String, CaseIterable { + case agent = "agent" + case system = "system" + case both = "both" + + var displayName: String { + switch self { + case .agent: return "智能体通知" + case .system: return "系统通知" + case .both: return "同时通知" + } + } +} + enum AssistOutputMode: String, CaseIterable { case inject = "inject" // 直接输入到输入栏 case notch = "notch" // 刘海弹窗展示 @@ -306,6 +320,18 @@ final class Config { set { defaults.set(newValue, forKey: "agent_thinking_mode") } } + // Agent 待办提醒通知方式 + var agentReminderMode: AgentReminderMode { + get { + guard let raw = defaults.string(forKey: "agent_reminder_mode"), + let mode = AgentReminderMode(rawValue: raw) else { + return .agent + } + return mode + } + set { defaults.set(newValue.rawValue, forKey: "agent_reminder_mode") } + } + // MARK: - 助手个性化配置 var assistantName: String {