feat: add reminder scheduling and sound controls

This commit is contained in:
2026-06-01 21:01:11 +08:00
parent 4a7b739eea
commit c794e32023
36 changed files with 2494 additions and 35 deletions
+18 -1
View File
@@ -10,7 +10,7 @@ set(CMAKE_AUTOMOC OFF)
set(CMAKE_AUTORCC ON) set(CMAKE_AUTORCC ON)
set(CMAKE_AUTOUIC ON) set(CMAKE_AUTOUIC ON)
find_package(Qt6 REQUIRED COMPONENTS Widgets Network) find_package(Qt6 REQUIRED COMPONENTS Widgets Network Multimedia)
qt_add_executable(QtDesktopPet qt_add_executable(QtDesktopPet
main.cpp main.cpp
@@ -50,6 +50,22 @@ qt_add_executable(QtDesktopPet
src/config/ConfigManager.cpp src/config/ConfigManager.cpp
src/config/SecretStore.h src/config/SecretStore.h
src/config/SecretStore.cpp 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.h
src/state/PetStateMachine.cpp src/state/PetStateMachine.cpp
src/tray/TrayController.h src/tray/TrayController.h
@@ -79,6 +95,7 @@ target_compile_definitions(QtDesktopPet
target_link_libraries(QtDesktopPet target_link_libraries(QtDesktopPet
PRIVATE PRIVATE
Qt6::Multimedia
Qt6::Network Qt6::Network
Qt6::Widgets Qt6::Widgets
) )
+62 -1
View File
@@ -35,6 +35,9 @@ QtDesktopPet 是一个基于 Qt Widgets / C++17 的 Windows 桌面宠物项目
- Google Gemini 原生聊天请求 - Google Gemini 原生聊天请求
- 角色文件夹导入和角色切换 - 角色文件夹导入和角色切换
- 删除用户导入角色 - 删除用户导入角色
- 本地一次性提醒:聊天创建、查询、取消,重启后 pending 提醒不丢
- 提醒到点气泡提示、拖动后延迟提示和隐藏时托盘通知
- 提醒音效切换、试听、用户 wav 导入和删除
- Windows 发布打包脚本和 Inno Setup 安装器脚本 - Windows 发布打包脚本和 Inno Setup 安装器脚本
- Windows GUI 子系统,Release exe 双击不弹控制台窗口 - Windows GUI 子系统,Release exe 双击不弹控制台窗口
@@ -50,6 +53,7 @@ QtDesktopPet 是一个基于 Qt Widgets / C++17 的 Windows 桌面宠物项目
- C++17 - C++17
- Qt 6 Widgets - Qt 6 Widgets
- Qt 6 Network - Qt 6 Network
- Qt 6 Multimedia
- CMake - CMake
- PNG 图片序列帧 - PNG 图片序列帧
- JSON 配置文件 - JSON 配置文件
@@ -145,6 +149,62 @@ resources/characters/shiroko/
- 隐藏到托盘时可释放非保护动画缓存 - 隐藏到托盘时可释放非保护动画缓存
- `enableLazyLoad=false` 时仍保持启动阶段加载全部状态帧的兼容行为 - `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 标准配置目录: 应用配置保存到 Qt 标准配置目录:
@@ -197,6 +257,7 @@ QtDesktopPet.exe
Qt runtime Qt runtime
resources/characters/ resources/characters/
resources/icons/ resources/icons/
resources/sounds/
LICENSE LICENSE
README.md README.md
``` ```
@@ -278,7 +339,7 @@ reports/perf/
docs/performance_stability_check.md 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 配置和聊天 ## AI 配置和聊天
@@ -30,6 +30,8 @@
- AI 请求取消和对话清空 - AI 请求取消和对话清空
- 角色文件夹导入和角色切换 - 角色文件夹导入和角色切换
- 删除用户导入角色 - 删除用户导入角色
- 本地一次性提醒、提醒列表、取消提醒和到点通知
- 内置/用户提醒音效切换、导入、删除和试听
- Windows 打包脚本和 Inno Setup 安装器脚本 - Windows 打包脚本和 Inno Setup 安装器脚本
- Release exe 双击不弹控制台窗口 - Release exe 双击不弹控制台窗口
@@ -294,12 +296,12 @@ IP 定位隐私说明
阶段 5:语音对话 / 更复杂 Agent 能力 阶段 5:语音对话 / 更复杂 Agent 能力
``` ```
当前最推荐做: 当前结构收口和定时提醒已经进入实现阶段。下一步最推荐继续做:
```text ```text
1. IntentRouter / CommandDispatcher 1. 天气查询
2. 定时提醒 2. 本地文件操作安全边界
3. 天气查询 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 第一版范围 ## 5.2 第一版范围
要做: 要做:
@@ -408,12 +424,18 @@ CommandDispatcher::dispatch(userText)
```text ```text
src/reminder/ src/reminder/
├── ReminderTypes.h ├── ReminderTypes.h
├── ReminderCommandHandler.h
├── ReminderCommandHandler.cpp
├── ReminderParser.h ├── ReminderParser.h
├── ReminderParser.cpp ├── ReminderParser.cpp
├── ReminderManager.h ├── ReminderManager.h
├── ReminderManager.cpp ├── ReminderManager.cpp
├── ReminderStore.h ├── ReminderStore.h
── ReminderStore.cpp ── ReminderStore.cpp
├── ReminderSoundRepository.h
├── ReminderSoundRepository.cpp
├── ReminderSoundPlayer.h
└── ReminderSoundPlayer.cpp
``` ```
## 5.4 数据结构建议 ## 5.4 数据结构建议
@@ -425,8 +447,9 @@ struct ReminderItem
QString title; QString title;
QString originalText; QString originalText;
QDateTime remindAt; QDateTime remindAt;
bool triggered = false; ReminderStatus status = ReminderStatus::Pending;
QDateTime createdAt; QDateTime createdAt;
QString soundId; // 历史兼容字段,触发时不再读取
}; };
``` ```
@@ -448,8 +471,9 @@ QStandardPaths::AppConfigLocation/reminders.json
"title": "提交作业", "title": "提交作业",
"originalText": "晚上8点提醒我提交作业", "originalText": "晚上8点提醒我提交作业",
"remindAt": "2026-06-01T20:00:00", "remindAt": "2026-06-01T20:00:00",
"triggered": false, "status": "pending",
"createdAt": "2026-06-01T15:20:00" "createdAt": "2026-06-01T15:20:00",
"soundId": ""
} }
] ]
} }
@@ -475,13 +499,21 @@ reminders.broken.yyyyMMdd-HHmmss.json
下午3点 下午3点
明天9点 明天9点
明天上午10点 明天上午10点
后天9点
今天下午3点
6月3日9点
6/3 09:00
下周一上午10点
10分钟后 10分钟后
半小时后 半小时后
一个半小时后
一小时后
1小时后 1小时后
两小时后
2小时后 2小时后
``` ```
如果规则解析失败,后续可以再接 AI 解析。 如果规则解析失败,后续可以再接 AI 解析。包含“每天 / 每周 / 每月 / 工作日 / 重复”等语义时,当前只返回“重复提醒尚未支持”,不创建一次性提醒。
## 5.7 AI 辅助解析的设计边界 ## 5.7 AI 辅助解析的设计边界
@@ -524,9 +556,9 @@ userText
建议行为: 建议行为:
```text ```text
桌宠可见:显示 ChatBubble + 切 talk 或 happy 桌宠可见:播放当前全局音效,显示 ChatBubble + 切 happy,无 happy 时回退 talk,不发 Windows 通知
桌宠隐藏:系统托盘通知 桌宠隐藏:播放当前全局音效,触发 Windows 托盘通知,不在下次显示时补气泡
用户拖动中:不打断 drag,拖动结束后显示 用户拖动中:播放当前全局音效,不打断 drag,拖动结束后显示气泡,不发 Windows 通知
``` ```
提醒文案: 提醒文案:
@@ -988,12 +1020,8 @@ CustomSearchProvider
## 9.2 第二步:定时提醒 ## 9.2 第二步:定时提醒
```text ```text
1. ReminderTypes 当前已落地一次性提醒、提醒列表、取消提醒、到点气泡/托盘通知和提醒音效管理。
2. ReminderStore 后续可继续补确认/稍后提醒、重复提醒和跨平台通知实现。
3. ReminderParser
4. ReminderManager
5. 到点气泡和托盘通知
6. 设置页增加提醒列表,可后置
``` ```
## 9.3 第三步:天气查询 ## 9.3 第三步:天气查询
@@ -1066,8 +1094,8 @@ CustomSearchProvider
其中: 其中:
```text ```text
定时提醒:最适合第一个落地 定时提醒:已作为第一个工具能力落地
天气:第二个落地 天气:建议第二个落地
本地文件操作:风险较高,第三个落地 本地文件操作:风险较高,第三个落地
联网搜索:通用能力,最后落地 联网搜索:通用能力,最后落地
``` ```
+1 -1
View File
@@ -1408,7 +1408,7 @@ Windows 下不能只拷贝 exe。
1. 用户手动完成 Release 构建 1. 用户手动完成 Release 构建
2. 运行 tools/package_release.ps1,传入 QtDesktopPet.exe 路径 2. 运行 tools/package_release.ps1,传入 QtDesktopPet.exe 路径
3. 脚本调用 windeployqt 收集 Qt 运行库 3. 脚本调用 windeployqt 收集 Qt 运行库
4. 脚本复制 resources/characters、resources/icons、LICENSE、README.md 4. 脚本复制 resources/characters、resources/icons、resources/sounds、LICENSE、README.md
5. 脚本生成 dist/QtDesktopPet-<version>-windows-x64.zip 5. 脚本生成 dist/QtDesktopPet-<version>-windows-x64.zip
6. 需要安装器时,脚本优先查找 D:\Inno Setup 7\ISCC.exe,并调用 ISCC 编译 installer/QtDesktopPet.iss 6. 需要安装器时,脚本优先查找 D:\Inno Setup 7\ISCC.exe,并调用 ISCC 编译 installer/QtDesktopPet.iss
7. 安装器默认最终输出到项目根目录 7. 安装器默认最终输出到项目根目录
+3
View File
@@ -76,6 +76,9 @@ int main(int argc, char *argv[])
TrayController trayController(&window); TrayController trayController(&window);
window.setSettingsFallbackInContextMenuEnabled(!trayController.isAvailable()); window.setSettingsFallbackInContextMenuEnabled(!trayController.isAvailable());
window.setTrayNotificationCallback([&trayController](const QString &title, const QString &message) {
trayController.showNotification(title, message);
});
trayController.show(); trayController.show();
QObject::connect(&singleInstanceServer, &QLocalServer::newConnection, [&singleInstanceServer, &window]() { QObject::connect(&singleInstanceServer, &QLocalServer::newConnection, [&singleInstanceServer, &window]() {
Binary file not shown.
Binary file not shown.
+5
View File
@@ -27,6 +27,11 @@ CommandDispatchResult CommandDispatcher::dispatch(const QString &text) const
return {CommandDispatchAction::Chat, intent, intent.text}; return {CommandDispatchAction::Chat, intent, intent.text};
} }
if (intent.type == UserIntentType::Reminder)
{
return {CommandDispatchAction::Reminder, intent, intent.text};
}
return {CommandDispatchAction::UnsupportedTool, intent, unsupportedToolMessage(intent.type)}; return {CommandDispatchAction::UnsupportedTool, intent, unsupportedToolMessage(intent.type)};
} }
+1
View File
@@ -7,6 +7,7 @@
enum class CommandDispatchAction enum class CommandDispatchAction
{ {
Chat, Chat,
Reminder,
UnsupportedTool, UnsupportedTool,
}; };
+3
View File
@@ -16,6 +16,9 @@ struct AppConfig
int animationCacheLimitMb = 180; int animationCacheLimitMb = 180;
bool unloadAnimationsWhenHidden = true; bool unloadAnimationsWhenHidden = true;
QString characterId = QStringLiteral("shiroko"); QString characterId = QStringLiteral("shiroko");
QString reminderSoundId = QStringLiteral("reminder_default");
bool reminderSoundEnabled = true;
double reminderSoundVolume = 0.8;
int requestContextMessageLimit = 12; int requestContextMessageLimit = 12;
int memoryHistoryMessageLimit = 200; int memoryHistoryMessageLimit = 200;
bool saveConversationHistory = false; bool saveConversationHistory = false;
+31
View File
@@ -11,6 +11,7 @@
#include <QJsonParseError> #include <QJsonParseError>
#include <QJsonValue> #include <QJsonValue>
#include <QStandardPaths> #include <QStandardPaths>
#include <QtGlobal>
namespace namespace
{ {
@@ -57,6 +58,15 @@ QJsonObject characterObjectFromConfig(const AppConfig &config)
return character; 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) QString normalizedProviderName(const QString &provider)
{ {
const QString normalized = provider.trimmed().toLower(); const QString normalized = provider.trimmed().toLower();
@@ -258,6 +268,26 @@ AppConfig ConfigManager::loadAppConfig() const
config.characterId = character.value(QStringLiteral("id")).toString(config.characterId).trimmed(); 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; return config;
} }
@@ -346,6 +376,7 @@ bool ConfigManager::saveAppConfig(const AppConfig &config) const
root.insert(QStringLiteral("performance"), performanceObjectFromConfig(config)); root.insert(QStringLiteral("performance"), performanceObjectFromConfig(config));
root.insert(QStringLiteral("chat"), chatObjectFromConfig(config)); root.insert(QStringLiteral("chat"), chatObjectFromConfig(config));
root.insert(QStringLiteral("character"), characterObjectFromConfig(config)); root.insert(QStringLiteral("character"), characterObjectFromConfig(config));
root.insert(QStringLiteral("reminder"), reminderObjectFromConfig(config));
QFile file(appConfigPath()); QFile file(appConfigPath());
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate))
@@ -0,0 +1,16 @@
#include "NotificationDispatcher.h"
#include <utility>
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);
}
}
+17
View File
@@ -0,0 +1,17 @@
#pragma once
#include <QString>
#include <functional>
class NotificationDispatcher
{
public:
using ShowCallback = std::function<void(const QString &, const QString &)>;
void setShowCallback(ShowCallback callback);
void showReminder(const QString &title, const QString &message) const;
private:
ShowCallback m_showCallback;
};
+47
View File
@@ -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("没有识别到有效提醒命令。"), {}};
}
+20
View File
@@ -0,0 +1,20 @@
#pragma once
#include "ReminderTypes.h"
#include <QString>
class ReminderManager;
struct ReminderCommandResult
{
bool success = false;
QString message;
ReminderItem item;
};
class ReminderCommandHandler
{
public:
static ReminderCommandResult handle(const QString &text, ReminderManager &manager);
};
+337
View File
@@ -0,0 +1,337 @@
#include "ReminderManager.h"
#include "../util/Logger.h"
#include <QDateTime>
#include <QRandomGenerator>
#include <QStringList>
#include <QtGlobal>
#include <utility>
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<ReminderItem> ReminderManager::allReminders() const
{
return sortedReminders(m_items);
}
QVector<ReminderItem> ReminderManager::pendingReminders() const
{
QVector<ReminderItem> 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<int> 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<ReminderItem> 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<ReminderItem> 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<ReminderItem> triggeredItems;
QVector<int> 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<int>(qBound<qint64>(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')));
}
+49
View File
@@ -0,0 +1,49 @@
#pragma once
#include "ReminderParser.h"
#include "ReminderStore.h"
#include <QTimer>
#include <QVector>
#include <functional>
class ReminderManager
{
public:
using TriggeredCallback = std::function<void(const ReminderItem &)>;
ReminderManager();
void start();
void setTriggeredCallback(TriggeredCallback callback);
QVector<ReminderItem> allReminders() const;
QVector<ReminderItem> 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<ReminderItem> m_items;
QTimer m_timer;
TriggeredCallback m_triggeredCallback;
bool m_started = false;
};
+484
View File
@@ -0,0 +1,484 @@
#include "ReminderParser.h"
#include <QDate>
#include <QRegularExpression>
#include <QStringList>
#include <QTime>
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 &currentDate)
{
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;
}
+16
View File
@@ -0,0 +1,16 @@
#pragma once
#include "ReminderTypes.h"
#include <QDateTime>
#include <QString>
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;
};
+22
View File
@@ -0,0 +1,22 @@
#include "ReminderSoundPlayer.h"
#include "ReminderSoundRepository.h"
#include <QFileInfo>
#include <QUrl>
#include <QtGlobal>
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<float>(qBound(0.0, volume, 1.0)));
m_soundEffect.setSource(QUrl::fromLocalFile(path));
m_soundEffect.play();
}
+13
View File
@@ -0,0 +1,13 @@
#pragma once
#include <QSoundEffect>
#include <QString>
class ReminderSoundPlayer
{
public:
void play(const QString &soundId, double volume);
private:
QSoundEffect m_soundEffect;
};
+350
View File
@@ -0,0 +1,350 @@
#include "ReminderSoundRepository.h"
#include "../util/ResourcePaths.h"
#include <QDataStream>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QRegularExpression>
#include <QStandardPaths>
#include <QStringList>
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<ReminderSoundInfo> ReminderSoundRepository::availableSounds()
{
QVector<ReminderSoundInfo> 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<double>(dataSize) / static_cast<double>(sampleRate * channels * (bitsPerSample / 8.0));
if (durationSeconds <= 0.0 || durationSeconds > MaxSoundDurationSeconds)
{
if (errorMessage != nullptr)
{
*errorMessage = QStringLiteral("音效时长不符合要求。");
}
return false;
}
return true;
}
+26
View File
@@ -0,0 +1,26 @@
#pragma once
#include <QString>
#include <QVector>
struct ReminderSoundInfo
{
QString id;
QString displayName;
QString path;
bool builtIn = false;
};
class ReminderSoundRepository
{
public:
static QString defaultSoundId();
static QVector<ReminderSoundInfo> 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);
};
+180
View File
@@ -0,0 +1,180 @@
#include "ReminderStore.h"
#include "../util/Logger.h"
#include <QDateTime>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonParseError>
#include <QStandardPaths>
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<ReminderItem> 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<ReminderItem> 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<ReminderItem> &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);
}
}
+18
View File
@@ -0,0 +1,18 @@
#pragma once
#include "ReminderTypes.h"
#include <QString>
#include <QVector>
class ReminderStore
{
public:
QVector<ReminderItem> load(QString *errorMessage = nullptr) const;
bool save(const QVector<ReminderItem> &items, QString *errorMessage = nullptr) const;
QString storePath() const;
private:
QString configDirectoryPath() const;
void backupBrokenStore(const QString &filePath) const;
};
+52
View File
@@ -0,0 +1,52 @@
#include "ReminderTypes.h"
#include <algorithm>
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<ReminderItem> sortedReminders(QVector<ReminderItem> 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;
}
+46
View File
@@ -0,0 +1,46 @@
#pragma once
#include <QDateTime>
#include <QString>
#include <QVector>
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<ReminderItem> sortedReminders(QVector<ReminderItem> reminders);
+10
View File
@@ -62,6 +62,16 @@ void TrayController::show()
m_trayIcon.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() void TrayController::createMenu()
{ {
QAction *showAction = m_menu.addAction(QStringLiteral("显示桌宠")); QAction *showAction = m_menu.addAction(QStringLiteral("显示桌宠"));
+1
View File
@@ -12,6 +12,7 @@ public:
bool isAvailable() const; bool isAvailable() const;
void show(); void show();
void showNotification(const QString &title, const QString &message);
private: private:
void createMenu(); void createMenu();
+115 -11
View File
@@ -7,6 +7,11 @@
#include "../character/CharacterPackageLoader.h" #include "../character/CharacterPackageLoader.h"
#include "../character/CharacterPackageRepository.h" #include "../character/CharacterPackageRepository.h"
#include "../config/ConfigManager.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 "../util/Logger.h"
#include "ChatBubble.h" #include "ChatBubble.h"
#include "ChatHistoryPanel.h" #include "ChatHistoryPanel.h"
@@ -37,6 +42,7 @@
#include <algorithm> #include <algorithm>
#include <memory> #include <memory>
#include <utility>
namespace namespace
{ {
@@ -88,6 +94,8 @@ AppConfig normalizedAppConfig(AppConfig config)
{ {
config.characterId = CharacterPackageRepository::defaultCharacterId(); config.characterId = CharacterPackageRepository::defaultCharacterId();
} }
config.reminderSoundId = ReminderSoundRepository::soundInfo(config.reminderSoundId).id;
config.reminderSoundVolume = qBound(0.0, config.reminderSoundVolume, 1.0);
return config; return config;
} }
@@ -156,6 +164,9 @@ PetWindow::PetWindow(QWidget *parent)
, m_chatInputDialog(std::make_unique<ChatInputDialog>(MaxUserMessageLength, this)) , m_chatInputDialog(std::make_unique<ChatInputDialog>(MaxUserMessageLength, this))
, m_conversationManager(std::make_unique<ConversationManager>()) , m_conversationManager(std::make_unique<ConversationManager>())
, m_conversationStore(std::make_unique<ConversationStore>(ConfigManager().conversationHistoryPath())) , m_conversationStore(std::make_unique<ConversationStore>(ConfigManager().conversationHistoryPath()))
, m_notificationDispatcher(std::make_unique<NotificationDispatcher>())
, m_reminderManager(std::make_unique<ReminderManager>())
, m_reminderSoundPlayer(std::make_unique<ReminderSoundPlayer>())
, m_petView(new PetView(this)) , m_petView(new PetView(this))
, m_dragging(false) , m_dragging(false)
, m_alwaysOnTop(true) , m_alwaysOnTop(true)
@@ -203,6 +214,13 @@ PetWindow::PetWindow(QWidget *parent)
return !window.isNull() && window->submitChatMessage(message); return !window.isNull() && window->submitChatMessage(message);
}); });
m_reminderManager->setTriggeredCallback([window](const ReminderItem &item) {
if (!window.isNull())
{
window->handleTriggeredReminder(item);
}
});
loadInitialImage(); loadInitialImage();
} }
@@ -321,11 +339,29 @@ void PetWindow::showBubbleMessage(const QString &message)
void PetWindow::openSettingsDialog() void PetWindow::openSettingsDialog()
{ {
ConfigManager configManager; ConfigManager configManager;
SettingsDialog dialog(configManager.loadAIConfigStore(), currentAppConfig(), [this]() { SettingsDialog dialog(
return isManualStateSwitchLocked(); configManager.loadAIConfigStore(),
}, [this]() { currentAppConfig(),
clearConversation(); m_reminderManager ? m_reminderManager->allReminders() : QVector<ReminderItem>(),
}, this); [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); centerDialogOnScreen(&dialog, this);
if (dialog.exec() != QDialog::Accepted) if (dialog.exec() != QDialog::Accepted)
{ {
@@ -374,6 +410,14 @@ void PetWindow::setSettingsFallbackInContextMenuEnabled(bool enabled)
m_settingsFallbackInContextMenuEnabled = enabled; m_settingsFallbackInContextMenuEnabled = enabled;
} }
void PetWindow::setTrayNotificationCallback(std::function<void(const QString &, const QString &)> callback)
{
if (m_notificationDispatcher)
{
m_notificationDispatcher->setShowCallback(std::move(callback));
}
}
void PetWindow::contextMenuEvent(QContextMenuEvent *event) void PetWindow::contextMenuEvent(QContextMenuEvent *event)
{ {
resetBubbleAutoHideTimer(); resetBubbleAutoHideTimer();
@@ -455,12 +499,6 @@ void PetWindow::startChat()
bool PetWindow::submitChatMessage(const QString &message) bool PetWindow::submitChatMessage(const QString &message)
{ {
if (!m_conversationManager || m_conversationManager->isBusy())
{
showBubbleMessage(QStringLiteral("AI 回复正在进行。"));
return false;
}
const QString normalizedMessage = message.trimmed(); const QString normalizedMessage = message.trimmed();
if (normalizedMessage.isEmpty()) if (normalizedMessage.isEmpty())
{ {
@@ -476,6 +514,11 @@ bool PetWindow::submitChatMessage(const QString &message)
CommandDispatcher dispatcher; CommandDispatcher dispatcher;
const CommandDispatchResult result = dispatcher.dispatch(normalizedMessage); const CommandDispatchResult result = dispatcher.dispatch(normalizedMessage);
if (result.action == CommandDispatchAction::Reminder)
{
return handleReminderChatMessage(result.message);
}
if (result.action == CommandDispatchAction::UnsupportedTool) if (result.action == CommandDispatchAction::UnsupportedTool)
{ {
playState(QStringLiteral("talk"), false); playState(QStringLiteral("talk"), false);
@@ -486,6 +529,57 @@ bool PetWindow::submitChatMessage(const QString &message)
return submitAiChatMessage(result.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) bool PetWindow::submitAiChatMessage(const QString &message)
{ {
if (!m_conversationManager || m_conversationManager->isBusy()) if (!m_conversationManager || m_conversationManager->isBusy())
@@ -814,6 +908,10 @@ void PetWindow::hideEvent(QHideEvent *event)
void PetWindow::showEvent(QShowEvent *event) void PetWindow::showEvent(QShowEvent *event)
{ {
QWidget::showEvent(event); QWidget::showEvent(event);
if (m_reminderManager)
{
m_reminderManager->start();
}
scheduleAnimationPrewarm(); scheduleAnimationPrewarm();
} }
@@ -878,6 +976,12 @@ void PetWindow::mouseReleaseEvent(QMouseEvent *event)
m_dragging = false; m_dragging = false;
playResolvedState(m_stateMachine.endDrag(), false); playResolvedState(m_stateMachine.endDrag(), false);
scheduleAnimationPrewarm(); scheduleAnimationPrewarm();
const QVector<ReminderItem> deferredReminders = m_deferredTriggeredReminders;
m_deferredTriggeredReminders.clear();
for (const ReminderItem &item : deferredReminders)
{
showTriggeredReminder(item);
}
event->accept(); event->accept();
return; return;
} }
+14
View File
@@ -4,6 +4,7 @@
#include "../character/CharacterPackage.h" #include "../character/CharacterPackage.h"
#include "../character/FrameAnimator.h" #include "../character/FrameAnimator.h"
#include "../config/AppConfig.h" #include "../config/AppConfig.h"
#include "../reminder/ReminderTypes.h"
#include "../state/PetStateMachine.h" #include "../state/PetStateMachine.h"
#include <QMap> #include <QMap>
@@ -11,9 +12,11 @@
#include <QSet> #include <QSet>
#include <QStringList> #include <QStringList>
#include <QTimer> #include <QTimer>
#include <QVector>
#include <QWidget> #include <QWidget>
#include <QtGlobal> #include <QtGlobal>
#include <functional>
#include <memory> #include <memory>
class QMenu; class QMenu;
@@ -26,7 +29,10 @@ class ChatHistoryPanel;
class ChatInputDialog; class ChatInputDialog;
class ConversationManager; class ConversationManager;
class ConversationStore; class ConversationStore;
class NotificationDispatcher;
class PetView; class PetView;
class ReminderManager;
class ReminderSoundPlayer;
class PetWindow : public QWidget class PetWindow : public QWidget
{ {
@@ -39,6 +45,7 @@ public:
void openSettingsDialog(); void openSettingsDialog();
void activateFromExternalInstance(); void activateFromExternalInstance();
void setSettingsFallbackInContextMenuEnabled(bool enabled); void setSettingsFallbackInContextMenuEnabled(bool enabled);
void setTrayNotificationCallback(std::function<void(const QString &, const QString &)> callback);
void pauseAnimation(); void pauseAnimation();
void resumeAnimation(); void resumeAnimation();
void showBubbleMessage(const QString &message); void showBubbleMessage(const QString &message);
@@ -61,6 +68,9 @@ private:
void startChat(); void startChat();
bool submitChatMessage(const QString &message); bool submitChatMessage(const QString &message);
bool submitAiChatMessage(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 clearConversation();
void cancelActiveAIRequest(); void cancelActiveAIRequest();
void showConversationHistory(); void showConversationHistory();
@@ -108,6 +118,9 @@ private:
std::unique_ptr<ChatInputDialog> m_chatInputDialog; std::unique_ptr<ChatInputDialog> m_chatInputDialog;
std::unique_ptr<ConversationManager> m_conversationManager; std::unique_ptr<ConversationManager> m_conversationManager;
std::unique_ptr<ConversationStore> m_conversationStore; std::unique_ptr<ConversationStore> m_conversationStore;
std::unique_ptr<NotificationDispatcher> m_notificationDispatcher;
std::unique_ptr<ReminderManager> m_reminderManager;
std::unique_ptr<ReminderSoundPlayer> m_reminderSoundPlayer;
PetView *m_petView; PetView *m_petView;
QTimer m_idleBehaviorTimer; QTimer m_idleBehaviorTimer;
QTimer m_behaviorReturnTimer; QTimer m_behaviorReturnTimer;
@@ -123,6 +136,7 @@ private:
QPoint m_dragOffset; QPoint m_dragOffset;
QString m_streamingAssistantText; QString m_streamingAssistantText;
QStringList m_animationPrewarmQueue; QStringList m_animationPrewarmQueue;
QVector<ReminderItem> m_deferredTriggeredReminders;
qint64 m_clipAccessSerial = 0; qint64 m_clipAccessSerial = 0;
bool m_dragging; bool m_dragging;
bool m_alwaysOnTop; bool m_alwaysOnTop;
+446
View File
@@ -4,7 +4,9 @@
#include "../ai/LLMTypes.h" #include "../ai/LLMTypes.h"
#include "../character/CharacterPackageRepository.h" #include "../character/CharacterPackageRepository.h"
#include "../config/SecretStore.h" #include "../config/SecretStore.h"
#include "../reminder/ReminderSoundRepository.h"
#include <QAbstractItemView>
#include <QCheckBox> #include <QCheckBox>
#include <QComboBox> #include <QComboBox>
#include <QDialogButtonBox> #include <QDialogButtonBox>
@@ -17,11 +19,13 @@
#include <QLineEdit> #include <QLineEdit>
#include <QList> #include <QList>
#include <QListWidget> #include <QListWidget>
#include <QListWidgetItem>
#include <QMessageBox> #include <QMessageBox>
#include <QPair> #include <QPair>
#include <QPointer> #include <QPointer>
#include <QPushButton> #include <QPushButton>
#include <QDir> #include <QDir>
#include <QSignalBlocker>
#include <QSpinBox> #include <QSpinBox>
#include <QStackedWidget> #include <QStackedWidget>
#include <QStyle> #include <QStyle>
@@ -60,13 +64,70 @@ QString userVisibleErrorMessage(const ChatResponse &response)
return message; 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<ReminderItem> &reminders)
{
for (const ReminderItem &item : reminders)
{
if (item.status == ReminderStatus::Triggered || item.status == ReminderStatus::Canceled)
{
return true;
}
}
return false;
}
} }
SettingsDialog::SettingsDialog( SettingsDialog::SettingsDialog(
const AIConfigStore &configStore, const AIConfigStore &configStore,
const AppConfig &appConfig, const AppConfig &appConfig,
const QVector<ReminderItem> &reminders,
std::function<bool()> aiTestBlocked, std::function<bool()> aiTestBlocked,
std::function<void()> clearConversationHistoryCallback, std::function<void()> clearConversationHistoryCallback,
std::function<bool(const QString &, QString *)> cancelReminderCallback,
std::function<bool(QString *)> clearFinishedRemindersCallback,
std::function<void(const QString &, double)> playReminderSoundCallback,
QWidget *parent) QWidget *parent)
: QDialog(parent) : QDialog(parent)
, m_providerComboBox(new QComboBox(this)) , m_providerComboBox(new QComboBox(this))
@@ -95,10 +156,26 @@ SettingsDialog::SettingsDialog(
, m_importCharacterButton(new QPushButton(QStringLiteral("导入角色文件夹"), this)) , m_importCharacterButton(new QPushButton(QStringLiteral("导入角色文件夹"), this))
, m_deleteCharacterButton(new QPushButton(QStringLiteral("删除角色"), this)) , m_deleteCharacterButton(new QPushButton(QStringLiteral("删除角色"), this))
, m_characterStatusLabel(new QLabel(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_configStore(configStore)
, m_appConfig(appConfig) , m_appConfig(appConfig)
, m_reminders(reminders)
, m_aiTestBlocked(std::move(aiTestBlocked)) , m_aiTestBlocked(std::move(aiTestBlocked))
, m_clearConversationHistory(std::move(clearConversationHistoryCallback)) , m_clearConversationHistory(std::move(clearConversationHistoryCallback))
, m_cancelReminder(std::move(cancelReminderCallback))
, m_clearFinishedReminders(std::move(clearFinishedRemindersCallback))
, m_playReminderSound(std::move(playReminderSoundCallback))
{ {
setWindowTitle(QStringLiteral("设置")); setWindowTitle(QStringLiteral("设置"));
setModal(true); setModal(true);
@@ -278,6 +355,75 @@ SettingsDialog::SettingsDialog(
auto *chatPage = new QWidget(this); auto *chatPage = new QWidget(this);
chatPage->setLayout(chatPageLayout); 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->setObjectName(QStringLiteral("HintLabel"));
m_characterStatusLabel->setWordWrap(true); m_characterStatusLabel->setWordWrap(true);
reloadCharacterList(m_appConfig.characterId); reloadCharacterList(m_appConfig.characterId);
@@ -319,6 +465,7 @@ SettingsDialog::SettingsDialog(
navigationList->setSpacing(4); navigationList->setSpacing(4);
navigationList->addItem(QStringLiteral("AI 配置")); navigationList->addItem(QStringLiteral("AI 配置"));
navigationList->addItem(QStringLiteral("聊天")); navigationList->addItem(QStringLiteral("聊天"));
navigationList->addItem(QStringLiteral("提醒"));
navigationList->addItem(QStringLiteral("应用")); navigationList->addItem(QStringLiteral("应用"));
navigationList->addItem(QStringLiteral("角色")); navigationList->addItem(QStringLiteral("角色"));
@@ -326,6 +473,7 @@ SettingsDialog::SettingsDialog(
pageStack->setObjectName(QStringLiteral("SettingsPages")); pageStack->setObjectName(QStringLiteral("SettingsPages"));
pageStack->addWidget(aiPage); pageStack->addWidget(aiPage);
pageStack->addWidget(chatPage); pageStack->addWidget(chatPage);
pageStack->addWidget(reminderPage);
pageStack->addWidget(appPage); pageStack->addWidget(appPage);
pageStack->addWidget(characterPage); pageStack->addWidget(characterPage);
@@ -372,6 +520,16 @@ SettingsDialog::SettingsDialog(
"QListWidget#SettingsNavigation::item:selected {" "QListWidget#SettingsNavigation::item:selected {"
" background: #eaf3ff; color: #175cd3; font-weight: 600;" " 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 {" "QStackedWidget#SettingsPages {"
" background: #ffffff; border: 1px solid #eaecf0; border-radius: 8px;" " background: #ffffff; border: 1px solid #eaecf0; border-radius: 8px;"
"}" "}"
@@ -405,6 +563,32 @@ SettingsDialog::SettingsDialog(
connect(m_deleteCharacterButton, &QPushButton::clicked, this, [this]() { connect(m_deleteCharacterButton, &QPushButton::clicked, this, [this]() {
deleteSelectedCharacter(); 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() SettingsDialog::~SettingsDialog()
@@ -455,6 +639,13 @@ AppConfig SettingsDialog::appConfig() const
{ {
config.characterId = CharacterPackageRepository::defaultCharacterId(); 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; return config;
} }
@@ -762,6 +953,261 @@ void SettingsDialog::clearConversationHistory()
m_clearConversationStatusLabel->setText(QStringLiteral("聊天记录已清空。")); m_clearConversationStatusLabel->setText(QStringLiteral("聊天记录已清空。"));
} }
void SettingsDialog::reloadReminderList()
{
m_reminderListWidget->clear();
const QString filter = m_reminderStatusFilterComboBox->currentData().toString();
const QVector<ReminderItem> 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<ReminderSoundInfo> 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) void SettingsDialog::reloadCharacterList(const QString &selectedCharacterId)
{ {
const QString currentSelection = selectedCharacterId.trimmed().isEmpty() const QString currentSelection = selectedCharacterId.trimmed().isEmpty()
+33
View File
@@ -2,8 +2,10 @@
#include "../config/AIConfig.h" #include "../config/AIConfig.h"
#include "../config/AppConfig.h" #include "../config/AppConfig.h"
#include "../reminder/ReminderTypes.h"
#include <QDialog> #include <QDialog>
#include <QVector>
#include <functional> #include <functional>
#include <memory> #include <memory>
@@ -13,6 +15,7 @@ class QComboBox;
class QDoubleSpinBox; class QDoubleSpinBox;
class QLabel; class QLabel;
class QLineEdit; class QLineEdit;
class QListWidget;
class QPushButton; class QPushButton;
class QSpinBox; class QSpinBox;
class LLMProvider; class LLMProvider;
@@ -23,8 +26,12 @@ public:
explicit SettingsDialog( explicit SettingsDialog(
const AIConfigStore &configStore, const AIConfigStore &configStore,
const AppConfig &appConfig, const AppConfig &appConfig,
const QVector<ReminderItem> &reminders,
std::function<bool()> aiTestBlocked, std::function<bool()> aiTestBlocked,
std::function<void()> clearConversationHistoryCallback, std::function<void()> clearConversationHistoryCallback,
std::function<bool(const QString &, QString *)> cancelReminderCallback,
std::function<bool(QString *)> clearFinishedRemindersCallback,
std::function<void(const QString &, double)> playReminderSoundCallback,
QWidget *parent = nullptr); QWidget *parent = nullptr);
~SettingsDialog() override; ~SettingsDialog() override;
@@ -48,6 +55,16 @@ private:
void reloadCharacterList(const QString &selectedCharacterId = {}); void reloadCharacterList(const QString &selectedCharacterId = {});
void importCharacterFolder(); void importCharacterFolder();
void deleteSelectedCharacter(); 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; QComboBox *m_providerComboBox = nullptr;
QLineEdit *m_baseUrlEdit = nullptr; QLineEdit *m_baseUrlEdit = nullptr;
@@ -77,12 +94,28 @@ private:
QPushButton *m_importCharacterButton = nullptr; QPushButton *m_importCharacterButton = nullptr;
QPushButton *m_deleteCharacterButton = nullptr; QPushButton *m_deleteCharacterButton = nullptr;
QLabel *m_characterStatusLabel = 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_configStore;
AIConfigStore m_acceptedConfigStore; AIConfigStore m_acceptedConfigStore;
AppConfig m_appConfig; AppConfig m_appConfig;
QVector<ReminderItem> m_reminders;
QString m_currentProvider; QString m_currentProvider;
std::function<bool()> m_aiTestBlocked; std::function<bool()> m_aiTestBlocked;
std::function<void()> m_clearConversationHistory; std::function<void()> m_clearConversationHistory;
std::function<bool(const QString &, QString *)> m_cancelReminder;
std::function<bool(QString *)> m_clearFinishedReminders;
std::function<void(const QString &, double)> m_playReminderSound;
std::unique_ptr<LLMProvider> m_testProvider; std::unique_ptr<LLMProvider> m_testProvider;
bool m_hasAcceptedConfigStore = false; bool m_hasAcceptedConfigStore = false;
}; };
+5
View File
@@ -55,6 +55,11 @@ QString ResourcePaths::charactersRootPath()
return resourcePath(QStringLiteral("characters")); return resourcePath(QStringLiteral("characters"));
} }
QString ResourcePaths::reminderSoundsRootPath()
{
return resourcePath(QStringLiteral("sounds/reminders"));
}
QString ResourcePaths::appIconPath() QString ResourcePaths::appIconPath()
{ {
return resourcePath(QStringLiteral("icons/app_icon.ico")); return resourcePath(QStringLiteral("icons/app_icon.ico"));
+1
View File
@@ -8,6 +8,7 @@ public:
static QString resourcesRootPath(); static QString resourcesRootPath();
static QString resourcePath(const QString &relativePath); static QString resourcePath(const QString &relativePath);
static QString charactersRootPath(); static QString charactersRootPath();
static QString reminderSoundsRootPath();
static QString appIconPath(); static QString appIconPath();
static QString appIconSourcePngPath(); static QString appIconSourcePngPath();
}; };
+4 -1
View File
@@ -190,6 +190,7 @@ $targetExePath = Join-Path $packageRoot "QtDesktopPet.exe"
$resourcesRoot = Join-Path $repoRoot "resources" $resourcesRoot = Join-Path $repoRoot "resources"
$charactersRoot = Join-Path $resourcesRoot "characters" $charactersRoot = Join-Path $resourcesRoot "characters"
$iconsRoot = Join-Path $resourcesRoot "icons" $iconsRoot = Join-Path $resourcesRoot "icons"
$soundsRoot = Join-Path $resourcesRoot "sounds"
$licensePath = Join-Path $repoRoot "LICENSE" $licensePath = Join-Path $repoRoot "LICENSE"
$readmePath = Join-Path $repoRoot "README.md" $readmePath = Join-Path $repoRoot "README.md"
$installerScriptPath = Join-Path $repoRoot "installer\QtDesktopPet.iss" $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 $resolvedExePath -Description "QtDesktopPet.exe"
Assert-RequiredPath -Path (Join-Path $charactersRoot "shiroko\character.json") -Description "Default character package" 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 $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 $licensePath -Description "LICENSE"
Assert-RequiredPath -Path $readmePath -Description "README.md" 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-Item -LiteralPath $resolvedExePath -Destination $targetExePath -Force
Copy-DirectoryFresh -Source $charactersRoot -Destination (Join-Path $packageRoot "resources\characters") Copy-DirectoryFresh -Source $charactersRoot -Destination (Join-Path $packageRoot "resources\characters")
Copy-DirectoryFresh -Source $iconsRoot -Destination (Join-Path $packageRoot "resources\icons") 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 $licensePath -Destination (Join-Path $packageRoot "LICENSE") -Force
Copy-Item -LiteralPath $readmePath -Destination (Join-Path $packageRoot "README.md") -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", "Version: $Version",
"CreatedUtc: $((Get-Date).ToUniversalTime().ToString("o"))", "CreatedUtc: $((Get-Date).ToUniversalTime().ToString("o"))",
"SourceExe: $resolvedExePath", "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" "Excludes: tools, docs, reports, build, dist, .git"
) | Set-Content -LiteralPath $manifestPath -Encoding UTF8 ) | Set-Content -LiteralPath $manifestPath -Encoding UTF8