diff --git a/CMakeLists.txt b/CMakeLists.txt index 1738833..64507d5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -10,7 +10,7 @@ set(CMAKE_AUTOMOC OFF) set(CMAKE_AUTORCC ON) set(CMAKE_AUTOUIC ON) -find_package(Qt6 REQUIRED COMPONENTS Widgets Network) +find_package(Qt6 REQUIRED COMPONENTS Widgets Network Multimedia) qt_add_executable(QtDesktopPet main.cpp @@ -50,6 +50,22 @@ qt_add_executable(QtDesktopPet src/config/ConfigManager.cpp src/config/SecretStore.h src/config/SecretStore.cpp + src/notification/NotificationDispatcher.h + src/notification/NotificationDispatcher.cpp + src/reminder/ReminderCommandHandler.h + src/reminder/ReminderCommandHandler.cpp + src/reminder/ReminderManager.h + src/reminder/ReminderManager.cpp + src/reminder/ReminderParser.h + src/reminder/ReminderParser.cpp + src/reminder/ReminderSoundPlayer.h + src/reminder/ReminderSoundPlayer.cpp + src/reminder/ReminderSoundRepository.h + src/reminder/ReminderSoundRepository.cpp + src/reminder/ReminderStore.h + src/reminder/ReminderStore.cpp + src/reminder/ReminderTypes.h + src/reminder/ReminderTypes.cpp src/state/PetStateMachine.h src/state/PetStateMachine.cpp src/tray/TrayController.h @@ -79,6 +95,7 @@ target_compile_definitions(QtDesktopPet target_link_libraries(QtDesktopPet PRIVATE + Qt6::Multimedia Qt6::Network Qt6::Widgets ) diff --git a/README.md b/README.md index 7f21818..4fbbdee 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,9 @@ QtDesktopPet 是一个基于 Qt Widgets / C++17 的 Windows 桌面宠物项目 - Google Gemini 原生聊天请求 - 角色文件夹导入和角色切换 - 删除用户导入角色 +- 本地一次性提醒:聊天创建、查询、取消,重启后 pending 提醒不丢 +- 提醒到点气泡提示、拖动后延迟提示和隐藏时托盘通知 +- 提醒音效切换、试听、用户 wav 导入和删除 - Windows 发布打包脚本和 Inno Setup 安装器脚本 - Windows GUI 子系统,Release exe 双击不弹控制台窗口 @@ -50,6 +53,7 @@ QtDesktopPet 是一个基于 Qt Widgets / C++17 的 Windows 桌面宠物项目 - C++17 - Qt 6 Widgets - Qt 6 Network +- Qt 6 Multimedia - CMake - PNG 图片序列帧 - JSON 配置文件 @@ -145,6 +149,62 @@ resources/characters/shiroko/ - 隐藏到托盘时可释放非保护动画缓存 - `enableLazyLoad=false` 时仍保持启动阶段加载全部状态帧的兼容行为 +## 定时提醒和音效 + +当前支持通过聊天输入创建一次性本地提醒,例如: + +```text +10分钟后提醒我喝水 +半小时后提醒我休息 +一个半小时后提醒我喝水 +明天9点提醒我开会 +后天9点提醒我开会 +6月3日9点提醒我提交 +下周一上午10点提醒我周会 +提醒列表 +取消喝水提醒 +``` + +提醒数据保存到: + +```text +QStandardPaths::AppConfigLocation/reminders.json +``` + +提醒文件损坏时会备份为: + +```text +reminders.broken.yyyyMMdd-HHmmss.json +``` + +内置提醒音效位于: + +```text +resources/sounds/reminders/ +``` + +用户导入的提醒音效保存到: + +```text +QStandardPaths::AppDataLocation/sounds/reminders/ +``` + +音效规则: + +- 默认音效为 `reminder_default` +- 提醒触发时使用当前设置页选择的全局音效;修改音效后对所有未触发提醒立即生效 +- 内置音效可切换、可试听,但不能在设置页删除 +- 用户音效只支持导入 PCM wav +- 用户导入音效可切换、可试听、可删除 +- 删除当前用户音效后会回退到 `reminder_default` + +触发规则: + +- 桌宠可见时显示气泡,不发系统通知 +- 桌宠隐藏时发 Windows 托盘通知,不在下次显示时补气泡 +- 用户拖动中不打断 `drag`,拖动结束后显示气泡 +- 重复提醒尚未支持,包含“每天 / 每周 / 每月”等语义时会提示暂不支持 + ## 配置和日志 应用配置保存到 Qt 标准配置目录: @@ -197,6 +257,7 @@ QtDesktopPet.exe Qt runtime resources/characters/ resources/icons/ +resources/sounds/ LICENSE README.md ``` @@ -278,7 +339,7 @@ reports/perf/ docs/performance_stability_check.md ``` -发布包应排除 `tools/`、`docs/`、`reports/`、`build/`、`dist/`、`release_packages/` 和 `.git/`,只保留运行必需文件、`resources/characters/`、`resources/icons/`、`LICENSE` 和必要说明。 +发布包应排除 `tools/`、`docs/`、`reports/`、`build/`、`dist/`、`release_packages/` 和 `.git/`,只保留运行必需文件、`resources/characters/`、`resources/icons/`、`resources/sounds/`、`LICENSE` 和必要说明。 ## AI 配置和聊天 diff --git a/docs/QtDesktopPet_后续功能规划与结构审查.md b/docs/QtDesktopPet_后续功能规划与结构审查.md index 2c96e9f..2e978f4 100644 --- a/docs/QtDesktopPet_后续功能规划与结构审查.md +++ b/docs/QtDesktopPet_后续功能规划与结构审查.md @@ -30,6 +30,8 @@ - AI 请求取消和对话清空 - 角色文件夹导入和角色切换 - 删除用户导入角色 +- 本地一次性提醒、提醒列表、取消提醒和到点通知 +- 内置/用户提醒音效切换、导入、删除和试听 - Windows 打包脚本和 Inno Setup 安装器脚本 - Release exe 双击不弹控制台窗口 @@ -294,12 +296,12 @@ IP 定位隐私说明 阶段 5:语音对话 / 更复杂 Agent 能力 ``` -当前最推荐先做: +当前结构收口和定时提醒已经进入实现阶段。下一步最推荐继续做: ```text -1. IntentRouter / CommandDispatcher -2. 定时提醒 -3. 天气查询 +1. 天气查询 +2. 本地文件操作安全边界 +3. 联网搜索 ``` --- @@ -378,6 +380,20 @@ CommandDispatcher::dispatch(userText) 桌宠创建本地提醒,到点后气泡提示或托盘提示。 +当前实现状态: + +```text +已新增 src/reminder/ 模块 +已支持一次性提醒解析、JSON 持久化、启动后加载、到点触发和状态标记 +已支持聊天创建 / 查询 / 取消提醒 +已支持设置页按状态查看提醒、取消 pending 提醒、清理已触发/已取消历史 +已支持 reminder_default / reminder_soft 内置音效 +已支持用户 wav 音效导入、删除、切换和试听 +提醒触发时使用当前设置页选择的全局音效,ReminderItem.soundId 仅保留为历史兼容字段 +已接入 Qt Multimedia / QSoundEffect 播放提醒音效 +已预留 NotificationDispatcher,当前 Windows 仍由托盘通知承接 +``` + ## 5.2 第一版范围 要做: @@ -408,12 +424,18 @@ CommandDispatcher::dispatch(userText) ```text src/reminder/ ├── ReminderTypes.h + ├── ReminderCommandHandler.h + ├── ReminderCommandHandler.cpp ├── ReminderParser.h ├── ReminderParser.cpp ├── ReminderManager.h ├── ReminderManager.cpp ├── ReminderStore.h - └── ReminderStore.cpp + ├── ReminderStore.cpp + ├── ReminderSoundRepository.h + ├── ReminderSoundRepository.cpp + ├── ReminderSoundPlayer.h + └── ReminderSoundPlayer.cpp ``` ## 5.4 数据结构建议 @@ -425,8 +447,9 @@ struct ReminderItem QString title; QString originalText; QDateTime remindAt; - bool triggered = false; + ReminderStatus status = ReminderStatus::Pending; QDateTime createdAt; + QString soundId; // 历史兼容字段,触发时不再读取 }; ``` @@ -448,8 +471,9 @@ QStandardPaths::AppConfigLocation/reminders.json "title": "提交作业", "originalText": "晚上8点提醒我提交作业", "remindAt": "2026-06-01T20:00:00", - "triggered": false, - "createdAt": "2026-06-01T15:20:00" + "status": "pending", + "createdAt": "2026-06-01T15:20:00", + "soundId": "" } ] } @@ -475,13 +499,21 @@ reminders.broken.yyyyMMdd-HHmmss.json 下午3点 明天9点 明天上午10点 +后天9点 +今天下午3点 +6月3日9点 +6/3 09:00 +下周一上午10点 10分钟后 半小时后 +一个半小时后 +一小时后 1小时后 +两小时后 2小时后 ``` -如果规则解析失败,后续可以再接 AI 解析。 +如果规则解析失败,后续可以再接 AI 解析。包含“每天 / 每周 / 每月 / 工作日 / 重复”等语义时,当前只返回“重复提醒尚未支持”,不创建一次性提醒。 ## 5.7 AI 辅助解析的设计边界 @@ -524,9 +556,9 @@ userText 建议行为: ```text -桌宠可见:显示 ChatBubble + 切 talk 或 happy -桌宠隐藏:系统托盘通知 -用户拖动中:不打断 drag,拖动结束后显示 +桌宠可见:播放当前全局音效,显示 ChatBubble + 切 happy,无 happy 时回退 talk,不发 Windows 通知 +桌宠隐藏:播放当前全局音效,触发 Windows 托盘通知,不在下次显示时补气泡 +用户拖动中:播放当前全局音效,不打断 drag,拖动结束后显示气泡,不发 Windows 通知 ``` 提醒文案: @@ -988,12 +1020,8 @@ CustomSearchProvider ## 9.2 第二步:定时提醒 ```text -1. ReminderTypes -2. ReminderStore -3. ReminderParser -4. ReminderManager -5. 到点气泡和托盘通知 -6. 设置页增加提醒列表,可后置 +当前已落地一次性提醒、提醒列表、取消提醒、到点气泡/托盘通知和提醒音效管理。 +后续可继续补确认/稍后提醒、重复提醒和跨平台通知实现。 ``` ## 9.3 第三步:天气查询 @@ -1066,8 +1094,8 @@ CustomSearchProvider 其中: ```text -定时提醒:最适合第一个落地 -天气:第二个落地 +定时提醒:已作为第一个工具能力落地 +天气:建议第二个落地 本地文件操作:风险较高,第三个落地 联网搜索:通用能力,最后落地 ``` diff --git a/docs/Qt_DesktopPet_开发文档.md b/docs/Qt_DesktopPet_开发文档.md index c8cc519..647e915 100644 --- a/docs/Qt_DesktopPet_开发文档.md +++ b/docs/Qt_DesktopPet_开发文档.md @@ -1408,7 +1408,7 @@ Windows 下不能只拷贝 exe。 1. 用户手动完成 Release 构建 2. 运行 tools/package_release.ps1,传入 QtDesktopPet.exe 路径 3. 脚本调用 windeployqt 收集 Qt 运行库 -4. 脚本复制 resources/characters、resources/icons、LICENSE、README.md +4. 脚本复制 resources/characters、resources/icons、resources/sounds、LICENSE、README.md 5. 脚本生成 dist/QtDesktopPet--windows-x64.zip 6. 需要安装器时,脚本优先查找 D:\Inno Setup 7\ISCC.exe,并调用 ISCC 编译 installer/QtDesktopPet.iss 7. 安装器默认最终输出到项目根目录 diff --git a/main.cpp b/main.cpp index e86c8ec..faee784 100644 --- a/main.cpp +++ b/main.cpp @@ -76,6 +76,9 @@ int main(int argc, char *argv[]) TrayController trayController(&window); window.setSettingsFallbackInContextMenuEnabled(!trayController.isAvailable()); + window.setTrayNotificationCallback([&trayController](const QString &title, const QString &message) { + trayController.showNotification(title, message); + }); trayController.show(); QObject::connect(&singleInstanceServer, &QLocalServer::newConnection, [&singleInstanceServer, &window]() { diff --git a/resources/sounds/reminders/reminder_default.wav b/resources/sounds/reminders/reminder_default.wav new file mode 100644 index 0000000..cc0e413 Binary files /dev/null and b/resources/sounds/reminders/reminder_default.wav differ diff --git a/resources/sounds/reminders/reminder_soft.wav b/resources/sounds/reminders/reminder_soft.wav new file mode 100644 index 0000000..ab643ff Binary files /dev/null and b/resources/sounds/reminders/reminder_soft.wav differ diff --git a/src/assistant/CommandDispatcher.cpp b/src/assistant/CommandDispatcher.cpp index 5fd1713..d18a09e 100644 --- a/src/assistant/CommandDispatcher.cpp +++ b/src/assistant/CommandDispatcher.cpp @@ -27,6 +27,11 @@ CommandDispatchResult CommandDispatcher::dispatch(const QString &text) const return {CommandDispatchAction::Chat, intent, intent.text}; } + if (intent.type == UserIntentType::Reminder) + { + return {CommandDispatchAction::Reminder, intent, intent.text}; + } + return {CommandDispatchAction::UnsupportedTool, intent, unsupportedToolMessage(intent.type)}; } diff --git a/src/assistant/CommandDispatcher.h b/src/assistant/CommandDispatcher.h index 51cfea3..8ae4b75 100644 --- a/src/assistant/CommandDispatcher.h +++ b/src/assistant/CommandDispatcher.h @@ -7,6 +7,7 @@ enum class CommandDispatchAction { Chat, + Reminder, UnsupportedTool, }; diff --git a/src/config/AppConfig.h b/src/config/AppConfig.h index 33b413b..b5e1fd3 100644 --- a/src/config/AppConfig.h +++ b/src/config/AppConfig.h @@ -16,6 +16,9 @@ struct AppConfig int animationCacheLimitMb = 180; bool unloadAnimationsWhenHidden = true; QString characterId = QStringLiteral("shiroko"); + QString reminderSoundId = QStringLiteral("reminder_default"); + bool reminderSoundEnabled = true; + double reminderSoundVolume = 0.8; int requestContextMessageLimit = 12; int memoryHistoryMessageLimit = 200; bool saveConversationHistory = false; diff --git a/src/config/ConfigManager.cpp b/src/config/ConfigManager.cpp index 00c1821..912fc3e 100644 --- a/src/config/ConfigManager.cpp +++ b/src/config/ConfigManager.cpp @@ -11,6 +11,7 @@ #include #include #include +#include namespace { @@ -57,6 +58,15 @@ QJsonObject characterObjectFromConfig(const AppConfig &config) return character; } +QJsonObject reminderObjectFromConfig(const AppConfig &config) +{ + QJsonObject reminder; + reminder.insert(QStringLiteral("soundId"), config.reminderSoundId); + reminder.insert(QStringLiteral("soundEnabled"), config.reminderSoundEnabled); + reminder.insert(QStringLiteral("soundVolume"), qBound(0.0, config.reminderSoundVolume, 1.0)); + return reminder; +} + QString normalizedProviderName(const QString &provider) { const QString normalized = provider.trimmed().toLower(); @@ -258,6 +268,26 @@ AppConfig ConfigManager::loadAppConfig() const config.characterId = character.value(QStringLiteral("id")).toString(config.characterId).trimmed(); } + const QJsonObject reminder = root.value(QStringLiteral("reminder")).toObject(); + if (reminder.contains(QStringLiteral("soundId"))) + { + config.reminderSoundId = reminder.value(QStringLiteral("soundId")).toString(config.reminderSoundId).trimmed(); + if (config.reminderSoundId.isEmpty()) + { + config.reminderSoundId = QStringLiteral("reminder_default"); + } + } + + if (reminder.contains(QStringLiteral("soundEnabled"))) + { + config.reminderSoundEnabled = reminder.value(QStringLiteral("soundEnabled")).toBool(config.reminderSoundEnabled); + } + + if (reminder.contains(QStringLiteral("soundVolume"))) + { + config.reminderSoundVolume = qBound(0.0, reminder.value(QStringLiteral("soundVolume")).toDouble(config.reminderSoundVolume), 1.0); + } + return config; } @@ -346,6 +376,7 @@ bool ConfigManager::saveAppConfig(const AppConfig &config) const root.insert(QStringLiteral("performance"), performanceObjectFromConfig(config)); root.insert(QStringLiteral("chat"), chatObjectFromConfig(config)); root.insert(QStringLiteral("character"), characterObjectFromConfig(config)); + root.insert(QStringLiteral("reminder"), reminderObjectFromConfig(config)); QFile file(appConfigPath()); if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) diff --git a/src/notification/NotificationDispatcher.cpp b/src/notification/NotificationDispatcher.cpp new file mode 100644 index 0000000..ee84df3 --- /dev/null +++ b/src/notification/NotificationDispatcher.cpp @@ -0,0 +1,16 @@ +#include "NotificationDispatcher.h" + +#include + +void NotificationDispatcher::setShowCallback(ShowCallback callback) +{ + m_showCallback = std::move(callback); +} + +void NotificationDispatcher::showReminder(const QString &title, const QString &message) const +{ + if (m_showCallback) + { + m_showCallback(title, message); + } +} diff --git a/src/notification/NotificationDispatcher.h b/src/notification/NotificationDispatcher.h new file mode 100644 index 0000000..962b57a --- /dev/null +++ b/src/notification/NotificationDispatcher.h @@ -0,0 +1,17 @@ +#pragma once + +#include + +#include + +class NotificationDispatcher +{ +public: + using ShowCallback = std::function; + + void setShowCallback(ShowCallback callback); + void showReminder(const QString &title, const QString &message) const; + +private: + ShowCallback m_showCallback; +}; diff --git a/src/reminder/ReminderCommandHandler.cpp b/src/reminder/ReminderCommandHandler.cpp new file mode 100644 index 0000000..839101d --- /dev/null +++ b/src/reminder/ReminderCommandHandler.cpp @@ -0,0 +1,47 @@ +#include "ReminderCommandHandler.h" + +#include "ReminderManager.h" + +ReminderCommandResult ReminderCommandHandler::handle(const QString &text, ReminderManager &manager) +{ + const ReminderCommand command = manager.parseCommand(text); + switch (command.type) + { + case ReminderCommandType::Create: + { + ReminderItem item; + QString errorMessage; + if (!manager.createReminder(command.title, command.originalText, command.remindAt, &item, &errorMessage)) + { + return {false, errorMessage.isEmpty() ? QStringLiteral("创建提醒失败。") : errorMessage, {}}; + } + + return { + true, + QStringLiteral("已设置提醒:%1,时间:%2").arg(item.title, reminderDisplayTime(item.remindAt)), + item, + }; + } + case ReminderCommandType::List: + return {true, manager.pendingReminderSummary(), {}}; + case ReminderCommandType::Cancel: + { + ReminderItem item; + QString errorMessage; + if (!manager.cancelReminderByQuery(command.cancelQuery, &item, &errorMessage)) + { + return {false, errorMessage.isEmpty() ? QStringLiteral("取消提醒失败。") : errorMessage, {}}; + } + + return { + true, + QStringLiteral("已取消提醒:%1(%2)").arg(item.title, reminderDisplayTime(item.remindAt)), + item, + }; + } + case ReminderCommandType::Invalid: + return {false, command.errorMessage.isEmpty() ? QStringLiteral("没有识别到有效提醒命令。") : command.errorMessage, {}}; + } + + return {false, QStringLiteral("没有识别到有效提醒命令。"), {}}; +} diff --git a/src/reminder/ReminderCommandHandler.h b/src/reminder/ReminderCommandHandler.h new file mode 100644 index 0000000..d1deb50 --- /dev/null +++ b/src/reminder/ReminderCommandHandler.h @@ -0,0 +1,20 @@ +#pragma once + +#include "ReminderTypes.h" + +#include + +class ReminderManager; + +struct ReminderCommandResult +{ + bool success = false; + QString message; + ReminderItem item; +}; + +class ReminderCommandHandler +{ +public: + static ReminderCommandResult handle(const QString &text, ReminderManager &manager); +}; diff --git a/src/reminder/ReminderManager.cpp b/src/reminder/ReminderManager.cpp new file mode 100644 index 0000000..8f995e0 --- /dev/null +++ b/src/reminder/ReminderManager.cpp @@ -0,0 +1,337 @@ +#include "ReminderManager.h" + +#include "../util/Logger.h" + +#include +#include +#include +#include + +#include + +namespace +{ +constexpr qint64 MinimumTimerIntervalMs = 1000; +constexpr qint64 MaximumTimerIntervalMs = 24 * 60 * 60 * 1000; + +bool isPending(const ReminderItem &item) +{ + return item.status == ReminderStatus::Pending; +} + +bool textMatchesReminder(const ReminderItem &item, const QString &query) +{ + const QString normalizedQuery = query.trimmed(); + if (normalizedQuery.isEmpty()) + { + return false; + } + + return item.id.compare(normalizedQuery, Qt::CaseInsensitive) == 0 + || item.title.contains(normalizedQuery, Qt::CaseInsensitive) + || item.originalText.contains(normalizedQuery, Qt::CaseInsensitive); +} +} + +ReminderManager::ReminderManager() +{ + QObject::connect(&m_timer, &QTimer::timeout, [this]() { + processDueReminders(); + }); + m_timer.setSingleShot(true); + load(); +} + +void ReminderManager::start() +{ + if (m_started) + { + return; + } + + m_started = true; + processDueReminders(); +} + +void ReminderManager::setTriggeredCallback(TriggeredCallback callback) +{ + m_triggeredCallback = std::move(callback); +} + +QVector ReminderManager::allReminders() const +{ + return sortedReminders(m_items); +} + +QVector ReminderManager::pendingReminders() const +{ + QVector result; + for (const ReminderItem &item : m_items) + { + if (isPending(item)) + { + result.append(item); + } + } + return sortedReminders(result); +} + +ReminderCommand ReminderManager::parseCommand(const QString &text) const +{ + return m_parser.parse(text); +} + +bool ReminderManager::createReminder( + const QString &title, + const QString &originalText, + const QDateTime &remindAt, + ReminderItem *createdItem, + QString *errorMessage) +{ + if (!remindAt.isValid() || remindAt <= QDateTime::currentDateTime()) + { + if (errorMessage != nullptr) + { + *errorMessage = QStringLiteral("提醒时间必须晚于当前时间。"); + } + return false; + } + + ReminderItem item; + item.id = nextReminderId(); + item.title = title.trimmed().isEmpty() ? QStringLiteral("提醒") : title.trimmed(); + item.originalText = originalText; + item.remindAt = remindAt; + item.status = ReminderStatus::Pending; + item.createdAt = QDateTime::currentDateTime(); + item.soundId.clear(); + + m_items.append(item); + if (!save(errorMessage)) + { + m_items.removeLast(); + return false; + } + + if (createdItem != nullptr) + { + *createdItem = item; + } + + scheduleNextReminder(); + return true; +} + +bool ReminderManager::cancelReminder(const QString &id, QString *errorMessage) +{ + const QString normalizedId = id.trimmed(); + for (ReminderItem &item : m_items) + { + if (item.id == normalizedId && isPending(item)) + { + item.status = ReminderStatus::Canceled; + const bool saved = save(errorMessage); + if (!saved) + { + item.status = ReminderStatus::Pending; + } + scheduleNextReminder(); + return saved; + } + } + + if (errorMessage != nullptr) + { + *errorMessage = QStringLiteral("没有找到可取消的提醒。"); + } + return false; +} + +bool ReminderManager::cancelReminderByQuery(const QString &query, ReminderItem *canceledItem, QString *errorMessage) +{ + QVector matches; + for (int index = 0; index < m_items.size(); ++index) + { + if (isPending(m_items.at(index)) && textMatchesReminder(m_items.at(index), query)) + { + matches.append(index); + } + } + + if (matches.isEmpty()) + { + if (errorMessage != nullptr) + { + *errorMessage = QStringLiteral("没有找到匹配的待提醒事项。"); + } + return false; + } + + if (matches.size() > 1) + { + if (errorMessage != nullptr) + { + *errorMessage = QStringLiteral("找到多条匹配提醒,请在设置页中选择具体提醒取消。"); + } + return false; + } + + ReminderItem &item = m_items[matches.first()]; + item.status = ReminderStatus::Canceled; + + const bool saved = save(errorMessage); + if (!saved) + { + item.status = ReminderStatus::Pending; + } + else if (canceledItem != nullptr) + { + *canceledItem = item; + } + scheduleNextReminder(); + return saved; +} + +bool ReminderManager::clearFinishedReminders(QString *errorMessage) +{ + QVector previousItems = m_items; + for (int index = m_items.size() - 1; index >= 0; --index) + { + const ReminderStatus status = m_items.at(index).status; + if (status == ReminderStatus::Triggered || status == ReminderStatus::Canceled) + { + m_items.removeAt(index); + } + } + + if (m_items.size() == previousItems.size()) + { + return true; + } + + if (!save(errorMessage)) + { + m_items = previousItems; + return false; + } + + scheduleNextReminder(); + return true; +} + +QString ReminderManager::pendingReminderSummary() const +{ + const QVector reminders = pendingReminders(); + if (reminders.isEmpty()) + { + return QStringLiteral("当前没有待提醒事项。"); + } + + QStringList lines; + lines.append(QStringLiteral("当前待提醒:")); + for (const ReminderItem &item : reminders) + { + lines.append(QStringLiteral("%1:%2(%3)") + .arg(item.id, item.title, reminderDisplayTime(item.remindAt))); + } + return lines.join(QChar('\n')); +} + +void ReminderManager::load() +{ + QString errorMessage; + m_items = m_store.load(&errorMessage); + if (!errorMessage.isEmpty()) + { + Logger::warning(QStringLiteral("Failed to load reminders: ") + errorMessage); + } +} + +bool ReminderManager::save(QString *errorMessage) const +{ + return m_store.save(m_items, errorMessage); +} + +void ReminderManager::processDueReminders() +{ + const QDateTime now = QDateTime::currentDateTime(); + QVector triggeredItems; + QVector triggeredIndexes; + for (int index = 0; index < m_items.size(); ++index) + { + ReminderItem &item = m_items[index]; + if (isPending(item) && item.remindAt <= now) + { + item.status = ReminderStatus::Triggered; + triggeredItems.append(item); + triggeredIndexes.append(index); + } + } + + if (!triggeredItems.isEmpty()) + { + QString errorMessage; + if (!save(&errorMessage)) + { + Logger::warning(QStringLiteral("Failed to save triggered reminders: ") + errorMessage); + for (const int index : triggeredIndexes) + { + if (index >= 0 && index < m_items.size()) + { + m_items[index].status = ReminderStatus::Pending; + } + } + scheduleNextReminder(); + return; + } + + for (const ReminderItem &item : triggeredItems) + { + if (m_triggeredCallback) + { + m_triggeredCallback(item); + } + } + } + + scheduleNextReminder(); +} + +void ReminderManager::scheduleNextReminder() +{ + m_timer.stop(); + if (!m_started) + { + return; + } + + QDateTime nextReminderTime; + for (const ReminderItem &item : m_items) + { + if (!isPending(item)) + { + continue; + } + + if (!nextReminderTime.isValid() || item.remindAt < nextReminderTime) + { + nextReminderTime = item.remindAt; + } + } + + if (!nextReminderTime.isValid()) + { + return; + } + + const qint64 delayMs = QDateTime::currentDateTime().msecsTo(nextReminderTime); + const int timerInterval = static_cast(qBound(MinimumTimerIntervalMs, delayMs, MaximumTimerIntervalMs)); + m_timer.start(timerInterval); +} + +QString ReminderManager::nextReminderId() const +{ + const QString timestamp = QDateTime::currentDateTime().toString(QStringLiteral("yyyyMMddHHmmsszzz")); + const quint32 randomValue = QRandomGenerator::global()->bounded(10000U); + return QStringLiteral("reminder_%1_%2").arg(timestamp, QString::number(randomValue).rightJustified(4, QLatin1Char('0'))); +} diff --git a/src/reminder/ReminderManager.h b/src/reminder/ReminderManager.h new file mode 100644 index 0000000..f6eaa55 --- /dev/null +++ b/src/reminder/ReminderManager.h @@ -0,0 +1,49 @@ +#pragma once + +#include "ReminderParser.h" +#include "ReminderStore.h" + +#include +#include + +#include + +class ReminderManager +{ +public: + using TriggeredCallback = std::function; + + ReminderManager(); + + void start(); + void setTriggeredCallback(TriggeredCallback callback); + + QVector allReminders() const; + QVector pendingReminders() const; + ReminderCommand parseCommand(const QString &text) const; + + bool createReminder( + const QString &title, + const QString &originalText, + const QDateTime &remindAt, + ReminderItem *createdItem = nullptr, + QString *errorMessage = nullptr); + bool cancelReminder(const QString &id, QString *errorMessage = nullptr); + bool cancelReminderByQuery(const QString &query, ReminderItem *canceledItem = nullptr, QString *errorMessage = nullptr); + bool clearFinishedReminders(QString *errorMessage = nullptr); + QString pendingReminderSummary() const; + +private: + void load(); + bool save(QString *errorMessage = nullptr) const; + void processDueReminders(); + void scheduleNextReminder(); + QString nextReminderId() const; + + ReminderStore m_store; + ReminderParser m_parser; + QVector m_items; + QTimer m_timer; + TriggeredCallback m_triggeredCallback; + bool m_started = false; +}; diff --git a/src/reminder/ReminderParser.cpp b/src/reminder/ReminderParser.cpp new file mode 100644 index 0000000..f505ddb --- /dev/null +++ b/src/reminder/ReminderParser.cpp @@ -0,0 +1,484 @@ +#include "ReminderParser.h" + +#include +#include +#include +#include + +namespace +{ +bool containsAny(const QString &text, const QStringList &keywords) +{ + for (const QString &keyword : keywords) + { + if (text.contains(keyword)) + { + return true; + } + } + + return false; +} + +int chineseDigitValue(QChar value) +{ + if (value == QLatin1Char('0') || value == QChar(0x3007) || value == QStringLiteral("零").at(0)) + { + return 0; + } + if (value == QStringLiteral("一").at(0)) + { + return 1; + } + if (value == QStringLiteral("二").at(0) || value == QStringLiteral("两").at(0)) + { + return 2; + } + if (value == QStringLiteral("三").at(0)) + { + return 3; + } + if (value == QStringLiteral("四").at(0)) + { + return 4; + } + if (value == QStringLiteral("五").at(0)) + { + return 5; + } + if (value == QStringLiteral("六").at(0)) + { + return 6; + } + if (value == QStringLiteral("七").at(0)) + { + return 7; + } + if (value == QStringLiteral("八").at(0)) + { + return 8; + } + if (value == QStringLiteral("九").at(0)) + { + return 9; + } + + return -1; +} + +int parseSmallInteger(QString value) +{ + value = value.trimmed(); + value.remove(QStringLiteral("个")); + if (value.isEmpty()) + { + return -1; + } + + bool ok = false; + const int numericValue = value.toInt(&ok); + if (ok) + { + return numericValue; + } + + const int tenIndex = value.indexOf(QStringLiteral("十")); + if (tenIndex >= 0) + { + const QString left = value.left(tenIndex); + const QString right = value.mid(tenIndex + 1); + const int tens = left.isEmpty() ? 1 : parseSmallInteger(left); + const int ones = right.isEmpty() ? 0 : parseSmallInteger(right); + if (tens < 0 || ones < 0) + { + return -1; + } + return tens * 10 + ones; + } + + if (value.size() == 1) + { + return chineseDigitValue(value.at(0)); + } + + return -1; +} + +QString cleanedText(QString text) +{ + return text + .replace(QChar(0xff0c), QStringLiteral(" ")) + .replace(QChar(0x3002), QStringLiteral(" ")) + .replace(QChar(0xff1a), QStringLiteral(":")) + .replace(QChar(0xff1b), QStringLiteral(" ")) + .trimmed(); +} + +int adjustedHour(const QString &period, int hour) +{ + if (hour < 0) + { + return -1; + } + + if (period == QStringLiteral("下午") || period == QStringLiteral("晚上")) + { + return hour < 12 ? hour + 12 : hour; + } + + if (period == QStringLiteral("中午")) + { + return hour < 11 ? hour + 12 : hour; + } + + if (period == QStringLiteral("凌晨") && hour == 12) + { + return 0; + } + + return hour; +} + +int weekdayFromText(const QString &text) +{ + const QString normalized = text.trimmed(); + if (normalized == QStringLiteral("一") || normalized == QStringLiteral("1")) + { + return 1; + } + if (normalized == QStringLiteral("二") || normalized == QStringLiteral("2")) + { + return 2; + } + if (normalized == QStringLiteral("三") || normalized == QStringLiteral("3")) + { + return 3; + } + if (normalized == QStringLiteral("四") || normalized == QStringLiteral("4")) + { + return 4; + } + if (normalized == QStringLiteral("五") || normalized == QStringLiteral("5")) + { + return 5; + } + if (normalized == QStringLiteral("六") || normalized == QStringLiteral("6")) + { + return 6; + } + if (normalized == QStringLiteral("日") + || normalized == QStringLiteral("天") + || normalized == QStringLiteral("7")) + { + return 7; + } + + return -1; +} + +struct ReminderDateResolution +{ + QDate date; + bool explicitDate = false; +}; + +ReminderDateResolution resolveReminderDate(const QString &text, const QDate ¤tDate) +{ + QRegularExpressionMatch match; + + const QRegularExpression nextWeekExpression(QStringLiteral("下周\\s*([一二三四五六日天1-7])")); + match = nextWeekExpression.match(text); + if (match.hasMatch()) + { + const int targetWeekday = weekdayFromText(match.captured(1)); + if (targetWeekday > 0) + { + const int daysToNextMonday = 8 - currentDate.dayOfWeek(); + return {currentDate.addDays(daysToNextMonday + targetWeekday - 1), true}; + } + } + + const QRegularExpression monthDayExpression(QStringLiteral("(\\d{1,2})\\s*月\\s*(\\d{1,2})\\s*(?:日|号)?")); + match = monthDayExpression.match(text); + if (match.hasMatch()) + { + const int month = match.captured(1).toInt(); + const int day = match.captured(2).toInt(); + QDate date(currentDate.year(), month, day); + if (date.isValid() && date < currentDate) + { + date = date.addYears(1); + } + return {date, true}; + } + + const QRegularExpression slashDateExpression(QStringLiteral("(\\d{1,2})\\s*/\\s*(\\d{1,2})")); + match = slashDateExpression.match(text); + if (match.hasMatch()) + { + const int month = match.captured(1).toInt(); + const int day = match.captured(2).toInt(); + QDate date(currentDate.year(), month, day); + if (date.isValid() && date < currentDate) + { + date = date.addYears(1); + } + return {date, true}; + } + + if (text.contains(QStringLiteral("后天"))) + { + return {currentDate.addDays(2), true}; + } + + if (text.contains(QStringLiteral("明天"))) + { + return {currentDate.addDays(1), true}; + } + + if (text.contains(QStringLiteral("今天"))) + { + return {currentDate, true}; + } + + return {currentDate, false}; +} + +QString removeFirst(const QString &text, const QString &part) +{ + QString result = text; + const int index = result.indexOf(part); + if (index >= 0) + { + result.remove(index, part.size()); + } + return result; +} +} + +ReminderCommand ReminderParser::parse(const QString &text, const QDateTime &now) const +{ + const QString normalized = cleanedText(text); + if (normalized.isEmpty()) + { + return {ReminderCommandType::Invalid, {}, {}, {}, {}, QStringLiteral("提醒内容为空。")}; + } + + if (containsAny(normalized, {QStringLiteral("提醒列表"), QStringLiteral("查看提醒"), QStringLiteral("我的提醒"), QStringLiteral("有哪些提醒")})) + { + return {ReminderCommandType::List, {}, normalized}; + } + + if (normalized.contains(QStringLiteral("取消提醒")) || normalized.startsWith(QStringLiteral("取消"))) + { + QString query = normalized; + query.remove(QStringLiteral("取消提醒")); + if (query == normalized) + { + query.remove(QStringLiteral("取消")); + query.remove(QStringLiteral("提醒")); + } + query = query.trimmed(); + if (query.isEmpty()) + { + return {ReminderCommandType::Invalid, {}, normalized, {}, {}, QStringLiteral("请说明要取消哪条提醒。")}; + } + return {ReminderCommandType::Cancel, {}, normalized, {}, query}; + } + + return parseCreateCommand(normalized, now); +} + +ReminderCommand ReminderParser::parseCreateCommand(const QString &text, const QDateTime &now) const +{ + const QDate currentDate = now.date(); + + if (containsAny(text, { + QStringLiteral("每天"), + QStringLiteral("每日"), + QStringLiteral("每周"), + QStringLiteral("每星期"), + QStringLiteral("每月"), + QStringLiteral("每年"), + QStringLiteral("工作日"), + QStringLiteral("重复"), + })) + { + return {ReminderCommandType::Invalid, {}, text, {}, {}, QStringLiteral("重复提醒尚未支持,目前只能创建一次性提醒。")}; + } + + const QRegularExpression relativeMinutesExpression(QStringLiteral("([0-9]+|[一二两三四五六七八九十]+)\\s*分钟后")); + QRegularExpressionMatch match = relativeMinutesExpression.match(text); + if (match.hasMatch()) + { + const int minutes = parseSmallInteger(match.captured(1)); + if (minutes <= 0) + { + return {ReminderCommandType::Invalid, {}, text, {}, {}, QStringLiteral("提醒时间必须晚于当前时间。")}; + } + + const QString timeExpression = match.captured(0); + return { + ReminderCommandType::Create, + extractTitle(text, timeExpression), + text, + now.addSecs(minutes * 60), + {}, + {}, + }; + } + + const QRegularExpression relativeOneAndHalfHourExpression(QStringLiteral("([0-9]+|[一二两三四五六七八九十]+)\\s*(?:个)?半小时后")); + match = relativeOneAndHalfHourExpression.match(text); + if (match.hasMatch()) + { + const int hours = parseSmallInteger(match.captured(1)); + if (hours < 0) + { + return {ReminderCommandType::Invalid, {}, text, {}, {}, QStringLiteral("没有识别到有效提醒时间。")}; + } + + const QString timeExpression = match.captured(0); + return { + ReminderCommandType::Create, + extractTitle(text, timeExpression), + text, + now.addSecs(hours * 60 * 60 + 30 * 60), + {}, + {}, + }; + } + + if (text.contains(QStringLiteral("半小时后"))) + { + const QString timeExpression = QStringLiteral("半小时后"); + return { + ReminderCommandType::Create, + extractTitle(text, timeExpression), + text, + now.addSecs(30 * 60), + {}, + {}, + }; + } + + const QRegularExpression relativeHoursExpression(QStringLiteral("([0-9]+|[一二两三四五六七八九十]+)\\s*(?:个)?小时后")); + match = relativeHoursExpression.match(text); + if (match.hasMatch()) + { + const int hours = parseSmallInteger(match.captured(1)); + if (hours <= 0) + { + return {ReminderCommandType::Invalid, {}, text, {}, {}, QStringLiteral("提醒时间必须晚于当前时间。")}; + } + + const QString timeExpression = match.captured(0); + return { + ReminderCommandType::Create, + extractTitle(text, timeExpression), + text, + now.addSecs(hours * 60 * 60), + {}, + {}, + }; + } + + const ReminderDateResolution dateResolution = resolveReminderDate(text, currentDate); + if (!dateResolution.date.isValid()) + { + return {ReminderCommandType::Invalid, {}, text, {}, {}, QStringLiteral("没有识别到有效提醒日期。")}; + } + + const QRegularExpression clockExpression(QStringLiteral("(上午|早上|下午|晚上|中午|凌晨)?\\s*([0-9]{1,2}|[一二两三四五六七八九十]+)\\s*点\\s*(?:(\\d{1,2})\\s*分?)?")); + match = clockExpression.match(text); + if (match.hasMatch()) + { + const QString period = match.captured(1); + int hour = adjustedHour(period, parseSmallInteger(match.captured(2))); + const int minute = match.captured(3).isEmpty() ? 0 : match.captured(3).toInt(); + if (hour < 0 || hour > 23 || minute < 0 || minute > 59) + { + return {ReminderCommandType::Invalid, {}, text, {}, {}, QStringLiteral("没有识别到有效提醒时间。")}; + } + + QDateTime remindAt(dateResolution.date, QTime(hour, minute)); + if (remindAt <= now) + { + if (dateResolution.explicitDate) + { + return {ReminderCommandType::Invalid, {}, text, {}, {}, QStringLiteral("提醒时间必须晚于当前时间。")}; + } + remindAt = remindAt.addDays(1); + } + + return { + ReminderCommandType::Create, + extractTitle(text, match.captured(0)), + text, + remindAt, + {}, + {}, + }; + } + + const QRegularExpression colonClockExpression(QStringLiteral("(明天)?\\s*(?:在)?\\s*([01]?\\d|2[0-3])\\s*[::]\\s*([0-5]\\d)")); + match = colonClockExpression.match(text); + if (match.hasMatch()) + { + QDateTime remindAt(dateResolution.date, QTime(match.captured(2).toInt(), match.captured(3).toInt())); + if (remindAt <= now) + { + if (dateResolution.explicitDate) + { + return {ReminderCommandType::Invalid, {}, text, {}, {}, QStringLiteral("提醒时间必须晚于当前时间。")}; + } + remindAt = remindAt.addDays(1); + } + + return { + ReminderCommandType::Create, + extractTitle(text, match.captured(0)), + text, + remindAt, + {}, + {}, + }; + } + + return {ReminderCommandType::Invalid, {}, text, {}, {}, QStringLiteral("没有识别到提醒时间。支持如“10分钟后提醒我喝水”“明天9点提醒我开会”。")}; +} + +QString ReminderParser::extractTitle(QString text, const QString &timeExpression) const +{ + text = removeFirst(text, timeExpression); + const QStringList tokensToRemove = { + QStringLiteral("提醒我"), + QStringLiteral("提醒"), + QStringLiteral("叫我"), + QStringLiteral("到点"), + QStringLiteral("的时候"), + QStringLiteral("请"), + QStringLiteral("帮我"), + QStringLiteral("今天"), + QStringLiteral("明天"), + QStringLiteral("后天"), + }; + + for (const QString &token : tokensToRemove) + { + text.remove(token); + } + + text.remove(QRegularExpression(QStringLiteral("\\d{1,2}\\s*月\\s*\\d{1,2}\\s*(?:日|号)?"))); + text.remove(QRegularExpression(QStringLiteral("\\d{1,2}\\s*/\\s*\\d{1,2}"))); + text.remove(QRegularExpression(QStringLiteral("下周\\s*[一二三四五六日天1-7]"))); + + text = text.trimmed(); + while (text.startsWith(QChar(0x3000)) || text.startsWith(QLatin1Char(' ')) || text.startsWith(QLatin1Char(',')) || text.startsWith(QChar(0xff0c))) + { + text.remove(0, 1); + text = text.trimmed(); + } + + return text.isEmpty() ? QStringLiteral("提醒") : text; +} diff --git a/src/reminder/ReminderParser.h b/src/reminder/ReminderParser.h new file mode 100644 index 0000000..1b586fe --- /dev/null +++ b/src/reminder/ReminderParser.h @@ -0,0 +1,16 @@ +#pragma once + +#include "ReminderTypes.h" + +#include +#include + +class ReminderParser +{ +public: + ReminderCommand parse(const QString &text, const QDateTime &now = QDateTime::currentDateTime()) const; + +private: + ReminderCommand parseCreateCommand(const QString &text, const QDateTime &now) const; + QString extractTitle(QString text, const QString &timeExpression) const; +}; diff --git a/src/reminder/ReminderSoundPlayer.cpp b/src/reminder/ReminderSoundPlayer.cpp new file mode 100644 index 0000000..a228e16 --- /dev/null +++ b/src/reminder/ReminderSoundPlayer.cpp @@ -0,0 +1,22 @@ +#include "ReminderSoundPlayer.h" + +#include "ReminderSoundRepository.h" + +#include +#include +#include + +void ReminderSoundPlayer::play(const QString &soundId, double volume) +{ + const QString path = ReminderSoundRepository::soundPath(soundId); + if (!QFileInfo::exists(path)) + { + return; + } + + m_soundEffect.stop(); + m_soundEffect.setLoopCount(1); + m_soundEffect.setVolume(static_cast(qBound(0.0, volume, 1.0))); + m_soundEffect.setSource(QUrl::fromLocalFile(path)); + m_soundEffect.play(); +} diff --git a/src/reminder/ReminderSoundPlayer.h b/src/reminder/ReminderSoundPlayer.h new file mode 100644 index 0000000..8acf52f --- /dev/null +++ b/src/reminder/ReminderSoundPlayer.h @@ -0,0 +1,13 @@ +#pragma once + +#include +#include + +class ReminderSoundPlayer +{ +public: + void play(const QString &soundId, double volume); + +private: + QSoundEffect m_soundEffect; +}; diff --git a/src/reminder/ReminderSoundRepository.cpp b/src/reminder/ReminderSoundRepository.cpp new file mode 100644 index 0000000..3bdbb18 --- /dev/null +++ b/src/reminder/ReminderSoundRepository.cpp @@ -0,0 +1,350 @@ +#include "ReminderSoundRepository.h" + +#include "../util/ResourcePaths.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +constexpr qint64 MaxSoundFileBytes = 5 * 1024 * 1024; +constexpr int MaxSoundDurationSeconds = 30; + +QString appDataPath() +{ + const QString path = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation); + return path.isEmpty() ? QDir::currentPath() : path; +} + +QString sanitizedSoundId(QString value) +{ + value = value.trimmed().toLower(); + value.replace(QRegularExpression(QStringLiteral("[^a-z0-9._-]+")), QStringLiteral("_")); + while (value.startsWith(QLatin1Char('.')) || value.startsWith(QLatin1Char('_')) || value.startsWith(QLatin1Char('-'))) + { + value.remove(0, 1); + } + while (value.endsWith(QLatin1Char('.')) || value.endsWith(QLatin1Char('_')) || value.endsWith(QLatin1Char('-'))) + { + value.chop(1); + } + return value.isEmpty() ? QStringLiteral("reminder_sound") : value; +} + +QString displayNameForBuiltInSound(const QString &id) +{ + if (id == QStringLiteral("reminder_default")) + { + return QStringLiteral("默认提醒"); + } + + if (id == QStringLiteral("reminder_soft")) + { + return QStringLiteral("柔和提醒"); + } + + return id; +} + +ReminderSoundInfo builtInSound(const QString &id) +{ + return { + id, + displayNameForBuiltInSound(id), + ResourcePaths::reminderSoundsRootPath() + QLatin1Char('/') + id + QStringLiteral(".wav"), + true, + }; +} + +bool readChunkHeader(QDataStream &stream, QByteArray *id, quint32 *size) +{ + char chunkId[4] = {}; + if (stream.readRawData(chunkId, 4) != 4) + { + return false; + } + + *id = QByteArray(chunkId, 4); + stream >> *size; + return stream.status() == QDataStream::Ok; +} +} + +QString ReminderSoundRepository::defaultSoundId() +{ + return QStringLiteral("reminder_default"); +} + +QVector ReminderSoundRepository::availableSounds() +{ + QVector sounds; + const QStringList builtInIds = { + QStringLiteral("reminder_default"), + QStringLiteral("reminder_soft"), + }; + + for (const QString &id : builtInIds) + { + const ReminderSoundInfo info = builtInSound(id); + if (QFileInfo::exists(info.path)) + { + sounds.append(info); + } + } + + QDir userRoot(userSoundsRootPath()); + const QFileInfoList userFiles = userRoot.entryInfoList({QStringLiteral("*.wav")}, QDir::Files, QDir::Name); + for (const QFileInfo &fileInfo : userFiles) + { + const QString id = fileInfo.completeBaseName(); + if (isBuiltInSound(id)) + { + continue; + } + + sounds.append({ + id, + id, + QDir::cleanPath(fileInfo.absoluteFilePath()), + false, + }); + } + + return sounds; +} + +ReminderSoundInfo ReminderSoundRepository::soundInfo(const QString &soundId) +{ + const QString normalizedId = soundId.trimmed().isEmpty() ? defaultSoundId() : soundId.trimmed(); + for (const ReminderSoundInfo &info : availableSounds()) + { + if (info.id == normalizedId) + { + return info; + } + } + + return builtInSound(defaultSoundId()); +} + +QString ReminderSoundRepository::soundPath(const QString &soundId) +{ + return soundInfo(soundId).path; +} + +QString ReminderSoundRepository::userSoundsRootPath() +{ + return QDir(appDataPath()).filePath(QStringLiteral("sounds/reminders")); +} + +bool ReminderSoundRepository::isBuiltInSound(const QString &soundId) +{ + return soundId == QStringLiteral("reminder_default") + || soundId == QStringLiteral("reminder_soft"); +} + +bool ReminderSoundRepository::importSoundFile(const QString &sourcePath, QString *importedSoundId, QString *errorMessage) +{ + const QFileInfo sourceInfo(sourcePath); + if (!sourceInfo.exists() || !sourceInfo.isFile()) + { + if (errorMessage != nullptr) + { + *errorMessage = QStringLiteral("音效文件不存在。"); + } + return false; + } + + if (sourceInfo.suffix().compare(QStringLiteral("wav"), Qt::CaseInsensitive) != 0) + { + if (errorMessage != nullptr) + { + *errorMessage = QStringLiteral("只支持导入 wav 音效。"); + } + return false; + } + + if (!validateWaveFile(sourceInfo.absoluteFilePath(), errorMessage)) + { + return false; + } + + QDir userRoot(userSoundsRootPath()); + if (!userRoot.exists() && !userRoot.mkpath(QStringLiteral("."))) + { + if (errorMessage != nullptr) + { + *errorMessage = QStringLiteral("无法创建用户音效目录。"); + } + return false; + } + + QString id = sanitizedSoundId(sourceInfo.completeBaseName()); + if (isBuiltInSound(id)) + { + id.prepend(QStringLiteral("user_")); + } + + QString targetPath = userRoot.filePath(id + QStringLiteral(".wav")); + int suffix = 2; + while (QFileInfo::exists(targetPath)) + { + targetPath = userRoot.filePath(id + QStringLiteral("_") + QString::number(suffix) + QStringLiteral(".wav")); + ++suffix; + } + + const QString finalId = QFileInfo(targetPath).completeBaseName(); + if (!QFile::copy(sourceInfo.absoluteFilePath(), targetPath)) + { + if (errorMessage != nullptr) + { + *errorMessage = QStringLiteral("复制音效文件失败。"); + } + return false; + } + + if (importedSoundId != nullptr) + { + *importedSoundId = finalId; + } + return true; +} + +bool ReminderSoundRepository::deleteUserSound(const QString &soundId, QString *errorMessage) +{ + const QString id = soundId.trimmed(); + if (id.isEmpty() || isBuiltInSound(id)) + { + if (errorMessage != nullptr) + { + *errorMessage = QStringLiteral("内置提醒音效不能删除。"); + } + return false; + } + + const QString path = QDir(userSoundsRootPath()).filePath(id + QStringLiteral(".wav")); + QFile file(path); + if (!file.exists()) + { + if (errorMessage != nullptr) + { + *errorMessage = QStringLiteral("没有找到可删除的用户音效。"); + } + return false; + } + + if (!file.remove()) + { + if (errorMessage != nullptr) + { + *errorMessage = QStringLiteral("删除用户音效失败。"); + } + return false; + } + + return true; +} + +bool ReminderSoundRepository::validateWaveFile(const QString &path, QString *errorMessage) +{ + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) + { + if (errorMessage != nullptr) + { + *errorMessage = QStringLiteral("无法读取音效文件。"); + } + return false; + } + + if (file.size() <= 44 || file.size() > MaxSoundFileBytes) + { + if (errorMessage != nullptr) + { + *errorMessage = QStringLiteral("音效文件大小不符合要求。"); + } + return false; + } + + QDataStream stream(&file); + stream.setByteOrder(QDataStream::LittleEndian); + + char riff[4] = {}; + char wave[4] = {}; + quint32 riffSize = 0; + if (stream.readRawData(riff, 4) != 4) + { + return false; + } + stream >> riffSize; + if (stream.readRawData(wave, 4) != 4 + || QByteArray(riff, 4) != QByteArray("RIFF", 4) + || QByteArray(wave, 4) != QByteArray("WAVE", 4)) + { + if (errorMessage != nullptr) + { + *errorMessage = QStringLiteral("不是有效的 RIFF/WAVE 文件。"); + } + return false; + } + + quint16 audioFormat = 0; + quint16 channels = 0; + quint32 sampleRate = 0; + quint16 bitsPerSample = 0; + quint32 dataSize = 0; + bool hasFmt = false; + bool hasData = false; + + while (!stream.atEnd()) + { + QByteArray chunkId; + quint32 chunkSize = 0; + if (!readChunkHeader(stream, &chunkId, &chunkSize)) + { + break; + } + + const qint64 chunkStart = file.pos(); + if (chunkId == QByteArray("fmt ", 4)) + { + quint32 byteRate = 0; + quint16 blockAlign = 0; + stream >> audioFormat >> channels >> sampleRate >> byteRate >> blockAlign >> bitsPerSample; + hasFmt = true; + } + else if (chunkId == QByteArray("data", 4)) + { + dataSize = chunkSize; + hasData = true; + } + + file.seek(chunkStart + chunkSize + (chunkSize % 2)); + } + + if (!hasFmt || !hasData || audioFormat != 1 || channels == 0 || sampleRate == 0 || bitsPerSample == 0 || dataSize == 0) + { + if (errorMessage != nullptr) + { + *errorMessage = QStringLiteral("音效文件格式不受支持。请使用 PCM wav。"); + } + return false; + } + + const double durationSeconds = static_cast(dataSize) / static_cast(sampleRate * channels * (bitsPerSample / 8.0)); + if (durationSeconds <= 0.0 || durationSeconds > MaxSoundDurationSeconds) + { + if (errorMessage != nullptr) + { + *errorMessage = QStringLiteral("音效时长不符合要求。"); + } + return false; + } + + return true; +} diff --git a/src/reminder/ReminderSoundRepository.h b/src/reminder/ReminderSoundRepository.h new file mode 100644 index 0000000..bb5e400 --- /dev/null +++ b/src/reminder/ReminderSoundRepository.h @@ -0,0 +1,26 @@ +#pragma once + +#include +#include + +struct ReminderSoundInfo +{ + QString id; + QString displayName; + QString path; + bool builtIn = false; +}; + +class ReminderSoundRepository +{ +public: + static QString defaultSoundId(); + static QVector availableSounds(); + static ReminderSoundInfo soundInfo(const QString &soundId); + static QString soundPath(const QString &soundId); + static QString userSoundsRootPath(); + static bool isBuiltInSound(const QString &soundId); + static bool importSoundFile(const QString &sourcePath, QString *importedSoundId = nullptr, QString *errorMessage = nullptr); + static bool deleteUserSound(const QString &soundId, QString *errorMessage = nullptr); + static bool validateWaveFile(const QString &path, QString *errorMessage = nullptr); +}; diff --git a/src/reminder/ReminderStore.cpp b/src/reminder/ReminderStore.cpp new file mode 100644 index 0000000..1a42ee8 --- /dev/null +++ b/src/reminder/ReminderStore.cpp @@ -0,0 +1,180 @@ +#include "ReminderStore.h" + +#include "../util/Logger.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +const QString ReminderStoreFileName = QStringLiteral("reminders.json"); + +QJsonObject reminderToObject(const ReminderItem &item) +{ + QJsonObject object; + object.insert(QStringLiteral("id"), item.id); + object.insert(QStringLiteral("title"), item.title); + object.insert(QStringLiteral("originalText"), item.originalText); + object.insert(QStringLiteral("remindAt"), item.remindAt.toString(Qt::ISODate)); + object.insert(QStringLiteral("status"), reminderStatusToString(item.status)); + object.insert(QStringLiteral("createdAt"), item.createdAt.toString(Qt::ISODate)); + object.insert(QStringLiteral("soundId"), item.soundId); + return object; +} + +ReminderItem reminderFromObject(const QJsonObject &object) +{ + ReminderItem item; + item.id = object.value(QStringLiteral("id")).toString().trimmed(); + item.title = object.value(QStringLiteral("title")).toString().trimmed(); + item.originalText = object.value(QStringLiteral("originalText")).toString(); + item.remindAt = QDateTime::fromString(object.value(QStringLiteral("remindAt")).toString(), Qt::ISODate); + item.status = reminderStatusFromString(object.value(QStringLiteral("status")).toString()); + item.createdAt = QDateTime::fromString(object.value(QStringLiteral("createdAt")).toString(), Qt::ISODate); + item.soundId = object.value(QStringLiteral("soundId")).toString().trimmed(); + return item; +} +} + +QVector ReminderStore::load(QString *errorMessage) const +{ + QFile file(storePath()); + if (!file.exists()) + { + return {}; + } + + if (!file.open(QIODevice::ReadOnly)) + { + if (errorMessage != nullptr) + { + *errorMessage = QStringLiteral("无法读取提醒文件。"); + } + return {}; + } + + QJsonParseError parseError; + const QJsonDocument document = QJsonDocument::fromJson(file.readAll(), &parseError); + if (parseError.error != QJsonParseError::NoError || !document.isObject()) + { + file.close(); + backupBrokenStore(storePath()); + if (errorMessage != nullptr) + { + *errorMessage = QStringLiteral("提醒文件损坏,已备份并使用空提醒列表。"); + } + return {}; + } + + QVector items; + const QJsonArray reminders = document.object().value(QStringLiteral("reminders")).toArray(); + for (const QJsonValue &value : reminders) + { + if (!value.isObject()) + { + continue; + } + + ReminderItem item = reminderFromObject(value.toObject()); + if (item.id.isEmpty() || !item.remindAt.isValid()) + { + continue; + } + + if (item.title.isEmpty()) + { + item.title = QStringLiteral("提醒"); + } + + if (!item.createdAt.isValid()) + { + item.createdAt = item.remindAt; + } + + items.append(item); + } + + return sortedReminders(items); +} + +bool ReminderStore::save(const QVector &items, QString *errorMessage) const +{ + QDir directory(configDirectoryPath()); + if (!directory.exists() && !directory.mkpath(QStringLiteral("."))) + { + if (errorMessage != nullptr) + { + *errorMessage = QStringLiteral("无法创建提醒配置目录。"); + } + return false; + } + + QJsonArray reminders; + for (const ReminderItem &item : items) + { + reminders.append(reminderToObject(item)); + } + + QJsonObject root; + root.insert(QStringLiteral("reminders"), reminders); + + QFile file(storePath()); + if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) + { + if (errorMessage != nullptr) + { + *errorMessage = QStringLiteral("无法写入提醒文件。"); + } + return false; + } + + const QJsonDocument document(root); + return file.write(document.toJson(QJsonDocument::Indented)) >= 0; +} + +QString ReminderStore::storePath() const +{ + return QDir(configDirectoryPath()).filePath(ReminderStoreFileName); +} + +QString ReminderStore::configDirectoryPath() const +{ + const QString path = QStandardPaths::writableLocation(QStandardPaths::AppConfigLocation); + return path.isEmpty() ? QDir::currentPath() : path; +} + +void ReminderStore::backupBrokenStore(const QString &filePath) const +{ + QFile file(filePath); + if (!file.exists()) + { + return; + } + + const QFileInfo fileInfo(filePath); + const QString timestamp = QDateTime::currentDateTime().toString(QStringLiteral("yyyyMMdd-HHmmss")); + QString backupPath = fileInfo.dir().filePath(QStringLiteral("reminders.broken.") + timestamp + QStringLiteral(".json")); + int suffix = 1; + while (QFile::exists(backupPath)) + { + backupPath = fileInfo.dir().filePath( + QStringLiteral("reminders.broken.") + + timestamp + + QStringLiteral("-") + + QString::number(suffix) + + QStringLiteral(".json")); + ++suffix; + } + + if (!file.rename(backupPath)) + { + Logger::warning(QStringLiteral("Failed to back up broken reminders file: ") + filePath); + } +} diff --git a/src/reminder/ReminderStore.h b/src/reminder/ReminderStore.h new file mode 100644 index 0000000..a932990 --- /dev/null +++ b/src/reminder/ReminderStore.h @@ -0,0 +1,18 @@ +#pragma once + +#include "ReminderTypes.h" + +#include +#include + +class ReminderStore +{ +public: + QVector load(QString *errorMessage = nullptr) const; + bool save(const QVector &items, QString *errorMessage = nullptr) const; + QString storePath() const; + +private: + QString configDirectoryPath() const; + void backupBrokenStore(const QString &filePath) const; +}; diff --git a/src/reminder/ReminderTypes.cpp b/src/reminder/ReminderTypes.cpp new file mode 100644 index 0000000..b3fd7f3 --- /dev/null +++ b/src/reminder/ReminderTypes.cpp @@ -0,0 +1,52 @@ +#include "ReminderTypes.h" + +#include + +QString reminderStatusToString(ReminderStatus status) +{ + switch (status) + { + case ReminderStatus::Pending: + return QStringLiteral("pending"); + case ReminderStatus::Triggered: + return QStringLiteral("triggered"); + case ReminderStatus::Canceled: + return QStringLiteral("canceled"); + } + + return QStringLiteral("pending"); +} + +ReminderStatus reminderStatusFromString(const QString &status) +{ + const QString normalized = status.trimmed().toLower(); + if (normalized == QStringLiteral("triggered")) + { + return ReminderStatus::Triggered; + } + + if (normalized == QStringLiteral("canceled")) + { + return ReminderStatus::Canceled; + } + + return ReminderStatus::Pending; +} + +QString reminderDisplayTime(const QDateTime &dateTime) +{ + return dateTime.toLocalTime().toString(QStringLiteral("yyyy-MM-dd HH:mm")); +} + +QVector sortedReminders(QVector reminders) +{ + std::sort(reminders.begin(), reminders.end(), [](const ReminderItem &left, const ReminderItem &right) { + if (left.remindAt == right.remindAt) + { + return left.createdAt < right.createdAt; + } + + return left.remindAt < right.remindAt; + }); + return reminders; +} diff --git a/src/reminder/ReminderTypes.h b/src/reminder/ReminderTypes.h new file mode 100644 index 0000000..89df221 --- /dev/null +++ b/src/reminder/ReminderTypes.h @@ -0,0 +1,46 @@ +#pragma once + +#include +#include +#include + +enum class ReminderStatus +{ + Pending, + Triggered, + Canceled, +}; + +enum class ReminderCommandType +{ + Create, + List, + Cancel, + Invalid, +}; + +struct ReminderItem +{ + QString id; + QString title; + QString originalText; + QDateTime remindAt; + ReminderStatus status = ReminderStatus::Pending; + QDateTime createdAt; + QString soundId; +}; + +struct ReminderCommand +{ + ReminderCommandType type = ReminderCommandType::Invalid; + QString title; + QString originalText; + QDateTime remindAt; + QString cancelQuery; + QString errorMessage; +}; + +QString reminderStatusToString(ReminderStatus status); +ReminderStatus reminderStatusFromString(const QString &status); +QString reminderDisplayTime(const QDateTime &dateTime); +QVector sortedReminders(QVector reminders); diff --git a/src/tray/TrayController.cpp b/src/tray/TrayController.cpp index 151e589..03fb3fe 100644 --- a/src/tray/TrayController.cpp +++ b/src/tray/TrayController.cpp @@ -62,6 +62,16 @@ void TrayController::show() m_trayIcon.show(); } +void TrayController::showNotification(const QString &title, const QString &message) +{ + if (!isAvailable() || !m_trayIcon.isVisible()) + { + return; + } + + m_trayIcon.showMessage(title, message, QSystemTrayIcon::Information, 10000); +} + void TrayController::createMenu() { QAction *showAction = m_menu.addAction(QStringLiteral("显示桌宠")); diff --git a/src/tray/TrayController.h b/src/tray/TrayController.h index fa91b7a..0f43d44 100644 --- a/src/tray/TrayController.h +++ b/src/tray/TrayController.h @@ -12,6 +12,7 @@ public: bool isAvailable() const; void show(); + void showNotification(const QString &title, const QString &message); private: void createMenu(); diff --git a/src/ui/PetWindow.cpp b/src/ui/PetWindow.cpp index 007555e..c644c46 100644 --- a/src/ui/PetWindow.cpp +++ b/src/ui/PetWindow.cpp @@ -7,6 +7,11 @@ #include "../character/CharacterPackageLoader.h" #include "../character/CharacterPackageRepository.h" #include "../config/ConfigManager.h" +#include "../notification/NotificationDispatcher.h" +#include "../reminder/ReminderCommandHandler.h" +#include "../reminder/ReminderManager.h" +#include "../reminder/ReminderSoundPlayer.h" +#include "../reminder/ReminderSoundRepository.h" #include "../util/Logger.h" #include "ChatBubble.h" #include "ChatHistoryPanel.h" @@ -37,6 +42,7 @@ #include #include +#include namespace { @@ -88,6 +94,8 @@ AppConfig normalizedAppConfig(AppConfig config) { config.characterId = CharacterPackageRepository::defaultCharacterId(); } + config.reminderSoundId = ReminderSoundRepository::soundInfo(config.reminderSoundId).id; + config.reminderSoundVolume = qBound(0.0, config.reminderSoundVolume, 1.0); return config; } @@ -156,6 +164,9 @@ PetWindow::PetWindow(QWidget *parent) , m_chatInputDialog(std::make_unique(MaxUserMessageLength, this)) , m_conversationManager(std::make_unique()) , m_conversationStore(std::make_unique(ConfigManager().conversationHistoryPath())) + , m_notificationDispatcher(std::make_unique()) + , m_reminderManager(std::make_unique()) + , m_reminderSoundPlayer(std::make_unique()) , m_petView(new PetView(this)) , m_dragging(false) , m_alwaysOnTop(true) @@ -203,6 +214,13 @@ PetWindow::PetWindow(QWidget *parent) return !window.isNull() && window->submitChatMessage(message); }); + m_reminderManager->setTriggeredCallback([window](const ReminderItem &item) { + if (!window.isNull()) + { + window->handleTriggeredReminder(item); + } + }); + loadInitialImage(); } @@ -321,11 +339,29 @@ void PetWindow::showBubbleMessage(const QString &message) void PetWindow::openSettingsDialog() { ConfigManager configManager; - SettingsDialog dialog(configManager.loadAIConfigStore(), currentAppConfig(), [this]() { - return isManualStateSwitchLocked(); - }, [this]() { - clearConversation(); - }, this); + SettingsDialog dialog( + configManager.loadAIConfigStore(), + currentAppConfig(), + m_reminderManager ? m_reminderManager->allReminders() : QVector(), + [this]() { + return isManualStateSwitchLocked(); + }, + [this]() { + clearConversation(); + }, + [this](const QString &reminderId, QString *errorMessage) { + return m_reminderManager && m_reminderManager->cancelReminder(reminderId, errorMessage); + }, + [this](QString *errorMessage) { + return m_reminderManager && m_reminderManager->clearFinishedReminders(errorMessage); + }, + [this](const QString &soundId, double volume) { + if (m_reminderSoundPlayer) + { + m_reminderSoundPlayer->play(soundId, volume); + } + }, + this); centerDialogOnScreen(&dialog, this); if (dialog.exec() != QDialog::Accepted) { @@ -374,6 +410,14 @@ void PetWindow::setSettingsFallbackInContextMenuEnabled(bool enabled) m_settingsFallbackInContextMenuEnabled = enabled; } +void PetWindow::setTrayNotificationCallback(std::function callback) +{ + if (m_notificationDispatcher) + { + m_notificationDispatcher->setShowCallback(std::move(callback)); + } +} + void PetWindow::contextMenuEvent(QContextMenuEvent *event) { resetBubbleAutoHideTimer(); @@ -455,12 +499,6 @@ void PetWindow::startChat() bool PetWindow::submitChatMessage(const QString &message) { - if (!m_conversationManager || m_conversationManager->isBusy()) - { - showBubbleMessage(QStringLiteral("AI 回复正在进行。")); - return false; - } - const QString normalizedMessage = message.trimmed(); if (normalizedMessage.isEmpty()) { @@ -476,6 +514,11 @@ bool PetWindow::submitChatMessage(const QString &message) CommandDispatcher dispatcher; const CommandDispatchResult result = dispatcher.dispatch(normalizedMessage); + if (result.action == CommandDispatchAction::Reminder) + { + return handleReminderChatMessage(result.message); + } + if (result.action == CommandDispatchAction::UnsupportedTool) { playState(QStringLiteral("talk"), false); @@ -486,6 +529,57 @@ bool PetWindow::submitChatMessage(const QString &message) return submitAiChatMessage(result.message); } +bool PetWindow::handleReminderChatMessage(const QString &message) +{ + if (!m_reminderManager) + { + playState(QStringLiteral("error"), false); + showBubbleMessage(QStringLiteral("提醒功能初始化失败。")); + return false; + } + + const ReminderCommandResult result = ReminderCommandHandler::handle( + message, + *m_reminderManager); + playState(result.success ? QStringLiteral("talk") : QStringLiteral("error"), false); + showBubbleMessage(result.message); + return result.success; +} + +void PetWindow::handleTriggeredReminder(const ReminderItem &item) +{ + if (m_appConfig.reminderSoundEnabled && m_reminderSoundPlayer) + { + m_reminderSoundPlayer->play(m_appConfig.reminderSoundId, m_appConfig.reminderSoundVolume); + } + + if (!isVisible()) + { + if (m_notificationDispatcher) + { + m_notificationDispatcher->showReminder(QStringLiteral("定时提醒"), QStringLiteral("到时间啦:%1").arg(item.title)); + } + return; + } + + if (m_dragging) + { + m_deferredTriggeredReminders.append(item); + return; + } + + showTriggeredReminder(item); +} + +void PetWindow::showTriggeredReminder(const ReminderItem &item) +{ + const QString reminderState = m_clips.contains(QStringLiteral("happy")) + ? QStringLiteral("happy") + : QStringLiteral("talk"); + playState(reminderState, false); + showBubbleMessage(QStringLiteral("到时间啦:%1").arg(item.title)); +} + bool PetWindow::submitAiChatMessage(const QString &message) { if (!m_conversationManager || m_conversationManager->isBusy()) @@ -814,6 +908,10 @@ void PetWindow::hideEvent(QHideEvent *event) void PetWindow::showEvent(QShowEvent *event) { QWidget::showEvent(event); + if (m_reminderManager) + { + m_reminderManager->start(); + } scheduleAnimationPrewarm(); } @@ -878,6 +976,12 @@ void PetWindow::mouseReleaseEvent(QMouseEvent *event) m_dragging = false; playResolvedState(m_stateMachine.endDrag(), false); scheduleAnimationPrewarm(); + const QVector deferredReminders = m_deferredTriggeredReminders; + m_deferredTriggeredReminders.clear(); + for (const ReminderItem &item : deferredReminders) + { + showTriggeredReminder(item); + } event->accept(); return; } diff --git a/src/ui/PetWindow.h b/src/ui/PetWindow.h index 0730143..cb144fc 100644 --- a/src/ui/PetWindow.h +++ b/src/ui/PetWindow.h @@ -4,6 +4,7 @@ #include "../character/CharacterPackage.h" #include "../character/FrameAnimator.h" #include "../config/AppConfig.h" +#include "../reminder/ReminderTypes.h" #include "../state/PetStateMachine.h" #include @@ -11,9 +12,11 @@ #include #include #include +#include #include #include +#include #include class QMenu; @@ -26,7 +29,10 @@ class ChatHistoryPanel; class ChatInputDialog; class ConversationManager; class ConversationStore; +class NotificationDispatcher; class PetView; +class ReminderManager; +class ReminderSoundPlayer; class PetWindow : public QWidget { @@ -39,6 +45,7 @@ public: void openSettingsDialog(); void activateFromExternalInstance(); void setSettingsFallbackInContextMenuEnabled(bool enabled); + void setTrayNotificationCallback(std::function callback); void pauseAnimation(); void resumeAnimation(); void showBubbleMessage(const QString &message); @@ -61,6 +68,9 @@ private: void startChat(); bool submitChatMessage(const QString &message); bool submitAiChatMessage(const QString &message); + bool handleReminderChatMessage(const QString &message); + void handleTriggeredReminder(const ReminderItem &item); + void showTriggeredReminder(const ReminderItem &item); void clearConversation(); void cancelActiveAIRequest(); void showConversationHistory(); @@ -108,6 +118,9 @@ private: std::unique_ptr m_chatInputDialog; std::unique_ptr m_conversationManager; std::unique_ptr m_conversationStore; + std::unique_ptr m_notificationDispatcher; + std::unique_ptr m_reminderManager; + std::unique_ptr m_reminderSoundPlayer; PetView *m_petView; QTimer m_idleBehaviorTimer; QTimer m_behaviorReturnTimer; @@ -123,6 +136,7 @@ private: QPoint m_dragOffset; QString m_streamingAssistantText; QStringList m_animationPrewarmQueue; + QVector m_deferredTriggeredReminders; qint64 m_clipAccessSerial = 0; bool m_dragging; bool m_alwaysOnTop; diff --git a/src/ui/SettingsDialog.cpp b/src/ui/SettingsDialog.cpp index 55dd0b8..ddd0d2e 100644 --- a/src/ui/SettingsDialog.cpp +++ b/src/ui/SettingsDialog.cpp @@ -4,7 +4,9 @@ #include "../ai/LLMTypes.h" #include "../character/CharacterPackageRepository.h" #include "../config/SecretStore.h" +#include "../reminder/ReminderSoundRepository.h" +#include #include #include #include @@ -17,11 +19,13 @@ #include #include #include +#include #include #include #include #include #include +#include #include #include #include @@ -60,13 +64,70 @@ QString userVisibleErrorMessage(const ChatResponse &response) return message; } + +QString reminderStatusDisplayText(ReminderStatus status) +{ + switch (status) + { + case ReminderStatus::Pending: + return QStringLiteral("待提醒"); + case ReminderStatus::Triggered: + return QStringLiteral("已触发"); + case ReminderStatus::Canceled: + return QStringLiteral("已取消"); + } + + return QStringLiteral("待提醒"); +} + +bool reminderMatchesStatusFilter(const ReminderItem &item, const QString &filter) +{ + if (filter == QStringLiteral("all")) + { + return true; + } + + if (filter == QStringLiteral("pending")) + { + return item.status == ReminderStatus::Pending; + } + + if (filter == QStringLiteral("triggered")) + { + return item.status == ReminderStatus::Triggered; + } + + if (filter == QStringLiteral("canceled")) + { + return item.status == ReminderStatus::Canceled; + } + + return item.status == ReminderStatus::Pending; +} + +bool hasFinishedReminders(const QVector &reminders) +{ + for (const ReminderItem &item : reminders) + { + if (item.status == ReminderStatus::Triggered || item.status == ReminderStatus::Canceled) + { + return true; + } + } + + return false; +} } SettingsDialog::SettingsDialog( const AIConfigStore &configStore, const AppConfig &appConfig, + const QVector &reminders, std::function aiTestBlocked, std::function clearConversationHistoryCallback, + std::function cancelReminderCallback, + std::function clearFinishedRemindersCallback, + std::function playReminderSoundCallback, QWidget *parent) : QDialog(parent) , m_providerComboBox(new QComboBox(this)) @@ -95,10 +156,26 @@ SettingsDialog::SettingsDialog( , m_importCharacterButton(new QPushButton(QStringLiteral("导入角色文件夹"), this)) , m_deleteCharacterButton(new QPushButton(QStringLiteral("删除角色"), this)) , m_characterStatusLabel(new QLabel(this)) + , m_reminderStatusFilterComboBox(new QComboBox(this)) + , m_reminderListWidget(new QListWidget(this)) + , m_cancelReminderButton(new QPushButton(QStringLiteral("取消选中提醒"), this)) + , m_clearFinishedRemindersButton(new QPushButton(QStringLiteral("清理历史"), this)) + , m_reminderStatusLabel(new QLabel(this)) + , m_reminderSoundEnabledCheckBox(new QCheckBox(QStringLiteral("启用提醒音效"), this)) + , m_reminderSoundVolumeSpinBox(new QSpinBox(this)) + , m_reminderSoundComboBox(new QComboBox(this)) + , m_importReminderSoundButton(new QPushButton(QStringLiteral("导入音效"), this)) + , m_deleteReminderSoundButton(new QPushButton(QStringLiteral("删除音效"), this)) + , m_testReminderSoundButton(new QPushButton(QStringLiteral("试听"), this)) + , m_reminderSoundStatusLabel(new QLabel(this)) , m_configStore(configStore) , m_appConfig(appConfig) + , m_reminders(reminders) , m_aiTestBlocked(std::move(aiTestBlocked)) , m_clearConversationHistory(std::move(clearConversationHistoryCallback)) + , m_cancelReminder(std::move(cancelReminderCallback)) + , m_clearFinishedReminders(std::move(clearFinishedRemindersCallback)) + , m_playReminderSound(std::move(playReminderSoundCallback)) { setWindowTitle(QStringLiteral("设置")); setModal(true); @@ -278,6 +355,75 @@ SettingsDialog::SettingsDialog( auto *chatPage = new QWidget(this); chatPage->setLayout(chatPageLayout); + m_reminderStatusLabel->setObjectName(QStringLiteral("HintLabel")); + m_reminderStatusLabel->setWordWrap(true); + m_reminderSoundStatusLabel->setObjectName(QStringLiteral("HintLabel")); + m_reminderSoundStatusLabel->setWordWrap(true); + + m_reminderStatusFilterComboBox->addItem(QStringLiteral("待提醒"), QStringLiteral("pending")); + m_reminderStatusFilterComboBox->addItem(QStringLiteral("已触发"), QStringLiteral("triggered")); + m_reminderStatusFilterComboBox->addItem(QStringLiteral("已取消"), QStringLiteral("canceled")); + m_reminderStatusFilterComboBox->addItem(QStringLiteral("全部"), QStringLiteral("all")); + m_reminderListWidget->setObjectName(QStringLiteral("ReminderList")); + m_reminderListWidget->setFrameShape(QFrame::NoFrame); + m_reminderListWidget->setMinimumHeight(150); + m_reminderListWidget->setSelectionMode(QAbstractItemView::SingleSelection); + m_reminderSoundEnabledCheckBox->setChecked(m_appConfig.reminderSoundEnabled); + m_reminderSoundVolumeSpinBox->setRange(0, 100); + m_reminderSoundVolumeSpinBox->setSingleStep(5); + m_reminderSoundVolumeSpinBox->setSuffix(QStringLiteral("%")); + m_reminderSoundVolumeSpinBox->setValue(qRound(qBound(0.0, m_appConfig.reminderSoundVolume, 1.0) * 100.0)); + reloadReminderList(); + reloadReminderSoundList(m_appConfig.reminderSoundId); + + auto *reminderTitleLabel = new QLabel(QStringLiteral("提醒"), this); + reminderTitleLabel->setObjectName(QStringLiteral("PageTitle")); + auto *reminderHintLabel = new QLabel(QStringLiteral("这里显示通过聊天创建的待触发提醒。内置提醒音效不可删除,导入音效仅支持 PCM wav。"), this); + reminderHintLabel->setObjectName(QStringLiteral("HintLabel")); + reminderHintLabel->setWordWrap(true); + + auto *reminderListLayout = new QVBoxLayout(); + reminderListLayout->setSpacing(8); + auto *reminderFilterLayout = new QHBoxLayout(); + reminderFilterLayout->addWidget(new QLabel(QStringLiteral("状态"), this)); + reminderFilterLayout->addWidget(m_reminderStatusFilterComboBox); + reminderFilterLayout->addStretch(); + reminderListLayout->addLayout(reminderFilterLayout); + reminderListLayout->addWidget(m_reminderListWidget); + auto *reminderActionLayout = new QHBoxLayout(); + reminderActionLayout->addWidget(m_cancelReminderButton); + reminderActionLayout->addWidget(m_clearFinishedRemindersButton); + reminderActionLayout->addWidget(m_reminderStatusLabel, 1); + reminderListLayout->addLayout(reminderActionLayout); + + auto *reminderSoundFormLayout = new QFormLayout(); + reminderSoundFormLayout->setFieldGrowthPolicy(QFormLayout::ExpandingFieldsGrow); + reminderSoundFormLayout->setLabelAlignment(Qt::AlignRight); + reminderSoundFormLayout->setHorizontalSpacing(18); + reminderSoundFormLayout->setVerticalSpacing(12); + reminderSoundFormLayout->addRow(QString(), m_reminderSoundEnabledCheckBox); + reminderSoundFormLayout->addRow(QStringLiteral("当前音效"), m_reminderSoundComboBox); + reminderSoundFormLayout->addRow(QStringLiteral("音量"), m_reminderSoundVolumeSpinBox); + + auto *reminderSoundActionLayout = new QHBoxLayout(); + reminderSoundActionLayout->addWidget(m_importReminderSoundButton); + reminderSoundActionLayout->addWidget(m_deleteReminderSoundButton); + reminderSoundActionLayout->addWidget(m_testReminderSoundButton); + reminderSoundActionLayout->addWidget(m_reminderSoundStatusLabel, 1); + + auto *reminderPageLayout = new QVBoxLayout(); + reminderPageLayout->setContentsMargins(24, 24, 24, 24); + reminderPageLayout->setSpacing(16); + reminderPageLayout->addWidget(reminderTitleLabel); + reminderPageLayout->addWidget(reminderHintLabel); + reminderPageLayout->addLayout(reminderListLayout); + reminderPageLayout->addLayout(reminderSoundFormLayout); + reminderPageLayout->addLayout(reminderSoundActionLayout); + reminderPageLayout->addStretch(); + + auto *reminderPage = new QWidget(this); + reminderPage->setLayout(reminderPageLayout); + m_characterStatusLabel->setObjectName(QStringLiteral("HintLabel")); m_characterStatusLabel->setWordWrap(true); reloadCharacterList(m_appConfig.characterId); @@ -319,6 +465,7 @@ SettingsDialog::SettingsDialog( navigationList->setSpacing(4); navigationList->addItem(QStringLiteral("AI 配置")); navigationList->addItem(QStringLiteral("聊天")); + navigationList->addItem(QStringLiteral("提醒")); navigationList->addItem(QStringLiteral("应用")); navigationList->addItem(QStringLiteral("角色")); @@ -326,6 +473,7 @@ SettingsDialog::SettingsDialog( pageStack->setObjectName(QStringLiteral("SettingsPages")); pageStack->addWidget(aiPage); pageStack->addWidget(chatPage); + pageStack->addWidget(reminderPage); pageStack->addWidget(appPage); pageStack->addWidget(characterPage); @@ -372,6 +520,16 @@ SettingsDialog::SettingsDialog( "QListWidget#SettingsNavigation::item:selected {" " background: #eaf3ff; color: #175cd3; font-weight: 600;" "}" + "QListWidget#ReminderList {" + " background: #ffffff; border: 1px solid #98a2b3; border-radius: 0px;" + " padding: 0px;" + "}" + "QListWidget#ReminderList::item {" + " min-height: 28px; padding: 5px 8px; color: #344054;" + "}" + "QListWidget#ReminderList::item:selected {" + " background: #eaf3ff; color: #175cd3;" + "}" "QStackedWidget#SettingsPages {" " background: #ffffff; border: 1px solid #eaecf0; border-radius: 8px;" "}" @@ -405,6 +563,32 @@ SettingsDialog::SettingsDialog( connect(m_deleteCharacterButton, &QPushButton::clicked, this, [this]() { deleteSelectedCharacter(); }); + connect(m_reminderStatusFilterComboBox, &QComboBox::currentIndexChanged, this, [this]() { + reloadReminderList(); + }); + connect(m_reminderListWidget, &QListWidget::currentItemChanged, this, [this]() { + updateReminderActionButtons(); + }); + connect(m_cancelReminderButton, &QPushButton::clicked, this, [this]() { + cancelSelectedReminder(); + }); + connect(m_clearFinishedRemindersButton, &QPushButton::clicked, this, [this]() { + clearFinishedReminders(); + }); + connect(m_reminderSoundComboBox, &QComboBox::currentIndexChanged, this, [this]() { + updateReminderSoundButtons(); + }); + connect(m_importReminderSoundButton, &QPushButton::clicked, this, [this]() { + importReminderSound(); + }); + connect(m_deleteReminderSoundButton, &QPushButton::clicked, this, [this]() { + deleteSelectedReminderSound(); + }); + connect(m_testReminderSoundButton, &QPushButton::clicked, this, [this]() { + testSelectedReminderSound(); + }); + updateReminderActionButtons(); + updateReminderSoundButtons(); } SettingsDialog::~SettingsDialog() @@ -455,6 +639,13 @@ AppConfig SettingsDialog::appConfig() const { config.characterId = CharacterPackageRepository::defaultCharacterId(); } + config.reminderSoundId = selectedReminderSoundId(); + if (config.reminderSoundId.isEmpty()) + { + config.reminderSoundId = ReminderSoundRepository::defaultSoundId(); + } + config.reminderSoundEnabled = m_reminderSoundEnabledCheckBox->isChecked(); + config.reminderSoundVolume = qBound(0.0, m_reminderSoundVolumeSpinBox->value() / 100.0, 1.0); return config; } @@ -762,6 +953,261 @@ void SettingsDialog::clearConversationHistory() m_clearConversationStatusLabel->setText(QStringLiteral("聊天记录已清空。")); } +void SettingsDialog::reloadReminderList() +{ + m_reminderListWidget->clear(); + + const QString filter = m_reminderStatusFilterComboBox->currentData().toString(); + const QVector reminders = sortedReminders(m_reminders); + for (const ReminderItem &item : reminders) + { + if (!reminderMatchesStatusFilter(item, filter)) + { + continue; + } + + auto *listItem = new QListWidgetItem( + QStringLiteral("%1 %2 %3 %4") + .arg(reminderDisplayTime(item.remindAt), reminderStatusDisplayText(item.status), item.title, item.originalText), + m_reminderListWidget); + listItem->setData(Qt::UserRole, item.id); + listItem->setData(Qt::UserRole + 1, reminderStatusToString(item.status)); + listItem->setToolTip(item.originalText); + } + + if (m_reminderListWidget->count() == 0) + { + m_reminderStatusLabel->setText(QStringLiteral("当前筛选条件下没有提醒。")); + } + else + { + m_reminderStatusLabel->setText(QStringLiteral("共 %1 条提醒。").arg(m_reminderListWidget->count())); + } + updateReminderActionButtons(); +} + +void SettingsDialog::reloadReminderSoundList(const QString &selectedSoundId) +{ + const QString preferredSoundId = selectedSoundId.trimmed().isEmpty() + ? ReminderSoundRepository::defaultSoundId() + : selectedSoundId.trimmed(); + + { + const QSignalBlocker blocker(m_reminderSoundComboBox); + m_reminderSoundComboBox->clear(); + const QVector sounds = ReminderSoundRepository::availableSounds(); + for (const ReminderSoundInfo &sound : sounds) + { + QString label = sound.displayName.trimmed().isEmpty() ? sound.id : sound.displayName.trimmed(); + if (label != sound.id) + { + label += QStringLiteral(" (") + sound.id + QStringLiteral(")"); + } + label += sound.builtIn ? QStringLiteral(" - 内置") : QStringLiteral(" - 用户"); + m_reminderSoundComboBox->addItem(label, sound.id); + } + + if (m_reminderSoundComboBox->count() == 0) + { + m_reminderSoundComboBox->addItem(ReminderSoundRepository::defaultSoundId(), ReminderSoundRepository::defaultSoundId()); + } + + int selectedIndex = m_reminderSoundComboBox->findData(preferredSoundId); + if (selectedIndex < 0) + { + selectedIndex = m_reminderSoundComboBox->findData(ReminderSoundRepository::defaultSoundId()); + } + m_reminderSoundComboBox->setCurrentIndex(selectedIndex >= 0 ? selectedIndex : 0); + } + + updateReminderSoundButtons(); +} + +QString SettingsDialog::selectedReminderSoundId() const +{ + return m_reminderSoundComboBox->currentData().toString().trimmed(); +} + +void SettingsDialog::updateReminderSoundButtons() +{ + const QString soundId = selectedReminderSoundId(); + const bool hasSound = !soundId.isEmpty(); + m_deleteReminderSoundButton->setEnabled(hasSound && !ReminderSoundRepository::isBuiltInSound(soundId)); + m_testReminderSoundButton->setEnabled(hasSound); +} + +void SettingsDialog::updateReminderActionButtons() +{ + QListWidgetItem *item = m_reminderListWidget->currentItem(); + const bool selectedPending = item != nullptr + && item->data(Qt::UserRole + 1).toString() == QStringLiteral("pending"); + m_cancelReminderButton->setEnabled(selectedPending); + m_clearFinishedRemindersButton->setEnabled(hasFinishedReminders(m_reminders)); +} + +void SettingsDialog::cancelSelectedReminder() +{ + QListWidgetItem *item = m_reminderListWidget->currentItem(); + if (item == nullptr) + { + return; + } + + const QString reminderId = item->data(Qt::UserRole).toString(); + const QMessageBox::StandardButton result = QMessageBox::question( + this, + QStringLiteral("取消提醒"), + QStringLiteral("确定要取消这条提醒吗?记录会保留为 canceled,不会物理删除。")); + if (result != QMessageBox::Yes) + { + return; + } + + QString errorMessage; + if (!m_cancelReminder || !m_cancelReminder(reminderId, &errorMessage)) + { + QMessageBox::warning( + this, + QStringLiteral("取消提醒失败"), + errorMessage.isEmpty() ? QStringLiteral("没有找到可取消的提醒。") : errorMessage); + return; + } + + for (ReminderItem &reminder : m_reminders) + { + if (reminder.id == reminderId) + { + reminder.status = ReminderStatus::Canceled; + break; + } + } + reloadReminderList(); + m_reminderStatusLabel->setText(QStringLiteral("已取消提醒:%1").arg(reminderId)); +} + +void SettingsDialog::clearFinishedReminders() +{ + if (!hasFinishedReminders(m_reminders)) + { + m_reminderStatusLabel->setText(QStringLiteral("没有可清理的历史提醒。")); + return; + } + + const QMessageBox::StandardButton result = QMessageBox::warning( + this, + QStringLiteral("清理提醒历史"), + QStringLiteral("确定要清理所有已触发和已取消的提醒记录吗?\n\n待提醒事项不会被删除。"), + QMessageBox::Yes | QMessageBox::Cancel, + QMessageBox::Cancel); + if (result != QMessageBox::Yes) + { + return; + } + + QString errorMessage; + if (!m_clearFinishedReminders || !m_clearFinishedReminders(&errorMessage)) + { + QMessageBox::warning( + this, + QStringLiteral("清理提醒历史失败"), + errorMessage.isEmpty() ? QStringLiteral("清理提醒历史失败。") : errorMessage); + return; + } + + for (int index = m_reminders.size() - 1; index >= 0; --index) + { + const ReminderStatus status = m_reminders.at(index).status; + if (status == ReminderStatus::Triggered || status == ReminderStatus::Canceled) + { + m_reminders.removeAt(index); + } + } + + reloadReminderList(); + m_reminderStatusLabel->setText(QStringLiteral("已清理提醒历史。")); +} + +void SettingsDialog::importReminderSound() +{ + const QString filePath = QFileDialog::getOpenFileName( + this, + QStringLiteral("导入提醒音效"), + QString(), + QStringLiteral("Wave 音频 (*.wav)")); + if (filePath.isEmpty()) + { + return; + } + + QString importedSoundId; + QString errorMessage; + if (!ReminderSoundRepository::importSoundFile(filePath, &importedSoundId, &errorMessage)) + { + QMessageBox::warning( + this, + QStringLiteral("导入音效失败"), + errorMessage.isEmpty() ? QStringLiteral("导入音效失败。") : errorMessage); + return; + } + + reloadReminderSoundList(importedSoundId); + m_reminderSoundStatusLabel->setText(QStringLiteral("已导入音效:%1").arg(importedSoundId)); +} + +void SettingsDialog::deleteSelectedReminderSound() +{ + const QString soundId = selectedReminderSoundId(); + if (soundId.isEmpty()) + { + return; + } + + if (ReminderSoundRepository::isBuiltInSound(soundId)) + { + QMessageBox::information( + this, + QStringLiteral("不能删除内置音效"), + QStringLiteral("内置提醒音效作为默认资源保留,不能在设置页删除。")); + return; + } + + const QMessageBox::StandardButton result = QMessageBox::warning( + this, + QStringLiteral("删除提醒音效"), + QStringLiteral("确定要删除用户音效 \"%1\" 吗?\n\n这会删除用户音效文件,操作不可恢复。").arg(soundId), + QMessageBox::Yes | QMessageBox::Cancel, + QMessageBox::Cancel); + if (result != QMessageBox::Yes) + { + return; + } + + QString errorMessage; + if (!ReminderSoundRepository::deleteUserSound(soundId, &errorMessage)) + { + QMessageBox::warning( + this, + QStringLiteral("删除音效失败"), + errorMessage.isEmpty() ? QStringLiteral("删除用户音效失败。") : errorMessage); + return; + } + + reloadReminderSoundList(ReminderSoundRepository::defaultSoundId()); + m_reminderSoundStatusLabel->setText(QStringLiteral("已删除音效:%1,当前已回退到默认音效。").arg(soundId)); +} + +void SettingsDialog::testSelectedReminderSound() +{ + const QString soundId = selectedReminderSoundId(); + if (soundId.isEmpty() || !m_playReminderSound) + { + return; + } + + m_playReminderSound(soundId, qBound(0.0, m_reminderSoundVolumeSpinBox->value() / 100.0, 1.0)); + m_reminderSoundStatusLabel->setText(QStringLiteral("正在试听:%1").arg(soundId)); +} + void SettingsDialog::reloadCharacterList(const QString &selectedCharacterId) { const QString currentSelection = selectedCharacterId.trimmed().isEmpty() diff --git a/src/ui/SettingsDialog.h b/src/ui/SettingsDialog.h index 5d3171a..5b4b1f0 100644 --- a/src/ui/SettingsDialog.h +++ b/src/ui/SettingsDialog.h @@ -2,8 +2,10 @@ #include "../config/AIConfig.h" #include "../config/AppConfig.h" +#include "../reminder/ReminderTypes.h" #include +#include #include #include @@ -13,6 +15,7 @@ class QComboBox; class QDoubleSpinBox; class QLabel; class QLineEdit; +class QListWidget; class QPushButton; class QSpinBox; class LLMProvider; @@ -23,8 +26,12 @@ public: explicit SettingsDialog( const AIConfigStore &configStore, const AppConfig &appConfig, + const QVector &reminders, std::function aiTestBlocked, std::function clearConversationHistoryCallback, + std::function cancelReminderCallback, + std::function clearFinishedRemindersCallback, + std::function playReminderSoundCallback, QWidget *parent = nullptr); ~SettingsDialog() override; @@ -48,6 +55,16 @@ private: void reloadCharacterList(const QString &selectedCharacterId = {}); void importCharacterFolder(); void deleteSelectedCharacter(); + void reloadReminderList(); + void reloadReminderSoundList(const QString &selectedSoundId = {}); + QString selectedReminderSoundId() const; + void updateReminderSoundButtons(); + void updateReminderActionButtons(); + void cancelSelectedReminder(); + void clearFinishedReminders(); + void importReminderSound(); + void deleteSelectedReminderSound(); + void testSelectedReminderSound(); QComboBox *m_providerComboBox = nullptr; QLineEdit *m_baseUrlEdit = nullptr; @@ -77,12 +94,28 @@ private: QPushButton *m_importCharacterButton = nullptr; QPushButton *m_deleteCharacterButton = nullptr; QLabel *m_characterStatusLabel = nullptr; + QComboBox *m_reminderStatusFilterComboBox = nullptr; + QListWidget *m_reminderListWidget = nullptr; + QPushButton *m_cancelReminderButton = nullptr; + QPushButton *m_clearFinishedRemindersButton = nullptr; + QLabel *m_reminderStatusLabel = nullptr; + QCheckBox *m_reminderSoundEnabledCheckBox = nullptr; + QSpinBox *m_reminderSoundVolumeSpinBox = nullptr; + QComboBox *m_reminderSoundComboBox = nullptr; + QPushButton *m_importReminderSoundButton = nullptr; + QPushButton *m_deleteReminderSoundButton = nullptr; + QPushButton *m_testReminderSoundButton = nullptr; + QLabel *m_reminderSoundStatusLabel = nullptr; AIConfigStore m_configStore; AIConfigStore m_acceptedConfigStore; AppConfig m_appConfig; + QVector m_reminders; QString m_currentProvider; std::function m_aiTestBlocked; std::function m_clearConversationHistory; + std::function m_cancelReminder; + std::function m_clearFinishedReminders; + std::function m_playReminderSound; std::unique_ptr m_testProvider; bool m_hasAcceptedConfigStore = false; }; diff --git a/src/util/ResourcePaths.cpp b/src/util/ResourcePaths.cpp index 614e316..1abf6c9 100644 --- a/src/util/ResourcePaths.cpp +++ b/src/util/ResourcePaths.cpp @@ -55,6 +55,11 @@ QString ResourcePaths::charactersRootPath() return resourcePath(QStringLiteral("characters")); } +QString ResourcePaths::reminderSoundsRootPath() +{ + return resourcePath(QStringLiteral("sounds/reminders")); +} + QString ResourcePaths::appIconPath() { return resourcePath(QStringLiteral("icons/app_icon.ico")); diff --git a/src/util/ResourcePaths.h b/src/util/ResourcePaths.h index 01e0a63..cbf3c23 100644 --- a/src/util/ResourcePaths.h +++ b/src/util/ResourcePaths.h @@ -8,6 +8,7 @@ public: static QString resourcesRootPath(); static QString resourcePath(const QString &relativePath); static QString charactersRootPath(); + static QString reminderSoundsRootPath(); static QString appIconPath(); static QString appIconSourcePngPath(); }; diff --git a/tools/package_release.ps1 b/tools/package_release.ps1 index b0dcf70..505861d 100644 --- a/tools/package_release.ps1 +++ b/tools/package_release.ps1 @@ -190,6 +190,7 @@ $targetExePath = Join-Path $packageRoot "QtDesktopPet.exe" $resourcesRoot = Join-Path $repoRoot "resources" $charactersRoot = Join-Path $resourcesRoot "characters" $iconsRoot = Join-Path $resourcesRoot "icons" +$soundsRoot = Join-Path $resourcesRoot "sounds" $licensePath = Join-Path $repoRoot "LICENSE" $readmePath = Join-Path $repoRoot "README.md" $installerScriptPath = Join-Path $repoRoot "installer\QtDesktopPet.iss" @@ -197,6 +198,7 @@ $installerScriptPath = Join-Path $repoRoot "installer\QtDesktopPet.iss" Assert-RequiredPath -Path $resolvedExePath -Description "QtDesktopPet.exe" Assert-RequiredPath -Path (Join-Path $charactersRoot "shiroko\character.json") -Description "Default character package" Assert-RequiredPath -Path (Join-Path $iconsRoot "app_icon.ico") -Description "Application icon" +Assert-RequiredPath -Path (Join-Path $soundsRoot "reminders\reminder_default.wav") -Description "Default reminder sound" Assert-RequiredPath -Path $licensePath -Description "LICENSE" Assert-RequiredPath -Path $readmePath -Description "README.md" @@ -211,6 +213,7 @@ New-Item -ItemType Directory -Force -Path $packageRoot | Out-Null Copy-Item -LiteralPath $resolvedExePath -Destination $targetExePath -Force Copy-DirectoryFresh -Source $charactersRoot -Destination (Join-Path $packageRoot "resources\characters") Copy-DirectoryFresh -Source $iconsRoot -Destination (Join-Path $packageRoot "resources\icons") +Copy-DirectoryFresh -Source $soundsRoot -Destination (Join-Path $packageRoot "resources\sounds") Copy-Item -LiteralPath $licensePath -Destination (Join-Path $packageRoot "LICENSE") -Force Copy-Item -LiteralPath $readmePath -Destination (Join-Path $packageRoot "README.md") -Force @@ -223,7 +226,7 @@ $manifestPath = Join-Path $packageRoot "package_manifest.txt" "Version: $Version", "CreatedUtc: $((Get-Date).ToUniversalTime().ToString("o"))", "SourceExe: $resolvedExePath", - "Includes: QtDesktopPet.exe, Qt runtime, resources, LICENSE, README.md", + "Includes: QtDesktopPet.exe, Qt runtime, character/icon/sound resources, LICENSE, README.md", "Excludes: tools, docs, reports, build, dist, .git" ) | Set-Content -LiteralPath $manifestPath -Encoding UTF8