diff --git a/CMakeLists.txt b/CMakeLists.txt index 64507d5..886bb0c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -50,6 +50,20 @@ qt_add_executable(QtDesktopPet src/config/ConfigManager.cpp src/config/SecretStore.h src/config/SecretStore.cpp + src/fileops/FileBackupManager.h + src/fileops/FileBackupManager.cpp + src/fileops/FileOperationManager.h + src/fileops/FileOperationManager.cpp + src/fileops/FileOperationTypes.h + src/fileops/FileSandbox.h + src/fileops/FileSandbox.cpp + src/launcher/AppDiscovery.h + src/launcher/AppDiscovery.cpp + src/launcher/AppLaunchManager.h + src/launcher/AppLaunchManager.cpp + src/launcher/AppLaunchStore.h + src/launcher/AppLaunchStore.cpp + src/launcher/AppLaunchTypes.h src/notification/NotificationDispatcher.h src/notification/NotificationDispatcher.cpp src/reminder/ReminderCommandHandler.h @@ -68,6 +82,8 @@ qt_add_executable(QtDesktopPet src/reminder/ReminderTypes.cpp src/state/PetStateMachine.h src/state/PetStateMachine.cpp + src/system/StartupManager.h + src/system/StartupManager.cpp src/tray/TrayController.h src/tray/TrayController.cpp src/ui/ChatBubble.h @@ -86,6 +102,24 @@ qt_add_executable(QtDesktopPet src/util/Logger.cpp src/util/ResourcePaths.h src/util/ResourcePaths.cpp + src/weather/WeatherConfig.h + src/weather/WeatherManager.h + src/weather/WeatherManager.cpp + src/weather/WeatherParser.h + src/weather/WeatherParser.cpp + src/weather/WeatherStore.h + src/weather/WeatherStore.cpp + src/weather/WeatherSummaryFormatter.h + src/weather/WeatherSummaryFormatter.cpp + src/weather/WeatherTypes.h + src/web/WebCapabilityDetector.h + src/web/WebCapabilityDetector.cpp + src/web/WebChatManager.h + src/web/WebChatManager.cpp + src/web/WebChatTypes.h + src/web/WebConfig.h + src/web/WebStore.h + src/web/WebStore.cpp ) target_compile_definitions(QtDesktopPet diff --git a/README.dev.md b/README.dev.md new file mode 100644 index 0000000..85d8832 --- /dev/null +++ b/README.dev.md @@ -0,0 +1,578 @@ +# QtDesktopPet + +QtDesktopPet 是一个基于 Qt Widgets / C++17 的 Windows 桌面宠物项目,当前已具备多状态 PNG 帧动画、托盘控制、角色包导入与切换、用户自定义大模型对话、设置面板和 Windows 发布打包能力。项目现阶段重点是完善稳定性、性能回归、角色管理和发布体验。 + +## 当前状态 + +已实现: + +- 透明无边框桌宠窗口 +- 鼠标拖动 +- 右键菜单退出和状态测试 +- 置顶切换 +- `resources/characters/shiroko` 默认角色包读取 +- PNG 序列帧动画播放 +- `idle` / `talk` / `think` / `sleep` / `happy` / `drag` / `error` 状态 +- 托盘显示、隐藏、退出 +- 单实例限制,重复启动会唤醒已有实例 +- 隐藏时暂停动画,显示时恢复动画 +- 保存窗口位置、置顶、缩放和性能设置 +- 文件日志和基础轮转 +- 设置窗口按当前屏幕居中弹出 +- 应用设置:缩放、性能模式、隐藏暂停、懒加载 +- 状态级动画预热和 LRU 缓存卸载 +- AI Provider 分组配置 +- 设置页内 AI 连通性测试 +- Windows DPAPI 加密保存 API Key +- 非 Windows 环境经用户确认后明文保存 API Key +- OpenAI Compatible 聊天请求 +- SSE 流式输出 +- 聊天输入框 +- AI 回复气泡 +- 对话历史面板 +- 内存历史上限、可选本地历史保存、搜索和 Markdown/JSON 导出 +- AI 请求取消和对话清空 +- Google Gemini 原生聊天请求 +- 角色文件夹导入和角色切换 +- 删除用户导入角色 +- 角色导出和打开用户角色目录 +- 本地一次性和重复提醒:聊天创建、查询、取消,重启后 pending 提醒不丢 +- 提醒到点气泡提示、稍后提醒、拖动后延迟提示和隐藏时托盘通知 +- 提醒音效切换、试听、用户 wav 导入和删除 +- 天气查询 v1:Open-Meteo 天气源、城市解析、默认城市、公网 IP 定位兜底和模板回复 +- 本地文件操作 v1:读取文本文件、列出文件夹、复制、备份、重命名 +- 联网模式:输入框开关触发 AI Provider 原生联网能力,支持 OpenAI 官方 Web Search 和 Gemini Google Search grounding +- 应用启动 v1:通过聊天打开已登记应用、开始菜单快捷方式或用户确认选择的 `.exe` +- Windows 发布打包脚本和 Inno Setup 安装器脚本 +- Windows GUI 子系统,Release exe 双击不弹控制台窗口 + +尚未实现: + +- 长期性能压测记录 +- 发布包实机安装/卸载验证 +- 文件操作 zip 打包、删除、覆盖、移动、脚本/命令执行 +- 搜索网页全文抓取、长期缓存和浏览器自动打开网页 +- 应用启动脚本、命令行参数、管理员权限和跨平台应用发现 + +## 技术栈 + +- C++17 +- Qt 6 Widgets +- Qt 6 Network +- Qt 6 Multimedia +- CMake +- PNG 图片序列帧 +- JSON 配置文件 +- Windows 10 / Windows 11 优先 + +## 构建 + +推荐环境: + +- Qt 6.5.3 +- CMake 3.20+ +- Ninja +- MinGW 11.2.0 或已配置好的 Qt MSVC Kit + +MinGW 示例: + +```powershell +cmake -S . -B build/mingw-debug -G Ninja ` + -DCMAKE_BUILD_TYPE=Debug ` + -DCMAKE_PREFIX_PATH=D:/Qt/6.5.3/mingw_64 ` + -DCMAKE_C_COMPILER=D:/Qt/Tools/mingw1120_64/bin/gcc.exe ` + -DCMAKE_CXX_COMPILER=D:/Qt/Tools/mingw1120_64/bin/g++.exe + +cmake --build build/mingw-debug +``` + +如果使用 Qt Creator,也可以直接打开根目录 `CMakeLists.txt`,选择合适的 Qt Kit 后构建。 + +## 应用图标 + +当前应用图标位于: + +```text +resources/icons/app_icon.ico +resources/icons/app_icon_1024.png +``` + +`app_icon.ico` 用于窗口图标、托盘图标和 Windows exe 资源图标;托盘图标加载失败时会回退到默认角色包的 `preview.png`。`app_icon_1024.png` 作为高分辨率源图保留。 +运行时会优先读取可执行文件同级的 `resources/icons/`,找不到时回退到源码目录下的 `resources/icons/`。Windows exe 图标需要重新构建后生效。 + +## 角色包 + +当前默认角色包位于: + +```text +resources/characters/shiroko/ +``` + +内置角色包按 `resources/characters//` 组织。用户导入的角色包不会写入安装目录,而是复制到用户数据目录: + +```text +QStandardPaths::AppDataLocation/characters// +``` + +角色包基本结构: + +```text +resources/characters/shiroko/ + character.json + preview.png + idle/ + talk/ + think/ + sleep/ + happy/ + drag/ + error/ +``` + +当前素材版本为 `2.1.0-stable`,所有帧使用 512x512 透明画布。当前实现会读取当前角色包的各状态配置,并按 `character.json` 中的 FPS 播放。 +运行时会合并内置角色和用户导入角色;内置资源优先读取可执行文件同级的 `resources/characters/`,找不到时回退到源码目录下的 `resources/characters/`。 + +角色导入: + +- 只支持导入本地文件夹,不支持 zip +- 导入前先验证源文件夹;验证失败只弹窗提示,不复制、不创建、不覆盖文件 +- 验证通过后复制到用户数据目录 +- 角色 id 优先读取 `character.json` 的 `id`;为空时使用文件夹名 +- 角色 id 只允许 ASCII 字母、数字、点、下划线和短横线,且不能以点开头或结尾 +- 用户角色同名时会询问是否覆盖 +- 内置角色 id 不能被导入包覆盖 +- 验证要求:`character.json` 可解析、id 安全、存在 `idle` 和 `defaultState`、状态路径安全、fps 合法、每个声明状态至少有一张可读 PNG +- 如果 `base.anchorY + bubble.offsetY` 计算出的气泡锚点明显偏低,导入时会提示用户检查配置,但不强制阻止导入 +- 只允许删除用户导入角色;选择内置角色删除时只会提示“不能删除内置角色”,不做文件操作 +- 设置页支持导出当前选择的角色到用户选择目录,内置角色和用户角色都可以导出副本 +- 导出目标目录已存在时会二次确认;不确认时不会覆盖 +- 设置页支持打开用户角色目录,便于检查导入角色文件 + +懒加载现状: + +- `enableLazyLoad=true` 时,启动阶段只建立状态到帧路径的索引 +- 某个状态首次播放时加载该状态的 PNG 帧 +- 启动后会在主线程按批次预热常用状态,避免一次性加载全部帧 +- 已加载状态按状态级 LRU 策略管理,超过动画缓存上限时卸载非保护状态 +- 单轮预热不会反复重新加载刚被 LRU 卸载的状态,避免缓存上限较低时出现加载/卸载循环 +- 隐藏到托盘时可释放非保护动画缓存 +- `enableLazyLoad=false` 时仍保持启动阶段加载全部状态帧的兼容行为 + +## 天气查询 + +当前支持通过聊天输入查询基础天气,例如: + +```text +西安天气怎么样 +明天西安天气怎么样 +后天纽约天气 +未来三天北京天气 +今天天气怎么样 +``` + +天气查询使用 Open-Meteo Forecast API 和 Open-Meteo Geocoding API,不需要 API Key。回复采用稳定模板,不依赖 AI 润色。支持当前/今天、明天、后天和未来 1-3 天基础天气;空气质量、天气预警、天气提醒联动、复杂小时级降雨判断和穿衣指数暂不支持。 + +当前 v1 推荐使用市级城市名。用户仍可输入天气源可识别的中文或英文地名,但区县、乡镇、街道不保证精确识别,可能无法匹配,或被匹配到上级/同名城市。当前会读取天气源返回的前 5 个地理编码候选;如果存在同名城市,仍使用第一个结果查询天气,并在回复中提示当前使用的城市和其他候选。 + +城市来源优先级: +- 用户输入明确城市 +- 设置页“天气”中的默认城市 +- 默认城市为空且开启开关时,根据公网 IP 定位判断城市 + +当使用设置页默认城市或公网 IP 定位时,回复会明确说明城市来源。公网 IP 定位使用 ipapi.co;如不希望请求该服务,可在设置页关闭“无默认城市时根据公网 IP 判断城市”。 + +设置页“天气”中提供“测试默认城市”按钮,只验证输入框当前城市会匹配到哪个城市、行政区和国家,不自动保存配置;最终保存仍由设置页 Save 控制。 + +天气配置保存到: + +```text +QStandardPaths::AppConfigLocation/weather_config.json +``` + +天气配置损坏时会备份为: + +```text +weather_config.broken.yyyyMMdd-HHmmss.json +``` + +天气查询由独立 `src/weather/` 模块处理,`PetWindow` 只负责展示查询状态和结果。AI 正在请求或流式回复时不会启动天气查询,以避免覆盖 AI 气泡。 + +## 聊天历史管理 + +聊天记录默认只保存在内存中。设置页“聊天”中开启“保存聊天记录到本地”后,会保存到: + +```text +QStandardPaths::AppConfigLocation/conversation_history.json +``` + +历史管理能力: + +- 支持按关键词搜索当前聊天历史 +- 支持按 Provider 和模型筛选;旧历史缺少元数据时会按未知处理 +- 支持导出当前筛选结果为 Markdown +- 支持导出当前筛选结果为 JSON +- 清空聊天记录需要确认,并会清空当前内存和本地保存记录 +- 导出内容不包含 API Key 或 AI 配置 +- 日志不会记录完整聊天正文 + +## 定时提醒和音效 + +当前支持通过聊天输入创建一次性和重复本地提醒,例如: + +```text +10分钟后提醒我喝水 +半小时后提醒我休息 +一个半小时后提醒我喝水 +明天9点提醒我开会 +后天9点提醒我开会 +6月3日9点提醒我提交 +下周一上午10点提醒我周会 +每天9点提醒我打卡 +每天提醒我9点打卡 +每日晚上8点提醒我吃药 +每周一上午10点提醒我周会 +每周一提醒我上午10点周会 +每星期五下午3点提醒我提交周报 +每月3号9点提醒我交报告 +每月3号提醒我9点交报告 +提醒列表 +取消喝水提醒 +``` + +提醒数据保存到: + +```text +QStandardPaths::AppConfigLocation/reminders.json +``` + +提醒数据使用原子写入,写入失败时不会触发到点 UI,也不会覆盖旧的有效提醒文件。已触发和已取消记录会写入 `finishedAt`;旧版数据没有该字段时按 `remindAt` 兼容读取。 + +提醒调度保留最近提醒的精确 timer,同时每 60 秒做一次兜底扫描;程序显示、外部激活或系统睡眠唤醒后,都会重新检查已到期 pending 提醒。 + +设置页支持编辑 pending 提醒的标题、下一次时间和重复规则;已触发/已取消历史只读。历史记录默认只保留最近 20 天,设置页“清理20天前历史”只删除超过 20 天的已触发/已取消记录,不影响 pending。 + +提醒文件损坏时会备份为: + +```text +reminders.broken.yyyyMMdd-HHmmss.json +``` + +内置提醒音效位于: + +```text +resources/sounds/reminders/ +``` + +用户导入的提醒音效保存到: + +```text +QStandardPaths::AppDataLocation/sounds/reminders/ +``` + +音效规则: + +- 默认音效为 `reminder_default` +- 提醒触发时使用当前设置页选择的全局音效;修改音效后对所有未触发提醒立即生效 +- 内置音效可切换、可试听,但不能在设置页删除 +- 用户音效只支持导入 PCM wav +- 用户导入音效可切换、可试听、可删除 +- 删除当前用户音效后会回退到 `reminder_default` + +触发规则: + +- 桌宠可见时显示气泡,不发系统通知 +- 桌宠隐藏时发 Windows 托盘通知,不在下次显示时补气泡 +- AI 正在请求或流式回复时,按隐藏场景处理:播放音效并发 Windows 托盘通知,不显示气泡 +- 托盘或系统通知后端不可用时只记录日志,不补气泡 +- 用户拖动中不打断 `drag`,拖动结束后显示气泡 +- 多条提醒同时触发时,可见状态下会按队列逐条展示 +- 桌宠可见触发时显示 `知道了` 和 `5分钟后再提醒` +- `5分钟后再提醒` 会创建一条新的一次性提醒,不影响原重复规则 +- 重复提醒支持 `每天 / 每周 / 每月`;`工作日 / 每两天 / 每月最后一天 / 自定义间隔 / 农历` 等复杂规则暂不支持 +- 每月 31 号这类规则会跳过不存在该日期的月份,寻找下一个有效月份 +- 用户音效删除仅允许删除用户音效目录内的安全 sound id,内置音效和非法路径不会被删除 + +## 本地文件操作 + +本地文件操作通过聊天意图触发,但不会直接使用聊天文本里的路径。所有文件和文件夹都必须由用户在系统文件选择框中主动选择。 + +当前 v1 支持: + +- 读取用户选择的常见文本文件:txt、md、log、json、csv、ini、xml、yaml +- 列出用户选择的文件夹,最多显示前 200 项 +- 复制用户选择的文件到用户选择的目标文件夹 +- 为用户选择的文件创建同目录备份 +- 重命名用户选择的文件 + +安全规则: + +- 写操作会先展示操作计划并要求确认 +- 不覆盖已有文件 +- 不删除文件 +- 不移动大量文件 +- 不执行脚本或命令 +- 不访问 Windows 系统目录 +- 拒绝符号链接或符号链接目录内的路径 + +暂不支持: + +- zip 打包 +- 删除、覆盖、移动 +- 修改源码 +- 执行脚本或命令 + +## 联网模式 + +联网模式不是搜索引擎聚合器。用户在输入框打开“联网”开关后,普通聊天会进入当前 AI Provider 的原生联网能力: + +- OpenAI 官方 Provider:使用 Responses API Web Search。 +- Google Gemini Provider:使用 Gemini Google Search grounding。 +- DeepSeek 官方 API:当前不提供托管联网搜索工具,显示“不支持原生联网”。 +- Custom / 第三方 OpenAI-Compatible:默认无法确认联网能力,不会尝试旧搜索页抓取。 + +配置保存到: + +```text +QStandardPaths::AppConfigLocation/web_config.json +``` + +交互规则: + +- 联网开关默认关闭,避免隐私和费用意外。 +- 设置页“联网模式”只显示能力状态、开关记忆、默认开关、超时、来源展示和测试联网模式。 +- 当前模型不支持联网时,输入框打开联网后会明确提示,不发起伪联网请求。 +- 支持联网的 Provider 返回来源时会展示来源;模型判断无需联网时允许无来源,并显示“模型未使用联网来源”。 +- 旧 `search_config.json` 已废弃,新版不会读取、迁移或写入。 + +后续可扩展: + +- 更多 AI Provider 的原生联网适配。 +- 结构化搜索 API 或自建联网后端。 +- 更细粒度的来源证据摘录和引用 UI。 + +## 应用启动 + +应用启动是独立能力,不归入本地文件操作。可通过聊天输入: + +```text +打开 Codex +启动酷狗音乐 +帮我打开 VSCode +``` + +配置保存到: + +```text +QStandardPaths::AppConfigLocation/launcher_config.json +``` + +设置页“应用启动”支持: + +- 启用或关闭应用启动 +- 未知应用允许用户手动选择 `.exe` +- 登记应用名称和别名 +- 编辑、删除、测试启动已登记应用 + +安全边界: + +- 启动前始终二次确认,确认内容包含应用名、路径、工作目录和来源 +- 只允许启动 `.exe` 或开始菜单 `.lnk` 快捷方式 +- 不执行 `.bat` / `.cmd` / `.ps1` / `.vbs` / `.js` / `.msi` +- 不执行聊天文本里的命令 +- 不拼接聊天文本参数 +- 不以管理员权限启动 +- 手选 `.exe` 后可选择记住为当前名称,后续走登记应用匹配 + +## 配置和日志 + +应用配置保存到 Qt 标准配置目录: + +```text +QStandardPaths::AppConfigLocation/app_config.json +``` + +配置损坏时会备份为带时间戳的文件: + +```text +app_config.broken.yyyyMMdd-HHmmss.json +ai_config.broken.yyyyMMdd-HHmmss.json +conversation_history.broken.yyyyMMdd-HHmmss.json +weather_config.broken.yyyyMMdd-HHmmss.json +web_config.broken.yyyyMMdd-HHmmss.json +launcher_config.broken.yyyyMMdd-HHmmss.json +``` + +日志输出到文件,不输出到控制台: + +```text +QStandardPaths::AppConfigLocation/logs/QtDesktopPet.log +``` + +如果 Qt 无法取得标准配置目录,则回退到当前工作目录下的 `logs/QtDesktopPet.log`。 + +日志轮转规则: + +- 单个日志文件超过 2MB 时轮转 +- 最多保留 3 个旧日志文件 +- 文件名为 `QtDesktopPet.log.1`、`QtDesktopPet.log.2`、`QtDesktopPet.log.3` + +## 发布打包 + +仓库提供 Windows Release 打包脚本。脚本不负责 CMake 构建,先手动完成 Release 构建,再把 exe 路径传给脚本: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File tools/package_release.ps1 -ExePath build/release/QtDesktopPet.exe +``` + +脚本会生成目录包和 zip: + +```text +dist/QtDesktopPet--windows-x64/ +dist/QtDesktopPet--windows-x64.zip +``` + +发布目录包含: + +```text +QtDesktopPet.exe +Qt runtime +resources/characters/ +resources/icons/ +resources/sounds/ +LICENSE +README.md +``` + +脚本会调用 `windeployqt.exe` 收集 Qt 运行库。若当前 PATH 找不到 `windeployqt.exe`,需要指定 Qt bin 目录下的工具路径: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File tools/package_release.ps1 ` + -ExePath build/release/QtDesktopPet.exe ` + -WindeployQtPath D:\Qt\6.x.x\msvcXXXX_64\bin\windeployqt.exe +``` + +生成 Inno Setup 安装器: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File tools/package_release.ps1 ` + -ExePath build/release/QtDesktopPet.exe ` + -BuildInstaller +``` + +安装器最终默认输出到项目根目录: + +```text +QtDesktopPet--windows-x64-setup.exe +``` + +脚本会先让 Inno Setup 输出到当前盘符下的纯 ASCII 临时目录,例如 `D:\QtDesktopPetInstallerOutput`,再把最终安装包复制回项目根目录,避免中文项目路径下出现 `EndUpdateResource failed (5)`。如果需要改变最终安装包目录,可传入 `-InstallerOutputDir`: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File tools/package_release.ps1 ` + -ExePath build/release/QtDesktopPet.exe ` + -BuildInstaller ` + -InstallerOutputDir D:\ReleaseOutput +``` + +如果需要改变 Inno Setup 的临时编译输出目录,可传入 `-InstallerWorkOutputDir`。 + +本地生成的安装包也可以集中放到 `release_packages/`: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File tools/package_release.ps1 ` + -ExePath build/release/QtDesktopPet.exe ` + -BuildInstaller ` + -InstallerOutputDir release_packages +``` + +`dist/` 和 `release_packages/` 都是本地发布产物目录,不进入 Git。 + +脚本默认优先查找: + +```text +D:\Inno Setup 7\ISCC.exe +D:\Inno Setup 6\ISCC.exe +C:\Program Files (x86)\Inno Setup 7\ISCC.exe +C:\Program Files (x86)\Inno Setup 6\ISCC.exe +``` + +如果 Inno Setup 安装在其他位置,可传入 `-InnoCompilerPath`。 + +安装器页面提供可选项: + +- 创建桌面快捷方式,默认不勾选。 +- 开机自启动,默认不勾选;勾选后写入当前用户 `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`,不需要管理员权限。 + +应用内设置页的“应用设置”页也提供“开机自启动”开关,保存后会立即写入或移除同一个当前用户 Run 项。卸载时会清理该 Run 项,避免卸载后残留自启动入口。 + +安装器卸载时会询问是否同时删除用户配置、导入角色、聊天记录和日志;用户确认后会在卸载完成阶段删除当前用户下的 QtDesktopPet 数据目录。 + +## 开发诊断 + +仓库提供开发用性能采样脚本,不进入普通用户发布包: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File tools/perf_sample.ps1 +``` + +默认采样当前 `QtDesktopPet` 进程 5 分钟,每 5 秒一条,CSV 输出到: + +```text +reports/perf/ +``` + +`reports/perf/` 已加入 `.gitignore`。稳定性检查记录模板见: + +```text +docs/performance_stability_check.md +``` + +发布包应排除 `tools/`、`docs/`、`reports/`、`build/`、`dist/`、`release_packages/` 和 `.git/`,只保留运行必需文件、`resources/characters/`、`resources/icons/`、`resources/sounds/`、`LICENSE` 和必要说明。 + +## AI 配置和聊天 + +当前正式聊天支持 OpenAI Compatible 和 Google Gemini 两类协议。已提供以下 Provider 配置入口: + +- OpenAI +- Google +- DeepSeek +- Custom + +其中 OpenAI、DeepSeek、Custom 走 OpenAI Compatible 形式配置;Google 走 Gemini 原生 REST 接口。旧版保存过的已废弃 Provider 配置会在读取 AI 配置时清理,废弃 Provider 被选中时会回退为 `custom`。 + +已支持: + +- 用户自定义 Base URL +- 用户自定义 API Key +- 用户自定义 Model +- 用户自定义 Path +- 超时、Temperature、Max Tokens +- 流式输出 +- Google Gemini `generateContent` / `streamGenerateContent` +- 请求中切换 `think` +- 收到首段输出后切换并保持 `talk` +- 失败时切换 `error` +- API Key 不写入日志,不在错误提示中完整显示 +- 对话历史面板记录用户消息和 AI 最终回复 + +AI 测试入口已从角色右键菜单移除,并迁移到设置页。 + +## 隐私说明 + +程序只会把用户消息发送到用户自己配置的接口。用户需要自行判断第三方代理、中转服务或自建服务是否可信。项目不会默认承诺第三方接口的隐私安全。 + +日志会记录请求诊断信息,例如 Provider、Base URL 主机、Path、HTTP 状态码、响应大小、错误摘要等;日志不应记录完整 API Key、Authorization Header、完整消息正文或完整错误响应正文。错误响应只保留脱敏摘要。 + +当前对话历史默认保存在内存中,已支持内存历史上限、请求上下文截取和可选本地历史保存;相关上限可在设置页调整。 + +## 素材版权说明 + +源码采用 MIT License。 + +角色素材、图片、图标等资源需要单独确认授权。当前 `shiroko` 素材用于桌宠加载器、动画和状态切换测试;在公开发布或正式分发前,需要确认素材的版权、再分发权限和适用范围。 + +用户导入或替换的素材,其版权责任由用户自行承担。 + +## 许可证 + +项目源码使用 MIT License,见 [LICENSE](LICENSE)。 diff --git a/README.md b/README.md index 9115cce..80d22d2 100644 --- a/README.md +++ b/README.md @@ -1,297 +1,127 @@ # QtDesktopPet -QtDesktopPet 是一个基于 Qt Widgets / C++17 的 Windows 桌面宠物项目,当前已具备多状态 PNG 帧动画、托盘控制、角色包导入与切换、用户自定义大模型对话、设置面板和 Windows 发布打包能力。项目现阶段重点是完善稳定性、性能回归、角色管理和发布体验。 +一个基于 **Qt 6 Widgets / C++17** 的 Windows 桌面宠物项目。它提供透明桌宠窗口、PNG 序列帧动画、多状态切换、托盘控制、AI 对话、本地提醒、天气查询、简单文件操作和本地应用启动等能力。 -## 当前状态 +> 当前仓库仍处于活跃开发阶段。角色素材、图标和音效的再分发权限需要在正式公开发布前单独确认。 -已实现: +![Preview](resources/characters/shiroko/preview.png) -- 透明无边框桌宠窗口 -- 鼠标拖动 -- 右键菜单退出和状态测试 -- 置顶切换 -- `resources/characters/shiroko` 默认角色包读取 -- PNG 序列帧动画播放 -- `idle` / `talk` / `think` / `sleep` / `happy` / `drag` / `error` 状态 -- 托盘显示、隐藏、退出 -- 单实例限制,重复启动会唤醒已有实例 -- 隐藏时暂停动画,显示时恢复动画 -- 保存窗口位置、置顶、缩放和性能设置 -- 文件日志和基础轮转 -- 设置窗口按当前屏幕居中弹出 -- 应用设置:缩放、性能模式、隐藏暂停、懒加载 -- 状态级动画预热和 LRU 缓存卸载 -- AI Provider 分组配置 -- 设置页内 AI 连通性测试 -- Windows DPAPI 加密保存 API Key -- 非 Windows 环境经用户确认后明文保存 API Key -- OpenAI Compatible 聊天请求 -- SSE 流式输出 -- 聊天输入框 -- AI 回复气泡 -- 对话历史面板 -- 内存历史上限和可选本地历史保存 -- AI 请求取消和对话清空 -- Google Gemini 原生聊天请求 -- 角色文件夹导入和角色切换 -- 删除用户导入角色 -- 本地一次性和重复提醒:聊天创建、查询、取消,重启后 pending 提醒不丢 -- 提醒到点气泡提示、稍后提醒、拖动后延迟提示和隐藏时托盘通知 -- 提醒音效切换、试听、用户 wav 导入和删除 -- Windows 发布打包脚本和 Inno Setup 安装器脚本 -- Windows GUI 子系统,Release exe 双击不弹控制台窗口 +## Features -尚未实现: +- 透明无边框桌宠窗口,支持拖动、置顶、托盘隐藏和单实例唤醒。 +- 多状态 PNG 序列帧动画:`idle`、`talk`、`think`、`sleep`、`happy`、`drag`、`error`。 +- 角色包导入、切换、导出和用户角色目录管理。 +- AI 对话: + - OpenAI-compatible API + - Google Gemini API + - DeepSeek / Custom Provider 配置 + - 流式输出、请求取消、对话历史面板 + - Windows DPAPI 加密保存 API Key +- 联网模式: + - 输入框“联网”开关 + - OpenAI 官方 Responses API Web Search + - Gemini Google Search grounding + - DeepSeek / Custom 默认提示不支持或无法确认原生联网 +- 本地提醒: + - 一次性提醒 + - 每天 / 每周 / 每月重复提醒 + - 提醒音效导入、试听、切换 + - 桌宠可见时气泡提示,隐藏或 AI 忙时系统通知 +- 天气查询: + - Open-Meteo Forecast API + - Open-Meteo Geocoding API + - 默认城市、公网 IP 定位兜底、多候选提示 +- 本地文件操作 v1: + - 读取文本文件 + - 列出文件夹 + - 复制、备份、重命名 + - 写操作前二次确认 +- 应用启动 v1: + - 聊天触发打开本地应用 + - 支持已登记应用、开始菜单快捷方式和用户手选 `.exe` + - 启动前二次确认 +- Windows 发布脚本和 Inno Setup 安装器脚本。 -- 角色导出和更完整的管理界面 -- 对话历史导出/管理界面 -- 长期性能压测记录 -- 发布包实机安装/卸载验证 +## Platform -## 技术栈 +当前主要目标平台是 Windows 10 / Windows 11。 + +项目中已有部分跨平台基础代码,但托盘通知、开机自启动、应用发现和安装器体验目前按 Windows 优先实现。 + +## Tech Stack - C++17 - Qt 6 Widgets - Qt 6 Network - Qt 6 Multimedia - CMake -- PNG 图片序列帧 -- JSON 配置文件 -- Windows 10 / Windows 11 优先 +- JSON 配置 +- PNG 序列帧动画 +- Inno Setup -## 构建 +## Repository Layout -推荐环境: +```text +. +├── CMakeLists.txt +├── main.cpp +├── installer/ # Inno Setup script +├── resources/ +│ ├── characters/ # Built-in character packages +│ ├── icons/ +│ └── sounds/ +├── src/ +│ ├── ai/ # AI providers and conversation state +│ ├── assistant/ # Intent routing and command dispatch +│ ├── character/ # Character package loading and animation +│ ├── config/ # Config persistence +│ ├── fileops/ # Local file operations +│ ├── launcher/ # Local application launcher +│ ├── notification/ # Notification dispatch +│ ├── reminder/ # Reminder parser/store/scheduler/sounds +│ ├── state/ # Pet state machine +│ ├── system/ # Windows startup integration +│ ├── tray/ # System tray controller +│ ├── ui/ # Widgets and main pet window +│ ├── util/ +│ ├── weather/ +│ └── web/ # AI-native web mode +└── tools/ # Packaging and diagnostic scripts +``` -- Qt 6.5.3 +## Build + +Recommended environment: + +- Qt 6.5+ - CMake 3.20+ - Ninja -- MinGW 11.2.0 或已配置好的 Qt MSVC Kit +- MinGW 11.2.0 or a configured Qt MSVC Kit -MinGW 示例: +Example with MinGW: ```powershell -cmake -S . -B build/mingw-debug -G Ninja ` - -DCMAKE_BUILD_TYPE=Debug ` +cmake -S . -B build/mingw-release -G Ninja ` + -DCMAKE_BUILD_TYPE=Release ` -DCMAKE_PREFIX_PATH=D:/Qt/6.5.3/mingw_64 ` -DCMAKE_C_COMPILER=D:/Qt/Tools/mingw1120_64/bin/gcc.exe ` -DCMAKE_CXX_COMPILER=D:/Qt/Tools/mingw1120_64/bin/g++.exe -cmake --build build/mingw-debug +cmake --build build/mingw-release ``` -如果使用 Qt Creator,也可以直接打开根目录 `CMakeLists.txt`,选择合适的 Qt Kit 后构建。 +Qt Creator users can open `CMakeLists.txt` directly and build with a matching Qt Kit. -## 应用图标 +## Package -当前应用图标位于: - -```text -resources/icons/app_icon.ico -resources/icons/app_icon_1024.png -``` - -`app_icon.ico` 用于窗口图标、托盘图标和 Windows exe 资源图标;托盘图标加载失败时会回退到默认角色包的 `preview.png`。`app_icon_1024.png` 作为高分辨率源图保留。 -运行时会优先读取可执行文件同级的 `resources/icons/`,找不到时回退到源码目录下的 `resources/icons/`。Windows exe 图标需要重新构建后生效。 - -## 角色包 - -当前默认角色包位于: - -```text -resources/characters/shiroko/ -``` - -内置角色包按 `resources/characters//` 组织。用户导入的角色包不会写入安装目录,而是复制到用户数据目录: - -```text -QStandardPaths::AppDataLocation/characters// -``` - -角色包基本结构: - -```text -resources/characters/shiroko/ - character.json - preview.png - idle/ - talk/ - think/ - sleep/ - happy/ - drag/ - error/ -``` - -当前素材版本为 `2.1.0-stable`,所有帧使用 512x512 透明画布。当前实现会读取当前角色包的各状态配置,并按 `character.json` 中的 FPS 播放。 -运行时会合并内置角色和用户导入角色;内置资源优先读取可执行文件同级的 `resources/characters/`,找不到时回退到源码目录下的 `resources/characters/`。 - -角色导入: - -- 只支持导入本地文件夹,不支持 zip -- 导入前先验证源文件夹;验证失败只弹窗提示,不复制、不创建、不覆盖文件 -- 验证通过后复制到用户数据目录 -- 角色 id 优先读取 `character.json` 的 `id`;为空时使用文件夹名 -- 角色 id 只允许 ASCII 字母、数字、点、下划线和短横线,且不能以点开头或结尾 -- 用户角色同名时会询问是否覆盖 -- 内置角色 id 不能被导入包覆盖 -- 验证要求:`character.json` 可解析、id 安全、存在 `idle` 和 `defaultState`、状态路径安全、fps 合法、每个声明状态至少有一张可读 PNG -- 如果 `base.anchorY + bubble.offsetY` 计算出的气泡锚点明显偏低,导入时会提示用户检查配置,但不强制阻止导入 -- 只允许删除用户导入角色;选择内置角色删除时只会提示“不能删除内置角色”,不做文件操作 - -懒加载现状: - -- `enableLazyLoad=true` 时,启动阶段只建立状态到帧路径的索引 -- 某个状态首次播放时加载该状态的 PNG 帧 -- 启动后会在主线程按批次预热常用状态,避免一次性加载全部帧 -- 已加载状态按状态级 LRU 策略管理,超过动画缓存上限时卸载非保护状态 -- 单轮预热不会反复重新加载刚被 LRU 卸载的状态,避免缓存上限较低时出现加载/卸载循环 -- 隐藏到托盘时可释放非保护动画缓存 -- `enableLazyLoad=false` 时仍保持启动阶段加载全部状态帧的兼容行为 - -## 定时提醒和音效 - -当前支持通过聊天输入创建一次性和重复本地提醒,例如: - -```text -10分钟后提醒我喝水 -半小时后提醒我休息 -一个半小时后提醒我喝水 -明天9点提醒我开会 -后天9点提醒我开会 -6月3日9点提醒我提交 -下周一上午10点提醒我周会 -每天9点提醒我打卡 -每天提醒我9点打卡 -每日晚上8点提醒我吃药 -每周一上午10点提醒我周会 -每周一提醒我上午10点周会 -每星期五下午3点提醒我提交周报 -每月3号9点提醒我交报告 -每月3号提醒我9点交报告 -提醒列表 -取消喝水提醒 -``` - -提醒数据保存到: - -```text -QStandardPaths::AppConfigLocation/reminders.json -``` - -提醒数据使用原子写入,写入失败时不会触发到点 UI,也不会覆盖旧的有效提醒文件。已触发和已取消记录会写入 `finishedAt`;旧版数据没有该字段时按 `remindAt` 兼容读取。 - -提醒调度保留最近提醒的精确 timer,同时每 60 秒做一次兜底扫描;程序显示、外部激活或系统睡眠唤醒后,都会重新检查已到期 pending 提醒。 - -设置页支持编辑 pending 提醒的标题、下一次时间和重复规则;已触发/已取消历史只读。历史记录默认只保留最近 20 天,设置页“清理20天前历史”只删除超过 20 天的已触发/已取消记录,不影响 pending。 - -提醒文件损坏时会备份为: - -```text -reminders.broken.yyyyMMdd-HHmmss.json -``` - -内置提醒音效位于: - -```text -resources/sounds/reminders/ -``` - -用户导入的提醒音效保存到: - -```text -QStandardPaths::AppDataLocation/sounds/reminders/ -``` - -音效规则: - -- 默认音效为 `reminder_default` -- 提醒触发时使用当前设置页选择的全局音效;修改音效后对所有未触发提醒立即生效 -- 内置音效可切换、可试听,但不能在设置页删除 -- 用户音效只支持导入 PCM wav -- 用户导入音效可切换、可试听、可删除 -- 删除当前用户音效后会回退到 `reminder_default` - -触发规则: - -- 桌宠可见时显示气泡,不发系统通知 -- 桌宠隐藏时发 Windows 托盘通知,不在下次显示时补气泡 -- AI 正在请求或流式回复时,按隐藏场景处理:播放音效并发 Windows 托盘通知,不显示气泡 -- 托盘或系统通知后端不可用时只记录日志,不补气泡 -- 用户拖动中不打断 `drag`,拖动结束后显示气泡 -- 多条提醒同时触发时,可见状态下会按队列逐条展示 -- 桌宠可见触发时显示 `知道了` 和 `5分钟后再提醒` -- `5分钟后再提醒` 会创建一条新的一次性提醒,不影响原重复规则 -- 重复提醒支持 `每天 / 每周 / 每月`;`工作日 / 每两天 / 每月最后一天 / 自定义间隔 / 农历` 等复杂规则暂不支持 -- 每月 31 号这类规则会跳过不存在该日期的月份,寻找下一个有效月份 -- 用户音效删除仅允许删除用户音效目录内的安全 sound id,内置音效和非法路径不会被删除 - -## 配置和日志 - -应用配置保存到 Qt 标准配置目录: - -```text -QStandardPaths::AppConfigLocation/app_config.json -``` - -配置损坏时会备份为带时间戳的文件: - -```text -app_config.broken.yyyyMMdd-HHmmss.json -ai_config.broken.yyyyMMdd-HHmmss.json -conversation_history.broken.yyyyMMdd-HHmmss.json -``` - -日志输出到文件,不输出到控制台: - -```text -QStandardPaths::AppConfigLocation/logs/QtDesktopPet.log -``` - -如果 Qt 无法取得标准配置目录,则回退到当前工作目录下的 `logs/QtDesktopPet.log`。 - -日志轮转规则: - -- 单个日志文件超过 2MB 时轮转 -- 最多保留 3 个旧日志文件 -- 文件名为 `QtDesktopPet.log.1`、`QtDesktopPet.log.2`、`QtDesktopPet.log.3` - -## 发布打包 - -仓库提供 Windows Release 打包脚本。脚本不负责 CMake 构建,先手动完成 Release 构建,再把 exe 路径传给脚本: - -```powershell -powershell -NoProfile -ExecutionPolicy Bypass -File tools/package_release.ps1 -ExePath build/release/QtDesktopPet.exe -``` - -脚本会生成目录包和 zip: - -```text -dist/QtDesktopPet--windows-x64/ -dist/QtDesktopPet--windows-x64.zip -``` - -发布目录包含: - -```text -QtDesktopPet.exe -Qt runtime -resources/characters/ -resources/icons/ -resources/sounds/ -LICENSE -README.md -``` - -脚本会调用 `windeployqt.exe` 收集 Qt 运行库。若当前 PATH 找不到 `windeployqt.exe`,需要指定 Qt bin 目录下的工具路径: +After building a Release executable, package it with: ```powershell powershell -NoProfile -ExecutionPolicy Bypass -File tools/package_release.ps1 ` - -ExePath build/release/QtDesktopPet.exe ` - -WindeployQtPath D:\Qt\6.x.x\msvcXXXX_64\bin\windeployqt.exe + -ExePath build/release/QtDesktopPet.exe ``` -生成 Inno Setup 安装器: +To generate the Inno Setup installer: ```powershell powershell -NoProfile -ExecutionPolicy Bypass -File tools/package_release.ps1 ` @@ -299,113 +129,108 @@ powershell -NoProfile -ExecutionPolicy Bypass -File tools/package_release.ps1 ` -BuildInstaller ``` -安装器最终默认输出到项目根目录: +The installer supports optional desktop shortcut creation and optional Windows startup launch. Both are disabled by default. + +## Runtime Data + +Runtime configuration and user data are stored under Qt standard user directories: + +- `QStandardPaths::AppConfigLocation` +- `QStandardPaths::AppDataLocation` + +Examples: + +- AI config: `ai_config.json` +- App config: `app_config.json` +- Conversation history: `conversation_history.json` +- Reminders: `reminders.json` +- Weather config: `weather_config.json` +- Web mode config: `web_config.json` +- Launcher config: `launcher_config.json` + +The app writes rotating logs under: ```text -QtDesktopPet--windows-x64-setup.exe +QStandardPaths::AppConfigLocation/logs/QtDesktopPet.log ``` -脚本会先让 Inno Setup 输出到当前盘符下的纯 ASCII 临时目录,例如 `D:\QtDesktopPetInstallerOutput`,再把最终安装包复制回项目根目录,避免中文项目路径下出现 `EndUpdateResource failed (5)`。如果需要改变最终安装包目录,可传入 `-InstallerOutputDir`: +## AI And Privacy + +QtDesktopPet only sends chat content to the AI endpoint configured by the user. + +Important notes: + +- API keys are not logged. +- Authorization headers are not logged. +- Full user messages and full error bodies should not be logged. +- On Windows, API keys are saved with DPAPI when available. +- Third-party compatible APIs and proxy services are controlled by the user; this project cannot guarantee their privacy behavior. + +## Safety Boundaries + +The project intentionally keeps local automation conservative: + +- File operations require user-selected paths. +- Write operations require confirmation. +- File operations do not execute scripts or commands. +- Application launch only supports `.exe` and Start Menu `.lnk` shortcuts. +- Chat text is not converted into shell commands. +- Startup integration writes only the current user's Windows `Run` entry. + +## Web Mode + +Web mode is an AI-native conversation feature, not a search engine scraper. + +Supported: + +- OpenAI official API with Responses API Web Search. +- Google Gemini API with Google Search grounding. + +Not treated as supported native web access: + +- DeepSeek official API +- Custom OpenAI-compatible endpoints +- Third-party relay APIs + +Unsupported providers show an explicit message instead of falling back to unreliable search-page scraping. + +## Character Packages + +Built-in characters are placed under: + +```text +resources/characters// +``` + +A character package contains: + +```text +character.json +preview.png +idle/ +talk/ +think/ +sleep/ +happy/ +drag/ +error/ +``` + +User-imported characters are copied to the user's app data directory instead of the installation directory. + +## Public Export + +This development workspace may contain internal planning and test documents. To create a clean public GitHub export, use: ```powershell -powershell -NoProfile -ExecutionPolicy Bypass -File tools/package_release.ps1 ` - -ExePath build/release/QtDesktopPet.exe ` - -BuildInstaller ` - -InstallerOutputDir D:\ReleaseOutput +powershell -NoProfile -ExecutionPolicy Bypass -File tools/prepare_github_export.ps1 ` + -OutputDir D:\DesktopPet-github-export ``` -如果需要改变 Inno Setup 的临时编译输出目录,可传入 `-InstallerWorkOutputDir`。 +The export excludes internal docs, reports, build outputs, release packages, local config, logs and Git metadata. -本地生成的安装包也可以集中放到 `release_packages/`: +## License -```powershell -powershell -NoProfile -ExecutionPolicy Bypass -File tools/package_release.ps1 ` - -ExePath build/release/QtDesktopPet.exe ` - -BuildInstaller ` - -InstallerOutputDir release_packages -``` +Source code is released under the MIT License. See [LICENSE](LICENSE). -`dist/` 和 `release_packages/` 都是本地发布产物目录,不进入 Git。 - -脚本默认优先查找: - -```text -D:\Inno Setup 7\ISCC.exe -D:\Inno Setup 6\ISCC.exe -C:\Program Files (x86)\Inno Setup 7\ISCC.exe -C:\Program Files (x86)\Inno Setup 6\ISCC.exe -``` - -如果 Inno Setup 安装在其他位置,可传入 `-InnoCompilerPath`。 - -安装器卸载时会询问是否同时删除用户配置、导入角色、聊天记录和日志;用户确认后会在卸载完成阶段删除当前用户下的 QtDesktopPet 数据目录。 - -## 开发诊断 - -仓库提供开发用性能采样脚本,不进入普通用户发布包: - -```powershell -powershell -NoProfile -ExecutionPolicy Bypass -File tools/perf_sample.ps1 -``` - -默认采样当前 `QtDesktopPet` 进程 5 分钟,每 5 秒一条,CSV 输出到: - -```text -reports/perf/ -``` - -`reports/perf/` 已加入 `.gitignore`。稳定性检查记录模板见: - -```text -docs/performance_stability_check.md -``` - -发布包应排除 `tools/`、`docs/`、`reports/`、`build/`、`dist/`、`release_packages/` 和 `.git/`,只保留运行必需文件、`resources/characters/`、`resources/icons/`、`resources/sounds/`、`LICENSE` 和必要说明。 - -## AI 配置和聊天 - -当前正式聊天支持 OpenAI Compatible 和 Google Gemini 两类协议。已提供以下 Provider 配置入口: - -- OpenAI -- Google -- DeepSeek -- Custom - -其中 OpenAI、DeepSeek、Custom 走 OpenAI Compatible 形式配置;Google 走 Gemini 原生 REST 接口。旧版保存过的已废弃 Provider 配置会在读取 AI 配置时清理,废弃 Provider 被选中时会回退为 `custom`。 - -已支持: - -- 用户自定义 Base URL -- 用户自定义 API Key -- 用户自定义 Model -- 用户自定义 Path -- 超时、Temperature、Max Tokens -- 流式输出 -- Google Gemini `generateContent` / `streamGenerateContent` -- 请求中切换 `think` -- 收到首段输出后切换并保持 `talk` -- 失败时切换 `error` -- API Key 不写入日志,不在错误提示中完整显示 -- 对话历史面板记录用户消息和 AI 最终回复 - -AI 测试入口已从角色右键菜单移除,并迁移到设置页。 - -## 隐私说明 - -程序只会把用户消息发送到用户自己配置的接口。用户需要自行判断第三方代理、中转服务或自建服务是否可信。项目不会默认承诺第三方接口的隐私安全。 - -日志会记录请求诊断信息,例如 Provider、Base URL 主机、Path、HTTP 状态码、响应大小、错误摘要等;日志不应记录完整 API Key、Authorization Header、完整消息正文或完整错误响应正文。错误响应只保留脱敏摘要。 - -当前对话历史默认保存在内存中,已支持内存历史上限、请求上下文截取和可选本地历史保存;相关上限可在设置页调整。 - -## 素材版权说明 - -源码采用 MIT License。 - -角色素材、图片、图标等资源需要单独确认授权。当前 `shiroko` 素材用于桌宠加载器、动画和状态切换测试;在公开发布或正式分发前,需要确认素材的版权、再分发权限和适用范围。 - -用户导入或替换的素材,其版权责任由用户自行承担。 - -## 许可证 - -项目源码使用 MIT License,见 [LICENSE](LICENSE)。 +Character art, icons, sounds and other media assets may have separate copyright requirements. Confirm asset licensing before public redistribution. diff --git a/docs/QtDesktopPet_后续功能规划与结构审查.md b/docs/QtDesktopPet_后续功能规划与结构审查.md index 670fb7f..c2999e1 100644 --- a/docs/QtDesktopPet_后续功能规划与结构审查.md +++ b/docs/QtDesktopPet_后续功能规划与结构审查.md @@ -32,12 +32,16 @@ - 删除用户导入角色 - 本地一次性/重复提醒、提醒列表、取消提醒和到点通知 - 内置/用户提醒音效切换、导入、删除和试听 +- 天气查询、默认城市、公网 IP 定位兜底和多候选提示 +- 本地文件操作安全入口:读取文本、列目录、复制、备份、重命名 +- 联网模式:输入框开关、OpenAI/Gemini 原生联网、DeepSeek/Custom 不支持提示 +- 本地应用启动:登记应用、开始菜单 / App Paths 发现、手选 `.exe` 和二次确认 - Windows 打包脚本和 Inno Setup 安装器脚本 - Release exe 双击不弹控制台窗口 项目已经从早期 MVP 进入到“可扩展桌面应用原型”阶段,可以开始规划工具能力扩展。 -但是,在正式加入定时提醒、天气、本地文件操作、联网搜索之前,建议先做一轮结构收口。 +但是,在正式加入定时提醒、天气、本地文件操作、联网模式之前,建议先做一轮结构收口。 --- @@ -98,7 +102,7 @@ loadCharacterPackage() 重新构建动画状态并从 idle 状态开始播放 ```text 提醒解析 天气查询 -联网搜索 +联网模式 本地文件读写 AI 工具调度 复杂业务状态管理 @@ -126,7 +130,8 @@ ToolCommandDispatcher 定时提醒 天气查询 本地文件操作 -联网搜索 +联网模式 +本地应用启动 普通 AI 对话 ``` @@ -165,7 +170,7 @@ enum class UserIntentType Reminder, Weather, FileOperation, - Search + LaunchApp }; ``` @@ -178,7 +183,7 @@ IntentRouter 判断意图 ↓ CommandDispatcher 分发 ↓ -ReminderManager / WeatherManager / FileOperationManager / WebSearchManager / ConversationManager +ReminderManager / WeatherManager / FileOperationManager / WebChatManager / AppLaunchManager / ConversationManager ``` 第一版意图识别不需要复杂,规则优先即可。 @@ -192,7 +197,7 @@ ReminderManager / WeatherManager / FileOperationManager / WebSearchManager / Con 推荐: ```text -Reminder > FileOperation > Weather > Search > Chat +Reminder > FileOperation > Weather > LaunchApp > Chat/WebChat ``` 原因: @@ -205,7 +210,7 @@ Reminder > FileOperation > Weather > Search > Chat 可能是文件操作,而不是单纯天气查询。 “搜索一下明天天气” -可能是搜索请求,但如果已有 WeatherTool,应优先走天气工具。 +新版不再作为独立搜索工具处理;如果已有 WeatherTool,应优先走天气工具,否则按普通聊天或输入框联网开关进入 Chat/WebChat。 ``` --- @@ -229,7 +234,7 @@ set(CMAKE_AUTOMOC ON) 当前阶段选择方案 B:继续保持 `CMAKE_AUTOMOC OFF`,新增意图分发模块使用普通 C++ 类和同步返回值,不引入 `Q_OBJECT`。 -后续如果 Reminder / Weather / Search 等模块需要大量跨对象异步信号,再单独评估是否切换到方案 A。 +后续如果 Reminder / Weather / WebChat 等模块需要大量跨对象异步信号,再单独评估是否切换到方案 A。 --- @@ -253,7 +258,7 @@ set(CMAKE_AUTOMOC ON) 天气 API 请求说明 IP 定位隐私说明 本地文件操作权限说明 -联网搜索来源和隐私说明 +联网模式来源和隐私说明 ``` --- @@ -292,7 +297,7 @@ IP 定位隐私说明 阶段 1:定时提醒 阶段 2:天气查询 阶段 3:本地文件操作 -阶段 4:联网搜索 +阶段 4:联网模式 阶段 5:语音对话 / 更复杂 Agent 能力 ``` @@ -301,7 +306,7 @@ IP 定位隐私说明 ```text 1. 天气查询 2. 本地文件操作安全边界 -3. 联网搜索 +3. 联网模式 ``` --- @@ -330,7 +335,7 @@ src/assistant/CommandDispatcher.cpp - 定时提醒 - 天气查询 - 本地文件操作 -- 联网搜索 +- 联网模式 ``` 第一版可以使用规则判断,不依赖 AI。 @@ -634,9 +639,35 @@ Windows 托盘通知后端不可用:记录日志,不补气泡,不进入可 # 6. 阶段 2:天气查询功能 +当前天气查询 v1 已进入实现阶段: + +- 已新增独立 `src/weather/` 模块 +- 已支持 Open-Meteo 市级城市优先的基础地理编码和基础天气查询 +- 已支持设置页默认城市 +- 已支持默认城市为空时通过公网 IP 定位兜底 +- 已支持当前/今天、明天、后天和未来 1-3 天模板回复 +- 已支持读取前 5 个地理编码候选;多候选时仍查首项,并在回复中提示同名城市风险和其他候选 +- 已支持设置页测试默认城市;测试只展示匹配结果,不自动保存配置 +- 已通过 `CommandDispatcher` 将 Weather 意图接入聊天入口 +- 已保持 `PetWindow` 只负责 UI 展示,不承载天气解析或网络逻辑 + +v1 明确暂不支持: + +- AI 润色天气回复 +- 空气质量 +- 天气预警 +- 天气提醒联动 +- 多天气源切换 +- 小时级精细降雨判断 +- 穿衣指数 + +默认城市为空且启用公网 IP 定位时,会请求 ipapi.co 判断城市;回复必须说明“根据公网 IP 判断城市”。设置页默认城市和公网 IP 定位都属于非用户明确城市来源,回复必须说明来源。 + +当前 v1 推荐填写市级城市名;区县、乡镇、街道不保证精确识别,可能无法匹配或被匹配到上级/同名城市。同名城市当前使用天气源返回的第一个结果,并在回复或测试结果中提示其他候选。后续优先补可交互候选选择、区县级定位和国内天气源增强。 + ## 6.1 功能定位 -天气是独立工具能力,不放进联网搜索。 +天气是独立工具能力,不放进联网模式。 正确流程: @@ -654,9 +685,9 @@ AI 根据结构化数据自然语言回复 不要让 AI 自己搜索天气,也不要让 AI 自己拼 URL。 -## 6.2 为什么不用联网搜索做天气 +## 6.2 为什么不用联网模式做天气 -搜索天气有几个问题: +用联网模式查询天气有几个问题: ```text 结果格式不稳定 @@ -706,10 +737,10 @@ src/weather/ ├── WeatherConfig.h ├── WeatherStore.h ├── WeatherStore.cpp - ├── WeatherProvider.h - ├── WeatherProvider.cpp - ├── OpenMeteoWeatherProvider.h - ├── OpenMeteoWeatherProvider.cpp + ├── WeatherParser.h + ├── WeatherParser.cpp + ├── WeatherSummaryFormatter.h + ├── WeatherSummaryFormatter.cpp ├── WeatherManager.h └── WeatherManager.cpp ``` @@ -836,6 +867,8 @@ VPN / 代理会导致定位错误 ## 6.10 AI 回复上下文 +当前 v1 采用模板优先,不依赖 AI 润色;本节作为 v1.1 预留方向。 + 程序应把结构化天气数据交给 AI: ```text @@ -872,6 +905,27 @@ VPN / 代理会导致定位错误 本地文件操作是高风险功能,必须后置,不能早于提醒和天气。 +当前本地文件操作 v1 已进入实现阶段: + +- 已新增独立 `src/fileops/` 模块 +- 已通过 `CommandDispatcher` 将 FileOperation 意图接入聊天入口 +- 已支持读取用户主动选择的常见文本文件 +- 已支持列出用户主动选择的文件夹 +- 已支持复制文件、创建备份、重命名文件 +- 写操作会展示操作计划并二次确认 +- 聊天文本不会直接变成本地路径,所有路径必须由用户通过文件选择框选择 +- 已拒绝删除、覆盖、移动、执行脚本、运行命令和系统目录访问 +- 已拒绝符号链接路径,降低路径逃逸风险 + +v1 明确暂不支持: + +- zip 打包 +- 删除文件 +- 覆盖文件 +- 移动文件 +- 执行脚本或命令 +- 修改源码 + 目标是让桌宠能辅助用户处理本地文件,例如: ```text @@ -930,8 +984,6 @@ AI 自己决定访问系统目录 ```text src/fileops/ ├── FileOperationTypes.h - ├── FileOperationPlanner.h - ├── FileOperationPlanner.cpp ├── FileOperationManager.h ├── FileOperationManager.cpp ├── FileSandbox.h @@ -953,6 +1005,8 @@ src/fileops/ 重命名文件,需确认 ``` +当前 v1 中 zip 打包延期,因为 Qt 公共 API 没有稳定内置写 zip 能力;不为此引入重依赖。后续如果要补 zip,优先选择明确许可和可维护的压缩库。 + 暂不做: ```text @@ -978,82 +1032,95 @@ D:/xxx/b.txt 是否继续? ``` ---- +## 7.7 应用启动独立模块 -# 8. 阶段 4:联网搜索功能 - -## 8.1 功能定位 - -联网搜索后置,不要现在优先做。 - -联网搜索是通用增强能力: +当前已新增独立 `src/launcher/` 模块,不放进 `src/fileops/`: ```text -查询最新信息 -查官网 -查报错 -查新闻 -查版本 -查文档 +src/launcher/ + ├── AppLaunchTypes.h + ├── AppLaunchStore.h + ├── AppLaunchStore.cpp + ├── AppDiscovery.h + ├── AppDiscovery.cpp + ├── AppLaunchManager.h + └── AppLaunchManager.cpp ``` -## 8.2 不要给每家模型各自配置搜索 - -不建议: +应用启动支持: ```text -OpenAIProvider 自己一套搜索 -GeminiProvider 自己一套搜索 -DeepSeekProvider 自己一套搜索 -CustomProvider 自己一套搜索 +打开 Codex +启动酷狗音乐 +帮我打开 VSCode ``` -推荐: +解析和发现顺序: ```text -WebSearchManager 独立负责搜索 -LLMProvider 只负责回答 -ConversationManager / CommandDispatcher 负责把搜索结果注入给 AI +用户在设置页登记的应用别名 +↓ +Windows 开始菜单快捷方式 +↓ +Windows App Paths 注册表 +↓ +未知应用时由用户手动选择 .exe ``` -## 8.3 建议目录 +启动安全边界: ```text -src/search/ - ├── SearchTypes.h - ├── SearchProvider.h - ├── SearXNGSearchProvider.h - ├── SearXNGSearchProvider.cpp - ├── WebSearchManager.h - └── WebSearchManager.cpp +启动前始终二次确认 +只允许 .exe 或开始菜单 .lnk +不执行 .bat / .cmd / .ps1 / .vbs / .js / .msi +不执行聊天文本里的命令 +不拼接聊天文本参数 +不以管理员权限启动 ``` -第一版只做: +配置保存到: ```text -SearXNG + 自定义 Base URL +QStandardPaths::AppConfigLocation/launcher_config.json ``` -后续再加: +损坏时备份为: ```text -Tavily -Brave Search -CustomSearchProvider -``` - -## 8.4 安全边界 - -```text -不要让 AI 自己生成任意 URL 给程序访问 -不要做网页全文抓取第一版 -不要默认每次都联网 -不要把用户隐私查询写日志 -搜索结果要显示来源 +launcher_config.broken.yyyyMMdd-HHmmss.json ``` --- +# 8. 阶段 4:联网模式 + +联网能力已从旧“搜索引擎聚合器”重做为 Web 端 AI 对话式联网模式。 + +当前已落地: + +- 已删除旧 `src/search/` 模块,不再维护 Google/百度/360/搜狗页面解析、SearXNG 旧配置、多源聚合和旧 `search_config.json` 兼容。 +- 已新增独立 `src/web/` 模块:`WebConfig`、`WebStore`、`WebCapabilityDetector`、`WebChatManager` 和 Web citation 类型。 +- 输入框新增“联网”开关;开关开启后,普通聊天进入 WebChat 流程。 +- 支持 OpenAI 官方 Provider 的 Responses API Web Search。 +- 支持 Google Gemini Provider 的 Google Search grounding。 +- DeepSeek 官方 API 当前不提供托管联网搜索工具,显示“不支持原生联网”。 +- Custom / 第三方 OpenAI-Compatible 默认无法确认联网能力,不发送未知联网参数,不做旧搜索页抓取。 +- 设置页改为“联网模式”:显示当前能力状态、开关记忆、默认开关、Provider 模式、超时、来源展示和测试联网模式。 +- 旧 `search_config.json` 已废弃;新版使用 `web_config.json`,损坏时备份为 `web_config.broken.yyyyMMdd-HHmmss.json`。 + +边界: + +- 不再默认每次联网,模型可判断稳定常识无需联网。 +- 不做搜索结果页 HTML 解析,避免登录、帮助、反馈、验证码页面污染答案。 +- 不实现 Tavily / Brave / Bing Search API、自建搜索后端、网页全文抓取或长期缓存。 +- 天气仍是独立工具能力,不放进联网模式。 + +后续可选: + +- 更多 AI Provider 原生联网适配。 +- 结构化搜索 API 或自建联网后端,用于不支持原生联网的模型。 +- 更强的引用 UI、证据片段摘录和来源可点击展示。 + # 9. 建议最终开发顺序 ## 9.1 第一步:收口架构 @@ -1076,33 +1143,30 @@ CustomSearchProvider ## 9.3 第三步:天气查询 ```text -1. WeatherTypes -2. WeatherConfig / WeatherStore -3. OpenMeteoWeatherProvider -4. WeatherManager -5. IntentRouter 天气识别 -6. AI 回复 / 模板兜底 +当前已落地天气 v1:WeatherTypes、WeatherConfig / WeatherStore、WeatherParser、WeatherManager、模板回复、设置页默认城市、公网 IP 定位兜底和 Weather 意图接入。 +当前已落地天气 v1.2 / v1.3:多候选城市提示和设置页默认城市测试。后续可继续补可交互候选选择、区县级定位、国内天气源增强、AI 润色、空气质量、天气预警、多天气源切换、小时级降雨判断和天气提醒联动。 ``` ## 9.4 第四步:本地文件操作 ```text -1. FileSandbox -2. FileOperationTypes -3. FileOperationPlanner -4. FileOperationManager -5. 用户确认 UI -6. 备份机制 +当前已落地 FileSandbox、FileOperationTypes、FileOperationManager、FileBackupManager 和用户确认 UI。 +v1 支持读取文本、列出文件夹、复制、备份、重命名。 +v1 不支持 zip、删除、覆盖、移动、脚本/命令执行和系统目录访问。 ``` -## 9.5 第五步:联网搜索 +## 9.5 第五步:联网模式 ```text -1. SearchTypes -2. SearchProvider -3. SearXNGSearchProvider -4. WebSearchManager -5. 来源展示 +当前已落地 WebConfig、WebStore、WebCapabilityDetector、WebChatManager 和输入框联网开关。新版支持 OpenAI/Gemini 原生联网;DeepSeek/Custom 明确提示不支持或无法确认;旧搜索聚合与 SearXNG 旧配置已废弃。 +``` + +## 9.6 第六步:应用启动 + +```text +当前已落地 AppLaunchStore、AppDiscovery、AppLaunchManager 和设置页应用登记。 +v1 支持已登记应用、开始菜单快捷方式、App Paths 注册表和用户确认手选 .exe。 +v1 不支持脚本、聊天参数、命令执行、管理员权限和跨平台应用发现。 ``` --- @@ -1112,17 +1176,17 @@ CustomSearchProvider 下面这段可以直接作为后续任务总说明: ```text -当前不要急着直接实现提醒、天气、文件操作或联网搜索。 +当前不要急着直接实现提醒、天气、文件操作或联网模式。 请先审查当前 QtDesktopPet 项目结构,并完成以下准备工作: 1. 角色切换在设置保存后已确认立即生效,后续只需保留回归检查。 2. 新增 IntentRouter / CommandDispatcher,使用户输入先经过统一意图分发。 -3. 意图类型包括 Chat、Reminder、Weather、FileOperation、Search。 -4. 意图优先级为 Reminder > FileOperation > Weather > Search > Chat。 +3. 意图类型包括 Chat、Reminder、Weather、FileOperation、LaunchApp;联网模式由输入框开关决定,不再是独立 Search 意图。 +4. 意图优先级为 Reminder > FileOperation > Weather > LaunchApp > Chat/WebChat。 5. 普通聊天继续走现有 AI 对话流程。 6. 新功能不得继续直接塞进 PetWindow。 -7. 后续 Reminder、Weather、FileOperation、Search 均应作为独立模块接入。 +7. 后续 Reminder、Weather、FileOperation、LaunchApp、WebChat 均应作为独立模块接入。 8. 当前阶段保持 CMAKE_AUTOMOC OFF,后续模块不要使用 Q_OBJECT;确需 Qt 信号槽时再单独评估。 9. 保持现有隐私策略:API Key、Authorization、完整用户消息、完整错误响应不得写入日志。 10. 保持现有文件安全策略:路径校验、危险操作确认、修改前备份。 @@ -1137,7 +1201,7 @@ CustomSearchProvider 建议先做: ```text -结构收口 → IntentRouter / CommandDispatcher → 定时提醒 → 天气 → 本地文件操作 → 联网搜索 +结构收口 → IntentRouter / CommandDispatcher → 定时提醒 → 天气 → 本地文件操作 → 联网模式 ``` 其中: @@ -1146,5 +1210,5 @@ CustomSearchProvider 定时提醒:已作为第一个工具能力落地 天气:建议第二个落地 本地文件操作:风险较高,第三个落地 -联网搜索:通用能力,最后落地 +联网模式:通用 AI 对话增强,最后落地 ``` diff --git a/docs/QtDesktopPet_测试清单与验收标准.md b/docs/QtDesktopPet_测试清单与验收标准.md new file mode 100644 index 0000000..6948701 --- /dev/null +++ b/docs/QtDesktopPet_测试清单与验收标准.md @@ -0,0 +1,198 @@ +# QtDesktopPet 测试清单与验收标准 + +本文档用于全功能统一手测验收。Codex 不调用 CMake 或构建命令;需要编译验证时由用户手动构建并反馈结果。 + +## 验收记录格式 + +每个用例建议记录: + +```text +测试日期: +Git commit: +构建类型:Debug / Release +操作系统: +Qt 版本: +测试人: +结论:通过 / 不通过 / 阻塞 +实际结果: +备注: +``` + +## 总体验收标准 + +- 程序不崩溃、不卡死,UI 主线程不被网络、文件或 AI 请求阻塞。 +- 日志不包含完整 API Key、Authorization、完整用户消息正文、完整错误响应正文或完整搜索词。 +- 设置页保存后立即生效,重启后配置仍能读取。 +- 所有损坏 JSON 配置都应备份为 `.broken.yyyyMMdd-HHmmss.json` 后回退默认或忽略损坏数据。 +- 所有涉及本地文件写入的操作必须由用户选择路径,并在执行前显示计划和二次确认。 +- 不运行 CMake 的自动验证;构建和运行由用户手动完成。 + +## 基础窗口与托盘 + +| 用例 | 步骤 | 预期结果 | 结论 | +| --- | --- | --- | --- | +| 启动 | 启动 `QtDesktopPet.exe` | 桌宠显示,透明无边框,无控制台窗口,日志创建成功 | TODO | +| 拖动 | 按住桌宠拖动后松开 | 拖动时进入 `drag`,松开后恢复合理状态 | TODO | +| 置顶 | 右键切换置顶 | 置顶状态立即变化,重启后保持 | TODO | +| 隐藏托盘 | 托盘菜单隐藏/显示 | 隐藏后动画暂停,显示后恢复 | TODO | +| 单实例 | 已运行时再次启动 exe | 只有一个进程,已有实例被唤醒 | TODO | +| 设置页居中 | 多屏或重复打开设置页 | 设置页出现在当前屏幕可用区域内并置前 | TODO | + +## 角色管理 + +| 用例 | 步骤 | 预期结果 | 结论 | +| --- | --- | --- | --- | +| 切换角色 | 设置页选择角色并保存 | 桌宠立即切换,不依赖重启 | TODO | +| 导入合法角色 | 导入有效角色文件夹 | 验证通过后复制到用户数据目录,可选择使用 | TODO | +| 导入非法角色 | 导入缺少 `character.json` 或无 idle 的角色 | 弹出错误,不复制、不覆盖 | TODO | +| 覆盖用户角色 | 导入同 id 用户角色 | 弹出覆盖确认,取消则不操作 | TODO | +| 内置角色保护 | 尝试删除内置角色 | 提示不能删除,不做文件操作 | TODO | +| 删除用户角色 | 删除用户导入角色 | 二次确认后删除用户角色目录;当前角色被删时回退默认 | TODO | +| 导出角色 | 选择任意角色并导出到目录 | 目标生成 `/` 副本;目标存在时必须确认 | TODO | +| 打开目录 | 点击打开用户角色目录 | 系统文件管理器打开用户角色目录 | TODO | + +## AI 与聊天历史 + +| 用例 | 步骤 | 预期结果 | 结论 | +| --- | --- | --- | --- | +| AI 配置保存 | 设置 Provider/Base URL/API Key/Model 并保存 | 配置保存,API Key 按平台策略加密或确认明文 | TODO | +| 连接测试 | 点击测试连接 | 成功显示成功;错误配置显示明确错误 | TODO | +| 普通聊天 | 发送短消息 | `think` 后进入 `talk`,气泡显示回复 | TODO | +| 流式回复 | 启用流式并发送消息 | 首段前保持 `think`,输出中保持 `talk` | TODO | +| AI 忙 | 回复中再次发送聊天/天气/搜索 | 不启动第二个请求,显示忙提示 | TODO | +| 取消 AI | 右键取消 AI 请求 | 请求取消,状态恢复,气泡提示取消 | TODO | +| 历史保存 | 开启本地保存后聊天 | 重启后历史可读取 | TODO | +| 历史搜索 | 在设置页按关键词搜索 | 只显示匹配记录 | TODO | +| 历史筛选 | 按 Provider/模型筛选 | 只显示对应元数据记录;旧记录按未知处理 | TODO | +| 导出 Markdown | 导出筛选结果为 `.md` | 文件包含角色、时间、Provider/模型和正文,不含 API Key | TODO | +| 导出 JSON | 导出筛选结果为 `.json` | JSON 可解析,包含 messages 数组,不含 API Key | TODO | +| 清空历史 | 点击清空聊天记录并确认 | 内存和本地历史清空,面板刷新 | TODO | + +## 定时提醒 + +| 用例 | 步骤 | 预期结果 | 结论 | +| --- | --- | --- | --- | +| 一次性提醒 | `10分钟后提醒我喝水` | 创建 pending 提醒,显示标题和时间 | TODO | +| 中文相对时间 | `半小时后提醒我休息`、`一个半小时后提醒我喝水` | 时间解析正确 | TODO | +| 绝对日期 | `明天9点提醒我开会`、`6月3日9点提醒我提交` | 未明确日期且当天已过时顺延 | TODO | +| 重复提醒 | `每天9点提醒我打卡`、`每周一上午10点提醒我周会`、`每月3号9点提醒我交报告` | 创建 daily/weekly/monthly | TODO | +| 不支持规则 | `工作日9点提醒我打卡`、`农历初一提醒我` | 明确提示暂不支持,不创建一次性误提醒 | TODO | +| 提醒列表 | `提醒列表` | 只列出 pending 提醒 | TODO | +| 聊天取消 | `取消喝水提醒` | 唯一匹配时标记 canceled;多匹配提示去设置页处理 | TODO | +| 设置页编辑 | 编辑 pending 标题/时间/重复规则 | 保存后列表和下一次触发时间更新 | TODO | +| 触发可见 | 到点时桌宠可见且 AI 不忙 | 播放当前音效,显示气泡和操作区,不发系统通知 | TODO | +| 触发隐藏 | 桌宠隐藏时到点 | 播放当前音效,只发 Windows 托盘通知,不补气泡 | TODO | +| AI 忙触发 | AI 请求或流式回复中到点 | 播放当前音效,发系统通知,不显示提醒气泡 | TODO | +| 拖动触发 | 拖动中到点 | 不打断 `drag`,松开后按队列显示气泡 | TODO | +| 多提醒同时触发 | 两条同时间提醒 | 可见时逐条队列展示,后一条不覆盖前一条 | TODO | +| 知道了 | 点击 `知道了` | 关闭当前提醒展示,队列进入下一条 | TODO | +| 5分钟后再提醒 | 点击 `5分钟后再提醒` | 创建当前时间 + 5 分钟的一次性 pending,不影响原重复规则 | TODO | +| 历史清理 | 构造 21 天前 triggered/canceled 后清理 | 删除 20 天前历史,保留最近 20 天和 pending | TODO | +| 音效导入 | 导入合法 PCM wav | 可选择、试听、删除 | TODO | +| 内置音效保护 | 选择内置音效点击删除 | 删除按钮禁用或提示不可删除 | TODO | + +## 天气查询 + +| 用例 | 步骤 | 预期结果 | 结论 | +| --- | --- | --- | --- | +| 明确城市 | `西安天气怎么样` | 返回 Open-Meteo 模板结果 | TODO | +| 明天 | `明天西安天气怎么样` | 返回明天预报 | TODO | +| 后天 | `后天纽约天气` | 返回后天预报 | TODO | +| 未来三天 | `未来三天北京天气` | 返回 1-3 天摘要 | TODO | +| 默认城市 | 设置默认城市后输入 `今天天气怎么样` | 使用默认城市并说明来源 | TODO | +| IP fallback | 清空默认城市并启用 IP 定位 | 使用公网 IP 城市并说明来源 | TODO | +| 禁用 IP fallback | 清空默认城市并关闭 IP 定位 | 提示配置默认城市或在问题中带城市 | TODO | +| 多候选 | 查询同名城市 | 使用首项并提示其他候选 | TODO | +| 默认城市测试 | 设置页点击测试默认城市 | 显示匹配城市/行政区/国家,不自动保存 | TODO | +| 未知城市 | 输入无法解析城市 | 明确错误,不崩溃 | TODO | +| 不支持项 | 查询空气质量/预警/穿衣指数 | 明确提示 v1 暂不支持 | TODO | + +## 本地文件操作 + +| 用例 | 步骤 | 预期结果 | 结论 | +| --- | --- | --- | --- | +| 读取文本 | 输入读取文本文件请求并选择 txt/md/log | 显示内容预览,最多读取前 64KB | TODO | +| 非文本拒绝 | 选择 exe/png 等非文本 | 提示只允许常见文本文件 | TODO | +| 列目录 | 输入列出文件夹请求并选择临时目录 | 显示前 200 项,区分目录/文件 | TODO | +| 复制文件 | 选择源文件和目标目录 | 显示计划,确认后复制;目标存在则拒绝覆盖 | TODO | +| 创建备份 | 选择文件备份 | 显示计划,确认后生成 `.backup.yyyyMMdd-HHmmss` 文件 | TODO | +| 重命名 | 选择文件并输入新文件名 | 显示计划,确认后重命名;目标存在拒绝 | TODO | +| 危险词拒绝 | 输入删除/覆盖/移动/执行脚本/运行命令 | 明确拒绝,不弹路径选择,不做文件操作 | TODO | +| 系统目录拒绝 | 尝试选择 Windows/Program Files 下路径 | 提示不允许访问系统目录 | TODO | +| 符号链接拒绝 | 选择符号链接或链接目录内路径 | 提示不允许操作符号链接路径 | TODO | +| zip 延期 | 输入打包/压缩请求 | 提示 zip 打包暂不启用 | TODO | + +## 联网模式 + +| 用例 | 步骤 | 预期结果 | 结论 | +| --- | --- | --- | --- | +| 开关关闭 | 输入框联网开关关闭,询问 `美国的首都在哪里` | 走普通 AI 对话,不调用 WebChatManager | TODO | +| OpenAI 官方联网 | 使用 OpenAI 官方 Provider,打开联网开关,询问 `Qt 6 最新版本` | 进入联网模式,返回回答;有来源时展示来源 | TODO | +| Gemini 官方联网 | 使用 Google Gemini Provider,打开联网开关,询问 `Qt 6 最新版本` | 进入 Gemini grounding,返回回答;有来源时展示来源 | TODO | +| DeepSeek 不支持 | 使用 DeepSeek Provider,打开联网开关并发送问题 | 不发起伪联网请求,提示 DeepSeek 当前不支持原生联网 | TODO | +| 第三方兼容不确认 | 使用 Custom 或第三方 Base URL,打开联网开关 | 提示当前 AI 配置无法确认联网能力 | TODO | +| 模型未联网 | 支持联网 Provider 回答稳定常识但未返回来源 | 展示回答,并标注模型未使用联网来源 | TODO | +| Web 忙冲突 | 联网请求中再次发送普通聊天或联网聊天 | 不启动第二个请求,提示稍后 | TODO | +| AI 忙冲突 | 普通 AI 回复中打开联网再发送 | 不启动联网请求,提示 AI 回复正在进行 | TODO | +| 设置页状态 | 切换 OpenAI/Gemini/DeepSeek/Custom | “联网模式”页即时显示可用/不可用/无法确认状态 | TODO | +| 设置页测试 | 点击“测试联网模式” | 支持 Provider 发起测试,不支持 Provider 直接显示原因 | TODO | +| 旧失败回归 | 打开联网后询问 `美国的首都在哪里` | 不再返回搜狗/360 帮助、登录、反馈页 | TODO | +| 意图优先级 | `明天早上提醒我看天气` / `把天气截图保存到桌面` | 仍优先走提醒或文件操作,不受联网开关影响 | TODO | + +## 应用启动 + +| 用例 | 步骤 | 预期结果 | 结论 | +| --- | --- | --- | --- | +| 设置页登记 | 添加一个 `.exe`,填写名称和别名后保存 | 重启后登记项仍存在 | TODO | +| 已登记启动 | 输入 `打开 <别名>` | 弹出确认框,确认后启动对应 exe | TODO | +| 未知应用手选 | 输入 `打开酷狗音乐` 且未登记 | 弹出 exe 选择器,选择 `.exe` 后弹确认框 | TODO | +| 记住手选应用 | 手选 exe 时勾选记住 | 后续同名请求直接命中登记应用并仍需确认 | TODO | +| 用户取消选择 | 未知应用弹出选择器后取消 | 不启动任何程序,无配置写入 | TODO | +| 用户取消确认 | 选择或命中应用后取消确认 | 不启动任何程序 | TODO | +| 拒绝脚本 | 尝试选择 `.bat/.cmd/.ps1/.vbs/.js/.msi` | 被拒绝,不启动 | TODO | +| 不执行命令 | 输入“运行 powershell 命令” | 不执行聊天文本命令;按安全规则拒绝或走普通聊天 | TODO | +| 文件操作优先 | 输入 `打开文件夹` | 走文件操作或文件操作拒绝路径,不走应用启动 | TODO | +| 设置页测试启动 | 选中登记应用点测试启动 | 先二次确认,确认后启动 | TODO | +| 设置页开机自启动 | 应用设置页勾选/取消“开机自启动”并保存 | 当前用户 Run 注册表项写入/移除,重开设置页状态与实际注册表一致 | TODO | + +## 发布与性能 + +| 用例 | 步骤 | 预期结果 | 结论 | +| --- | --- | --- | --- | +| Release 构建 | 用户手动构建 Release | 构建成功,无新增编译错误 | TODO | +| 打包 | 运行 `tools/package_release.ps1` | 包含 exe、Qt runtime、resources、README、LICENSE | TODO | +| 安装器 | 生成并安装 Inno Setup 包 | 无 Qt 开发环境机器可运行 | TODO | +| 安装器自启动选项 | 安装页面勾选/不勾选开机自启动分别安装 | 勾选时写入当前用户 Run 项;不勾选时不新增自启动项;选项默认不勾选 | TODO | +| 卸载清理自启动 | 已启用开机自启动后卸载 | 当前用户 Run 项被清理,不残留开机启动入口 | TODO | +| 卸载保留数据 | 卸载时选择不删除用户数据 | 配置、角色、历史、日志保留 | TODO | +| 卸载删除数据 | 卸载时选择删除用户数据 | 当前用户 QtDesktopPet 数据目录被删除 | TODO | +| 性能采样 | 按 `performance_stability_check.md` 执行 | CPU/内存无持续异常增长,摘要写入文档 | TODO | + +## 静态推演记录 + +每个模块完成后记录: + +```text +模块: +入口: +成功路径: +失败路径: +数据兼容: +UI 状态: +日志隐私: +回归影响: +结论: +``` + +本轮静态推演明细: + +| 模块 | 入口 | 成功路径 | 失败路径 / 兜底 | 风险点 | 结论 | +| --- | --- | --- | --- | --- | --- | +| 天气 | `CommandDispatcher -> PetWindow::handleWeatherChatMessage() -> WeatherManager` | 解析城市和日期,按显式城市 / 默认城市 / IP fallback 定位,请求 Open-Meteo,模板格式化后显示气泡 | AI 忙、天气忙提前拒绝;未知城市、IP 失败、超时、JSON 异常返回明确错误;默认城市测试超时会恢复测试按钮 | 网络不可用、Open-Meteo 多候选首项可能不符合预期 | v1/v1.2/v1.3 收口完成,区县精确定位和空气质量等保持延期 | +| 提醒 | `CommandDispatcher -> ReminderCommandHandler -> ReminderManager` | 创建/查询/取消一次性和 daily/weekly/monthly;到点先保存 JSON,再播放音效和展示/通知 | 保存失败回滚内存,不播放、不通知、不气泡;隐藏和 AI 忙只发系统通知;拖动中进入可见队列;20 天前历史可清理 | 系统通知是否真正弹出受 Windows/托盘后端影响,Qt 只能确认发起状态 | v2 行为保持,复杂重复规则、跨平台通知、自定义稍后间隔延期 | +| 角色 | 设置页角色页和 `CharacterPackageRepository` | 导入先验证再复制;切换保存后立即生效;用户角色可删除;任意角色可导出;可打开用户角色目录 | 内置角色不可删除/覆盖;导出目标存在需二次确认;损坏角色包不复制 | 目标目录权限失败、用户取消、角色包资源缺失 | 当前角色管理补全完成,角色市场/分享延期 | +| 历史 | 设置页聊天页和 `ConversationManager/ConversationStore` | 新消息写入 `timestamp/provider/model`;旧 role/content 历史兼容读取;支持关键词、Provider、模型筛选;Markdown/JSON 导出 | 本地历史关闭时只显示内存记录;损坏历史备份后忽略;导出路径不可写时提示错误 | 超大历史由保存上限和筛选 UI 控制;导出文件由用户选择路径 | 历史管理补全完成,不导出 API Key,不记录完整聊天日志 | +| 文件操作 | `CommandDispatcher -> PetWindow::handleFileOperationChatMessage() -> FileOperationManager` | 用户通过系统对话框选择路径;读取文本、列目录、复制、备份、重命名;写操作先展示计划并二次确认 | 删除/覆盖/移动/脚本/命令/截图保存/zip 直接拒绝;系统目录、符号链接、目标冲突、非法文件名均拒绝 | 文件选择对话框是安全边界;系统目录根据常见路径和环境变量识别 | v1 安全能力完成,zip 打包延期,不引入重依赖 | +| 联网模式 | `ChatInputDialog -> PetWindow::submitWebChatMessage() -> WebChatManager` | 输入框联网开关开启后,支持 OpenAI 官方 Web Search 和 Gemini Google Search grounding;不支持 DeepSeek/Custom 伪联网 | AI 未配置、Provider 不支持、超时、网络失败、无来源均明确提示;Web 忙时不启动第二个请求 | 不再维护旧搜索源聚合或 SearXNG 旧配置 | 联网模式完成,结构化搜索 API/自建后端延期 | +| 应用启动 | `CommandDispatcher -> PetWindow::handleLaunchAppChatMessage() -> AppLaunchManager` | 用户输入打开/启动应用后,按登记别名、开始菜单、App Paths、手选 exe 顺序解析,启动前二次确认 | 配置关闭、未找到应用、用户取消、非 exe/lnk、脚本/安装包、路径不存在均不启动;手选记住失败只提示,不影响已确认启动 | 启动成功由系统返回值判断,目标应用自身异常不由桌宠控制;开始菜单和注册表发现仅 Windows 优先 | v1 完成,不支持脚本、命令行参数、管理员权限、跨平台发现 | +| 发布/性能 | `tools/package_release.ps1` 和 `docs/performance_stability_check.md` | 发布脚本复制 exe、Qt runtime、角色/图标/音效资源、README、LICENSE;性能文档覆盖静置、AI、提醒、天气、联网模式、文件操作 | `windeployqt`、Inno Setup、安装/卸载验证需用户手动执行并记录 | 未经手动构建和实机安装不能确认二进制运行 | 验收入口已补齐,本轮不运行 CMake 或构建命令 | diff --git a/docs/Qt_DesktopPet_开发文档.md b/docs/Qt_DesktopPet_开发文档.md index 647e915..38ca949 100644 --- a/docs/Qt_DesktopPet_开发文档.md +++ b/docs/Qt_DesktopPet_开发文档.md @@ -1696,8 +1696,9 @@ MIT License 开源 当前仍需补齐: ```text -1. 角色包导出和更完整管理界面 -2. 对话历史导出、搜索或更完整管理界面 -3. 发布前素材授权确认与打包验证 -4. 长期性能压测记录 +1. 发布前素材授权确认与打包验证 +2. 长期性能压测记录 +3. 本地文件操作 zip 打包能力,如后续确认压缩库方案再补 +4. 联网模式后续可补更多 AI Provider 原生联网适配、结构化搜索 API 或自建联网后端;网页全文抓取和长期缓存仍需先确认安全边界 +5. 应用启动跨平台发现、脚本/命令执行和管理员权限当前不支持,后续如确需增加必须重新评估安全边界 ``` diff --git a/docs/implementation_plan.md b/docs/implementation_plan.md index 69bb75e..a82c13a 100644 --- a/docs/implementation_plan.md +++ b/docs/implementation_plan.md @@ -597,8 +597,8 @@ release_packages/ 当前实现与计划仍存在差异: ```text -1. SettingsDialog 仍是最小设置界面,角色页已有导入、切换和删除用户角色,但尚未包含导出和更完整的角色管理流程 -2. 对话历史已有内存上限和可选本地保存,但尚未提供导出、搜索或完整管理界面 +1. SettingsDialog 已支持角色导入、切换、删除用户角色、导出角色和打开用户角色目录;后续更复杂的角色市场/分享仍不在当前范围 +2. 对话历史已有内存上限、可选本地保存、关键词搜索、Provider/模型筛选和 Markdown/JSON 导出 3. 状态级懒加载尚未包含后台线程预热、单帧级缓存和长期压测记录 4. README 和开发文档已开始同步当前进度,但仍需随功能继续维护 ``` @@ -636,12 +636,12 @@ release_packages/ ```text 1. 完善设置界面: - - 角色导出 - - 打开用户角色目录 - 更完整的角色管理状态提示 + - 后续如果做角色分享,需要补版权提示和包格式校验 2. 使用 tools/perf_sample.ps1 补一轮可重复的稳定性与性能测试记录 3. 使用 tools/perf_sample.ps1 验证状态级 LRU 卸载、主线程分批预热和动画缓存上限策略 4. 做发布包安装/卸载实机验证,并确认 release_packages/ 或根目录安装包输出规则 +5. 按 docs/QtDesktopPet_测试清单与验收标准.md 做全功能手测 ``` --- @@ -652,6 +652,6 @@ release_packages/ ```text 1. shiroko 素材是否允许作为正式开源发布素材继续保留在仓库中 -2. 角色管理下一步是否需要导出、打开用户角色目录 -3. 对话历史后续是否需要导出、搜索或按角色/Provider 分组管理 +2. 角色管理后续是否需要角色分享/市场能力 +3. 对话历史后续是否需要更复杂的会话分组、归档或全文索引 ``` diff --git a/docs/performance_stability_check.md b/docs/performance_stability_check.md index 933570e..ca9f405 100644 --- a/docs/performance_stability_check.md +++ b/docs/performance_stability_check.md @@ -53,6 +53,11 @@ reports/perf/ | 动画预热与卸载 | 默认配置启动后静置,随后隐藏到托盘再显示 | `tools/perf_sample.ps1 -DurationSeconds 600` | 日志出现有限次分批预热;隐藏后非保护动画缓存释放;显示后不会反复预热刚被卸载的状态 | TODO | TODO | 不应影响当前播放状态恢复 | | 缩放 / 置顶切换 | 设置页切换缩放,右键切换置顶 | `tools/perf_sample.ps1 -DurationSeconds 300` | 窗口尺寸和状态稳定,无崩溃 | TODO | TODO | TODO | | AI 对话 | 连续发送 20 轮短消息 | `tools/perf_sample.ps1 -DurationSeconds 900` | 请求期间 UI 不阻塞,内存不持续上涨;日志不包含完整消息正文、API Key 或完整错误响应正文 | TODO | TODO | 使用错误 Key/Base URL 时检查脱敏摘要 | +| 提醒触发 | 创建 2 条同时间提醒并等待触发 | `tools/perf_sample.ps1 -DurationSeconds 300` | 可见时队列逐条显示;隐藏或 AI 忙时只发系统通知;内存和句柄无异常增长 | TODO | TODO | 检查音效播放和 5 分钟稍后提醒 | +| 天气查询 | 连续查询当前/明天/未来三天天气 | `tools/perf_sample.ps1 -DurationSeconds 300` | 网络请求异步执行,UI 不阻塞;失败时明确提示;日志不记录完整用户原文 | TODO | TODO | 覆盖默认城市、IP fallback、多候选提示 | +| 联网模式 | 使用支持联网的 OpenAI/Gemini Provider 连续发起 5 次联网对话,并覆盖 DeepSeek/Custom 不支持提示 | `tools/perf_sample.ps1 -DurationSeconds 300` | 请求异步执行,来源展示不阻塞 UI;不支持 Provider 不发起伪联网;日志不记录完整用户问题或 API Key | TODO | TODO | 输入框打开联网开关;不再测试旧多搜索源/SearXNG 路径 | +| 应用启动 | 登记一个测试 exe,聊天触发启动并取消/确认各一次 | 手工观察 + 采样 | 启动前始终二次确认;取消时不启动;确认后 UI 可继续响应;不执行脚本或聊天参数 | TODO | TODO | 使用无副作用测试程序,例如记事本或临时测试 exe | +| 本地文件操作 | 读取文本、列目录、复制、备份、重命名 | 手工观察 + 采样 | 写操作有确认;不覆盖、不删除、不访问系统目录;操作后 UI 可继续响应 | TODO | TODO | 使用临时测试目录,不操作真实重要文件 | | 配置损坏兜底 | 备份后分别破坏 app 配置、AI 配置或本地聊天记录再启动 | 启动后采样 3 分钟 | 程序恢复默认配置或忽略损坏历史,并生成带时间戳的 broken 备份,不覆盖旧备份 | TODO | TODO | 使用备份副本测试 | | 角色包损坏兜底 | 使用临时复制的损坏角色包测试 | 启动后采样 3 分钟 | 程序不崩溃,回退 preview 或默认显示 | TODO | TODO | 不直接破坏仓库内默认角色包 | @@ -99,6 +104,7 @@ QtDesktopPet.exe Qt 运行时依赖 resources/characters/ resources/icons/ +resources/sounds/ LICENSE README.md ``` diff --git a/installer/QtDesktopPet.iss b/installer/QtDesktopPet.iss index 382c028..b1bf57b 100644 --- a/installer/QtDesktopPet.iss +++ b/installer/QtDesktopPet.iss @@ -30,10 +30,14 @@ UninstallDisplayIcon={app}\QtDesktopPet.exe [Tasks] Name: "desktopicon"; Description: "Create a desktop shortcut"; GroupDescription: "Additional shortcuts:"; Flags: unchecked +Name: "startup"; Description: "开机自启动"; GroupDescription: "Additional options:"; Flags: unchecked [Files] Source: "{#SourceDir}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs +[Registry] +Root: HKCU; Subkey: "Software\Microsoft\Windows\CurrentVersion\Run"; ValueType: string; ValueName: "{#AppName}"; ValueData: """{app}\QtDesktopPet.exe"""; Flags: uninsdeletevalue; Tasks: startup + [Icons] Name: "{group}\QtDesktopPet"; Filename: "{app}\QtDesktopPet.exe"; WorkingDir: "{app}" Name: "{group}\Uninstall QtDesktopPet"; Filename: "{uninstallexe}" @@ -79,4 +83,9 @@ begin DeleteDirIfExists(ExpandConstant('{userappdata}\QtDesktopPet\QtDesktopPet')); DeleteDirIfExists(ExpandConstant('{localappdata}\QtDesktopPet\QtDesktopPet')); end; + + if CurUninstallStep = usPostUninstall then + begin + RegDeleteValue(HKCU, 'Software\Microsoft\Windows\CurrentVersion\Run', '{#AppName}'); + end; end; diff --git a/src/ai/ConversationManager.cpp b/src/ai/ConversationManager.cpp index 60e60ae..08989d1 100644 --- a/src/ai/ConversationManager.cpp +++ b/src/ai/ConversationManager.cpp @@ -1,5 +1,6 @@ #include "ConversationManager.h" +#include #include #include @@ -66,6 +67,12 @@ void ConversationManager::setMemoryHistoryMessageLimit(int maxMessages) pruneHistory(); } +void ConversationManager::setConversationMetadata(const QString &provider, const QString &model) +{ + m_currentProvider = provider.trimmed(); + m_currentModel = model.trimmed(); +} + bool ConversationManager::setProvider(std::unique_ptr provider) { if (isBusy()) @@ -77,6 +84,32 @@ bool ConversationManager::setProvider(std::unique_ptr provider) return true; } +ChatRequest ConversationManager::buildRequestForUserMessage(const QString &message) const +{ + const QString content = message.trimmed(); + if (content.isEmpty()) + { + return {}; + } + + const ChatMessage userMessage{QStringLiteral("user"), content, QDateTime::currentDateTime(), m_currentProvider, m_currentModel}; + return buildRequest(userMessage); +} + +void ConversationManager::appendExternalExchange(const QString &userMessage, const QString &assistantMessage) +{ + const QString userContent = userMessage.trimmed(); + const QString assistantContent = assistantMessage.trimmed(); + if (userContent.isEmpty() || assistantContent.isEmpty()) + { + return; + } + + const ChatMessage user{QStringLiteral("user"), userContent, QDateTime::currentDateTime(), m_currentProvider, m_currentModel}; + const ChatMessage assistant{QStringLiteral("assistant"), assistantContent, QDateTime::currentDateTime(), m_currentProvider, m_currentModel}; + appendExchange(user, assistant); +} + void ConversationManager::sendUserMessage(const QString &message, ResponseCallback callback) { const QString content = message.trimmed(); @@ -107,11 +140,13 @@ void ConversationManager::sendUserMessage(const QString &message, ResponseCallba return; } - const ChatMessage userMessage{QStringLiteral("user"), content}; + const QDateTime timestamp = QDateTime::currentDateTime(); + const ChatMessage userMessage{QStringLiteral("user"), content, timestamp, m_currentProvider, m_currentModel}; m_provider->sendChatRequest(buildRequest(userMessage), [this, userMessage, callback = std::move(callback)](const ChatResponse &response) mutable { if (response.success) { - appendExchange(userMessage, {QStringLiteral("assistant"), response.content}); + ChatMessage assistantMessage{QStringLiteral("assistant"), response.content, QDateTime::currentDateTime(), userMessage.provider, userMessage.model}; + appendExchange(userMessage, assistantMessage); } if (callback) @@ -151,14 +186,16 @@ void ConversationManager::sendUserMessageStreaming(const QString &message, Strea return; } - const ChatMessage userMessage{QStringLiteral("user"), content}; + const QDateTime timestamp = QDateTime::currentDateTime(); + const ChatMessage userMessage{QStringLiteral("user"), content, timestamp, m_currentProvider, m_currentModel}; m_provider->sendStreamingChatRequest( buildRequest(userMessage), std::move(streamCallback), [this, userMessage, callback = std::move(callback)](const ChatResponse &response) mutable { if (response.success) { - appendExchange(userMessage, {QStringLiteral("assistant"), response.content}); + ChatMessage assistantMessage{QStringLiteral("assistant"), response.content, QDateTime::currentDateTime(), userMessage.provider, userMessage.model}; + appendExchange(userMessage, assistantMessage); } if (callback) @@ -192,7 +229,7 @@ ChatRequest ConversationManager::buildRequest(const ChatMessage &userMessage) co ChatRequest request; if (!m_systemPrompt.trimmed().isEmpty()) { - request.messages.append({QStringLiteral("system"), m_systemPrompt}); + request.messages.append(ChatMessage{QStringLiteral("system"), m_systemPrompt}); } const int firstHistoryIndex = qMax(0, m_history.size() - m_maxRequestContextMessages); diff --git a/src/ai/ConversationManager.h b/src/ai/ConversationManager.h index 51443bc..f1f0a92 100644 --- a/src/ai/ConversationManager.h +++ b/src/ai/ConversationManager.h @@ -23,7 +23,10 @@ public: void setHistory(const QVector &history); void setRequestContextMessageLimit(int maxMessages); void setMemoryHistoryMessageLimit(int maxMessages); + void setConversationMetadata(const QString &provider, const QString &model); bool setProvider(std::unique_ptr provider); + ChatRequest buildRequestForUserMessage(const QString &message) const; + void appendExternalExchange(const QString &userMessage, const QString &assistantMessage); void sendUserMessage(const QString &message, ResponseCallback callback); void sendUserMessageStreaming(const QString &message, StreamCallback streamCallback, ResponseCallback callback); void cancel(); @@ -38,6 +41,8 @@ private: std::unique_ptr m_provider; QVector m_history; QString m_systemPrompt; + QString m_currentProvider; + QString m_currentModel; int m_maxRequestContextMessages = 12; int m_maxStoredHistoryMessages = 200; int m_prunedHistoryMessageCount = 0; diff --git a/src/ai/ConversationStore.cpp b/src/ai/ConversationStore.cpp index db2296d..e35dc26 100644 --- a/src/ai/ConversationStore.cpp +++ b/src/ai/ConversationStore.cpp @@ -24,14 +24,39 @@ QJsonObject objectFromMessage(const ChatMessage &message) QJsonObject object; object.insert(QStringLiteral("role"), message.role); object.insert(QStringLiteral("content"), message.content); + if (message.timestamp.isValid()) + { + object.insert(QStringLiteral("timestamp"), message.timestamp.toString(Qt::ISODate)); + } + if (!message.provider.trimmed().isEmpty()) + { + object.insert(QStringLiteral("provider"), message.provider.trimmed()); + } + if (!message.model.trimmed().isEmpty()) + { + object.insert(QStringLiteral("model"), message.model.trimmed()); + } return object; } ChatMessage messageFromObject(const QJsonObject &object) { + QDateTime timestamp = QDateTime::fromString( + object.value(QStringLiteral("timestamp")).toString(), + Qt::ISODate); + if (!timestamp.isValid()) + { + timestamp = QDateTime::fromString( + object.value(QStringLiteral("createdAt")).toString(), + Qt::ISODate); + } + return { object.value(QStringLiteral("role")).toString().trimmed(), - object.value(QStringLiteral("content")).toString() + object.value(QStringLiteral("content")).toString(), + timestamp, + object.value(QStringLiteral("provider")).toString().trimmed(), + object.value(QStringLiteral("model")).toString().trimmed() }; } diff --git a/src/ai/LLMTypes.h b/src/ai/LLMTypes.h index 5493280..fa8f5dc 100644 --- a/src/ai/LLMTypes.h +++ b/src/ai/LLMTypes.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include @@ -7,6 +8,9 @@ struct ChatMessage { QString role; QString content; + QDateTime timestamp; + QString provider; + QString model; }; struct ChatRequest diff --git a/src/assistant/CommandDispatcher.cpp b/src/assistant/CommandDispatcher.cpp index d18a09e..07086b6 100644 --- a/src/assistant/CommandDispatcher.cpp +++ b/src/assistant/CommandDispatcher.cpp @@ -12,8 +12,8 @@ QString userIntentTypeName(UserIntentType type) return QStringLiteral("Weather"); case UserIntentType::FileOperation: return QStringLiteral("FileOperation"); - case UserIntentType::Search: - return QStringLiteral("Search"); + case UserIntentType::LaunchApp: + return QStringLiteral("LaunchApp"); } return QStringLiteral("Unknown"); @@ -32,6 +32,21 @@ CommandDispatchResult CommandDispatcher::dispatch(const QString &text) const return {CommandDispatchAction::Reminder, intent, intent.text}; } + if (intent.type == UserIntentType::Weather) + { + return {CommandDispatchAction::Weather, intent, intent.text}; + } + + if (intent.type == UserIntentType::FileOperation) + { + return {CommandDispatchAction::FileOperation, intent, intent.text}; + } + + if (intent.type == UserIntentType::LaunchApp) + { + return {CommandDispatchAction::LaunchApp, intent, intent.text}; + } + return {CommandDispatchAction::UnsupportedTool, intent, unsupportedToolMessage(intent.type)}; } @@ -40,16 +55,16 @@ QString CommandDispatcher::unsupportedToolMessage(UserIntentType type) const switch (type) { case UserIntentType::Reminder: - return QStringLiteral("提醒功能尚未接入,我现在还不能创建本地提醒。"); + return QStringLiteral("提醒请求暂时无法处理,请稍后再试。"); case UserIntentType::Weather: - return QStringLiteral("天气查询功能尚未接入,我现在还不能查询实时天气。"); + return QStringLiteral("天气查询请求暂时无法处理,请稍后再试。"); case UserIntentType::FileOperation: - return QStringLiteral("本地文件操作功能尚未接入。为避免风险,我现在不会读写或删除文件。"); - case UserIntentType::Search: - return QStringLiteral("联网搜索功能尚未接入,我现在不会发起搜索请求。"); + return QStringLiteral("本地文件操作请求暂时无法处理,请稍后再试。"); + case UserIntentType::LaunchApp: + return QStringLiteral("应用启动请求暂时无法处理,请稍后再试。"); case UserIntentType::Chat: return QString(); } - return QStringLiteral("该工具功能尚未接入。"); + return QStringLiteral("该请求暂时无法处理,请稍后再试。"); } diff --git a/src/assistant/CommandDispatcher.h b/src/assistant/CommandDispatcher.h index 8ae4b75..3f990dc 100644 --- a/src/assistant/CommandDispatcher.h +++ b/src/assistant/CommandDispatcher.h @@ -8,6 +8,9 @@ enum class CommandDispatchAction { Chat, Reminder, + Weather, + FileOperation, + LaunchApp, UnsupportedTool, }; diff --git a/src/assistant/IntentRouter.cpp b/src/assistant/IntentRouter.cpp index 3fb7e26..d2f43a2 100644 --- a/src/assistant/IntentRouter.cpp +++ b/src/assistant/IntentRouter.cpp @@ -76,20 +76,33 @@ bool isWeatherIntent(const QString &text) return containsAny(text, keywords); } -bool isSearchIntent(const QString &text) +bool isLaunchAppIntent(const QString &text) { + static const QStringList blockedFileKeywords = { + QStringLiteral("打开文件"), + QStringLiteral("打开文件夹"), + QStringLiteral("打开目录"), + QStringLiteral("读取文件"), + QStringLiteral("复制文件"), + QStringLiteral("备份文件"), + QStringLiteral("重命名文件"), + QStringLiteral("保存到"), + QStringLiteral("截图"), + QStringLiteral("打包"), + QStringLiteral("压缩"), + QStringLiteral("脚本"), + QStringLiteral("命令"), + }; + if (containsAny(text, blockedFileKeywords)) + { + return false; + } + static const QStringList keywords = { - QStringLiteral("搜索"), - QStringLiteral("搜一下"), - QStringLiteral("查一下"), - QStringLiteral("查找"), - QStringLiteral("联网"), - QStringLiteral("最新"), - QStringLiteral("官网"), - QStringLiteral("新闻"), - QStringLiteral("版本"), - QStringLiteral("文档"), - QStringLiteral("报错"), + QStringLiteral("打开"), + QStringLiteral("启动"), + QStringLiteral("运行"), + QStringLiteral("唤起"), }; return containsAny(text, keywords); } @@ -114,9 +127,9 @@ UserIntent IntentRouter::route(const QString &text) const return {UserIntentType::Weather, trimmedText}; } - if (isSearchIntent(trimmedText)) + if (isLaunchAppIntent(trimmedText)) { - return {UserIntentType::Search, trimmedText}; + return {UserIntentType::LaunchApp, trimmedText}; } return {UserIntentType::Chat, trimmedText}; diff --git a/src/assistant/UserIntent.h b/src/assistant/UserIntent.h index 2bc53b5..0f8e956 100644 --- a/src/assistant/UserIntent.h +++ b/src/assistant/UserIntent.h @@ -8,7 +8,7 @@ enum class UserIntentType Reminder, Weather, FileOperation, - Search, + LaunchApp, }; struct UserIntent diff --git a/src/character/CharacterPackageRepository.cpp b/src/character/CharacterPackageRepository.cpp index 967717f..10b8662 100644 --- a/src/character/CharacterPackageRepository.cpp +++ b/src/character/CharacterPackageRepository.cpp @@ -629,6 +629,70 @@ bool CharacterPackageRepository::importPackageDirectory( return true; } +bool CharacterPackageRepository::exportPackageDirectory( + const QString &characterId, + const QString &targetParentDirectoryPath, + bool overwrite, + QString *exportedPath, + QString *errorMessage) +{ + const QString trimmed = characterId.trimmed(); + if (!isValidCharacterId(trimmed)) + { + return setError(errorMessage, QStringLiteral("角色 id 无效。")); + } + + const QString sourcePath = packagePath(trimmed); + if (sourcePath.isEmpty() || !QFileInfo::exists(QDir(sourcePath).filePath(QStringLiteral("character.json")))) + { + return setError(errorMessage, QStringLiteral("角色包不存在或不可导出。")); + } + + QString validationId; + QString displayName; + if (!validatePackageDirectory(sourcePath, &validationId, &displayName, errorMessage)) + { + return false; + } + + const QFileInfo targetParentInfo(QDir::cleanPath(targetParentDirectoryPath)); + if (!targetParentInfo.exists() || !targetParentInfo.isDir()) + { + return setError(errorMessage, QStringLiteral("导出目标目录不存在。")); + } + + const QString targetPath = QDir(targetParentInfo.absoluteFilePath()).filePath(trimmed); + const QString cleanedSourcePath = QDir::cleanPath(sourcePath); + const QString cleanedTargetPath = QDir::cleanPath(targetPath); + if (cleanedSourcePath == cleanedTargetPath) + { + return setError(errorMessage, QStringLiteral("导出目标不能与源角色目录相同。")); + } + + QDir targetRoot(targetParentInfo.absoluteFilePath()); + const QString exportStamp = QDateTime::currentDateTime().toString(QStringLiteral("yyyyMMdd-HHmmss-zzz")); + const QString preparedPath = targetRoot.filePath(QStringLiteral(".exporting-") + trimmed + QStringLiteral("-") + exportStamp); + QDir(preparedPath).removeRecursively(); + + if (!copyDirectoryRecursively(cleanedSourcePath, preparedPath, errorMessage)) + { + QDir(preparedPath).removeRecursively(); + return false; + } + + if (!replaceDirectoryWithPreparedImport(preparedPath, cleanedTargetPath, overwrite, errorMessage)) + { + QDir(preparedPath).removeRecursively(); + return false; + } + + if (exportedPath != nullptr) + { + *exportedPath = cleanedTargetPath; + } + return true; +} + bool CharacterPackageRepository::deleteUserPackage(const QString &characterId, QString *errorMessage) { const QString trimmed = characterId.trimmed(); diff --git a/src/character/CharacterPackageRepository.h b/src/character/CharacterPackageRepository.h index 58f11b1..e6c5c87 100644 --- a/src/character/CharacterPackageRepository.h +++ b/src/character/CharacterPackageRepository.h @@ -52,5 +52,11 @@ public: bool overwrite, QString *importedCharacterId = nullptr, QString *errorMessage = nullptr); + static bool exportPackageDirectory( + const QString &characterId, + const QString &targetParentDirectoryPath, + bool overwrite, + QString *exportedPath = nullptr, + QString *errorMessage = nullptr); static bool deleteUserPackage(const QString &characterId, QString *errorMessage = nullptr); }; diff --git a/src/config/AppConfig.h b/src/config/AppConfig.h index b5e1fd3..4ec1678 100644 --- a/src/config/AppConfig.h +++ b/src/config/AppConfig.h @@ -8,6 +8,7 @@ struct AppConfig QPoint windowPosition = QPoint(100, 100); bool hasWindowPosition = false; bool alwaysOnTop = true; + bool launchAtStartup = false; double scale = 1.0; QString performanceMode = QStringLiteral("standard"); bool pauseWhenHidden = true; diff --git a/src/config/ConfigManager.cpp b/src/config/ConfigManager.cpp index 912fc3e..8e886cd 100644 --- a/src/config/ConfigManager.cpp +++ b/src/config/ConfigManager.cpp @@ -29,6 +29,13 @@ QJsonObject windowObjectFromConfig(const AppConfig &config) return window; } +QJsonObject systemObjectFromConfig(const AppConfig &config) +{ + QJsonObject system; + system.insert(QStringLiteral("launchAtStartup"), config.launchAtStartup); + return system; +} + QJsonObject performanceObjectFromConfig(const AppConfig &config) { QJsonObject performance; @@ -210,6 +217,12 @@ AppConfig ConfigManager::loadAppConfig() const config.scale = window.value(QStringLiteral("scale")).toDouble(config.scale); } + const QJsonObject system = root.value(QStringLiteral("system")).toObject(); + if (system.contains(QStringLiteral("launchAtStartup"))) + { + config.launchAtStartup = system.value(QStringLiteral("launchAtStartup")).toBool(config.launchAtStartup); + } + const QJsonObject performance = root.value(QStringLiteral("performance")).toObject(); if (performance.contains(QStringLiteral("mode"))) { @@ -373,6 +386,7 @@ bool ConfigManager::saveAppConfig(const AppConfig &config) const QJsonObject root; root.insert(QStringLiteral("window"), windowObjectFromConfig(config)); + root.insert(QStringLiteral("system"), systemObjectFromConfig(config)); root.insert(QStringLiteral("performance"), performanceObjectFromConfig(config)); root.insert(QStringLiteral("chat"), chatObjectFromConfig(config)); root.insert(QStringLiteral("character"), characterObjectFromConfig(config)); diff --git a/src/fileops/FileBackupManager.cpp b/src/fileops/FileBackupManager.cpp new file mode 100644 index 0000000..c5a7a48 --- /dev/null +++ b/src/fileops/FileBackupManager.cpp @@ -0,0 +1,49 @@ +#include "FileBackupManager.h" + +#include +#include +#include +#include + +namespace +{ +bool setError(QString *errorMessage, const QString &message) +{ + if (errorMessage != nullptr) + { + *errorMessage = message; + } + return false; +} +} + +bool FileBackupManager::createBackup(const QString &sourceFilePath, QString *backupPath, QString *errorMessage) const +{ + if (!m_sandbox.validateReadableFile(sourceFilePath, errorMessage)) + { + return false; + } + + const QFileInfo sourceInfo(sourceFilePath); + const QString timestamp = QDateTime::currentDateTime().toString(QStringLiteral("yyyyMMdd-HHmmss")); + const QString suffix = sourceInfo.suffix().trimmed(); + const QString backupName = suffix.isEmpty() + ? QStringLiteral("%1.backup.%2").arg(sourceInfo.completeBaseName(), timestamp) + : QStringLiteral("%1.backup.%2.%3").arg(sourceInfo.completeBaseName(), timestamp, suffix); + const QString targetPath = QDir(sourceInfo.absolutePath()).filePath(backupName); + if (!m_sandbox.validateNewFileTarget(targetPath, errorMessage)) + { + return false; + } + + if (!QFile::copy(sourceInfo.absoluteFilePath(), targetPath)) + { + return setError(errorMessage, QStringLiteral("创建备份文件失败。")); + } + + if (backupPath != nullptr) + { + *backupPath = targetPath; + } + return true; +} diff --git a/src/fileops/FileBackupManager.h b/src/fileops/FileBackupManager.h new file mode 100644 index 0000000..244d39c --- /dev/null +++ b/src/fileops/FileBackupManager.h @@ -0,0 +1,14 @@ +#pragma once + +#include "FileSandbox.h" + +#include + +class FileBackupManager +{ +public: + bool createBackup(const QString &sourceFilePath, QString *backupPath = nullptr, QString *errorMessage = nullptr) const; + +private: + FileSandbox m_sandbox; +}; diff --git a/src/fileops/FileOperationManager.cpp b/src/fileops/FileOperationManager.cpp new file mode 100644 index 0000000..66ca3f4 --- /dev/null +++ b/src/fileops/FileOperationManager.cpp @@ -0,0 +1,208 @@ +#include "FileOperationManager.h" + +#include +#include +#include +#include +#include + +namespace +{ +constexpr qint64 MaxReadBytes = 64 * 1024; +constexpr int MaxDirectoryEntries = 200; + +FileOperationResult failedResult(const QString &message) +{ + FileOperationResult result; + result.success = false; + result.errorMessage = message; + return result; +} + +QString fileDisplayName(const QString &path) +{ + const QFileInfo info(path); + return info.fileName().isEmpty() ? info.absoluteFilePath() : info.fileName(); +} + +QString copyTargetPath(const QString &sourceFilePath, const QString &targetDirectoryPath) +{ + return QDir(targetDirectoryPath).filePath(QFileInfo(sourceFilePath).fileName()); +} +} + +FileOperationPlan FileOperationManager::readTextFilePlan(const QString &filePath) const +{ + FileOperationPlan plan; + plan.type = FileOperationType::ReadTextFile; + plan.title = QStringLiteral("读取文本文件"); + plan.sourcePath = QFileInfo(filePath).absoluteFilePath(); + plan.description = QStringLiteral("读取文本文件:%1").arg(plan.sourcePath); + return plan; +} + +FileOperationPlan FileOperationManager::listDirectoryPlan(const QString &directoryPath) const +{ + FileOperationPlan plan; + plan.type = FileOperationType::ListDirectory; + plan.title = QStringLiteral("列出文件夹"); + plan.sourcePath = QFileInfo(directoryPath).absoluteFilePath(); + plan.description = QStringLiteral("列出文件夹内容:%1").arg(plan.sourcePath); + return plan; +} + +FileOperationPlan FileOperationManager::copyFilePlan(const QString &sourceFilePath, const QString &targetDirectoryPath) const +{ + FileOperationPlan plan; + plan.type = FileOperationType::CopyFile; + plan.title = QStringLiteral("复制文件"); + plan.sourcePath = QFileInfo(sourceFilePath).absoluteFilePath(); + plan.targetPath = copyTargetPath(plan.sourcePath, QFileInfo(targetDirectoryPath).absoluteFilePath()); + plan.description = QStringLiteral("复制文件:\n%1\n到:\n%2").arg(plan.sourcePath, plan.targetPath); + plan.requiresConfirmation = true; + return plan; +} + +FileOperationPlan FileOperationManager::backupFilePlan(const QString &sourceFilePath) const +{ + FileOperationPlan plan; + plan.type = FileOperationType::BackupFile; + plan.title = QStringLiteral("创建备份"); + plan.sourcePath = QFileInfo(sourceFilePath).absoluteFilePath(); + plan.description = QStringLiteral("为文件创建同目录备份:\n%1").arg(plan.sourcePath); + plan.requiresConfirmation = true; + return plan; +} + +FileOperationPlan FileOperationManager::renameFilePlan(const QString &sourceFilePath, const QString &newFileName) const +{ + FileOperationPlan plan; + plan.type = FileOperationType::RenameFile; + plan.title = QStringLiteral("重命名文件"); + plan.sourcePath = QFileInfo(sourceFilePath).absoluteFilePath(); + plan.targetPath = QDir(QFileInfo(plan.sourcePath).absolutePath()).filePath(newFileName.trimmed()); + plan.description = QStringLiteral("重命名文件:\n%1\n为:\n%2").arg(plan.sourcePath, plan.targetPath); + plan.requiresConfirmation = true; + return plan; +} + +FileOperationResult FileOperationManager::executeReadTextFile(const FileOperationPlan &plan) const +{ + QString errorMessage; + if (!m_sandbox.validateReadableTextFile(plan.sourcePath, &errorMessage)) + { + return failedResult(errorMessage); + } + + QFile file(plan.sourcePath); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) + { + return failedResult(QStringLiteral("无法读取文本文件。")); + } + + const QByteArray content = file.read(MaxReadBytes + 1); + QString text = QString::fromUtf8(content.left(MaxReadBytes)); + if (content.size() > MaxReadBytes) + { + text += QStringLiteral("\n\n[文件较大,仅显示前 64KB。]"); + } + + FileOperationResult result; + result.success = true; + result.outputText = text; + result.message = QStringLiteral("已读取:%1").arg(fileDisplayName(plan.sourcePath)); + return result; +} + +FileOperationResult FileOperationManager::executeListDirectory(const FileOperationPlan &plan) const +{ + QString errorMessage; + if (!m_sandbox.validateReadableDirectory(plan.sourcePath, &errorMessage)) + { + return failedResult(errorMessage); + } + + const QDir directory(plan.sourcePath); + const QFileInfoList entries = directory.entryInfoList( + QDir::NoDotAndDotDot | QDir::AllEntries, + QDir::DirsFirst | QDir::Name); + + QStringList lines; + lines.append(QStringLiteral("文件夹内容:%1").arg(directory.absolutePath())); + const int count = qMin(entries.size(), MaxDirectoryEntries); + for (int index = 0; index < count; ++index) + { + const QFileInfo &entry = entries.at(index); + lines.append(QStringLiteral("%1 %2").arg(entry.isDir() ? QStringLiteral("[目录]") : QStringLiteral("[文件]"), entry.fileName())); + } + if (entries.size() > MaxDirectoryEntries) + { + lines.append(QStringLiteral("[仅显示前 %1 项,共 %2 项]").arg(MaxDirectoryEntries).arg(entries.size())); + } + + FileOperationResult result; + result.success = true; + result.outputText = lines.join(QLatin1Char('\n')); + result.message = QStringLiteral("已列出文件夹。"); + return result; +} + +FileOperationResult FileOperationManager::executeCopyFile(const FileOperationPlan &plan) const +{ + QString errorMessage; + if (!m_sandbox.validateReadableFile(plan.sourcePath, &errorMessage) + || !m_sandbox.validateNewFileTarget(plan.targetPath, &errorMessage)) + { + return failedResult(errorMessage); + } + + if (!QFile::copy(plan.sourcePath, plan.targetPath)) + { + return failedResult(QStringLiteral("复制文件失败。")); + } + + FileOperationResult result; + result.success = true; + result.targetPath = plan.targetPath; + result.message = QStringLiteral("已复制到:%1").arg(plan.targetPath); + return result; +} + +FileOperationResult FileOperationManager::executeBackupFile(const FileOperationPlan &plan) const +{ + QString backupPath; + QString errorMessage; + if (!m_backupManager.createBackup(plan.sourcePath, &backupPath, &errorMessage)) + { + return failedResult(errorMessage); + } + + FileOperationResult result; + result.success = true; + result.targetPath = backupPath; + result.message = QStringLiteral("已创建备份:%1").arg(backupPath); + return result; +} + +FileOperationResult FileOperationManager::executeRenameFile(const FileOperationPlan &plan) const +{ + QString errorMessage; + if (!m_sandbox.validateReadableFile(plan.sourcePath, &errorMessage) + || !m_sandbox.validateSafeFileName(QFileInfo(plan.targetPath).fileName(), &errorMessage) + || !m_sandbox.validateNewFileTarget(plan.targetPath, &errorMessage)) + { + return failedResult(errorMessage); + } + + QFile file(plan.sourcePath); + if (!file.rename(plan.targetPath)) + { + return failedResult(QStringLiteral("重命名文件失败。")); + } + + FileOperationResult result; + result.success = true; + result.targetPath = plan.targetPath; + result.message = QStringLiteral("已重命名为:%1").arg(plan.targetPath); + return result; +} diff --git a/src/fileops/FileOperationManager.h b/src/fileops/FileOperationManager.h new file mode 100644 index 0000000..3df8444 --- /dev/null +++ b/src/fileops/FileOperationManager.h @@ -0,0 +1,27 @@ +#pragma once + +#include "FileBackupManager.h" +#include "FileOperationTypes.h" +#include "FileSandbox.h" + +#include + +class FileOperationManager +{ +public: + FileOperationPlan readTextFilePlan(const QString &filePath) const; + FileOperationPlan listDirectoryPlan(const QString &directoryPath) const; + FileOperationPlan copyFilePlan(const QString &sourceFilePath, const QString &targetDirectoryPath) const; + FileOperationPlan backupFilePlan(const QString &sourceFilePath) const; + FileOperationPlan renameFilePlan(const QString &sourceFilePath, const QString &newFileName) const; + + FileOperationResult executeReadTextFile(const FileOperationPlan &plan) const; + FileOperationResult executeListDirectory(const FileOperationPlan &plan) const; + FileOperationResult executeCopyFile(const FileOperationPlan &plan) const; + FileOperationResult executeBackupFile(const FileOperationPlan &plan) const; + FileOperationResult executeRenameFile(const FileOperationPlan &plan) const; + +private: + FileSandbox m_sandbox; + FileBackupManager m_backupManager; +}; diff --git a/src/fileops/FileOperationTypes.h b/src/fileops/FileOperationTypes.h new file mode 100644 index 0000000..2321d58 --- /dev/null +++ b/src/fileops/FileOperationTypes.h @@ -0,0 +1,34 @@ +#pragma once + +#include +#include + +enum class FileOperationType +{ + ReadTextFile, + ListDirectory, + CopyFile, + BackupFile, + RenameFile, + ZipPackage, +}; + +struct FileOperationPlan +{ + FileOperationType type = FileOperationType::ReadTextFile; + QString title; + QString description; + QString sourcePath; + QString targetPath; + QStringList warnings; + bool requiresConfirmation = false; +}; + +struct FileOperationResult +{ + bool success = false; + QString message; + QString errorMessage; + QString outputText; + QString targetPath; +}; diff --git a/src/fileops/FileSandbox.cpp b/src/fileops/FileSandbox.cpp new file mode 100644 index 0000000..b541dc3 --- /dev/null +++ b/src/fileops/FileSandbox.cpp @@ -0,0 +1,212 @@ +#include "FileSandbox.h" + +#include +#include +#include +#include + +namespace +{ +bool setError(QString *errorMessage, const QString &message) +{ + if (errorMessage != nullptr) + { + *errorMessage = message; + } + return false; +} + +QString cleanedAbsolutePath(const QString &path) +{ + QString cleaned = QDir::cleanPath(QFileInfo(path).absoluteFilePath()); + cleaned.replace(QLatin1Char('\\'), QLatin1Char('/')); + return cleaned; +} + +bool isWindowsSystemPath(const QString &path) +{ + const QString cleaned = cleanedAbsolutePath(path).toUpper(); + QStringList deniedPrefixes = { + QStringLiteral("C:/WINDOWS"), + QStringLiteral("C:/PROGRAM FILES"), + QStringLiteral("C:/PROGRAM FILES (X86)"), + QStringLiteral("C:/PROGRAMDATA"), + QStringLiteral("C:/SYSTEM VOLUME INFORMATION"), + QStringLiteral("C:/$RECYCLE.BIN") + }; + + const auto addEnvironmentPath = [&deniedPrefixes](const char *name) { + const QString value = qEnvironmentVariable(name).trimmed(); + if (!value.isEmpty()) + { + deniedPrefixes.append(cleanedAbsolutePath(value).toUpper()); + } + }; + addEnvironmentPath("WINDIR"); + addEnvironmentPath("SystemRoot"); + addEnvironmentPath("ProgramFiles"); + addEnvironmentPath("ProgramFiles(x86)"); + addEnvironmentPath("ProgramData"); + + for (const QString &prefix : deniedPrefixes) + { + if (cleaned == prefix || cleaned.startsWith(prefix + QLatin1Char('/'))) + { + return true; + } + } + return false; +} + +bool hasSymlinkSegment(const QString &path) +{ + QFileInfo info(path); + if (info.isSymLink()) + { + return true; + } + + QDir directory(info.isDir() ? info.absoluteFilePath() : info.absolutePath()); + while (directory.exists()) + { + const QFileInfo directoryInfo(directory.absolutePath()); + if (directoryInfo.isSymLink()) + { + return true; + } + + if (!directory.cdUp()) + { + break; + } + } + return false; +} + +bool isSupportedTextSuffix(const QString &suffix) +{ + static const QStringList supported = { + QStringLiteral("txt"), + QStringLiteral("md"), + QStringLiteral("markdown"), + QStringLiteral("log"), + QStringLiteral("json"), + QStringLiteral("csv"), + QStringLiteral("ini"), + QStringLiteral("xml"), + QStringLiteral("yaml"), + QStringLiteral("yml") + }; + return supported.contains(suffix.trimmed().toLower()); +} +} + +bool FileSandbox::validateReadableFile(const QString &filePath, QString *errorMessage) const +{ + const QFileInfo info(filePath); + if (!info.exists() || !info.isFile()) + { + return setError(errorMessage, QStringLiteral("请选择一个存在的文件。")); + } + if (!info.isReadable()) + { + return setError(errorMessage, QStringLiteral("文件不可读取。")); + } + if (hasSymlinkSegment(info.absoluteFilePath())) + { + return setError(errorMessage, QStringLiteral("不允许操作符号链接或符号链接目录内的文件。")); + } + if (isWindowsSystemPath(info.absoluteFilePath())) + { + return setError(errorMessage, QStringLiteral("不允许访问系统目录内的文件。")); + } + return true; +} + +bool FileSandbox::validateReadableTextFile(const QString &filePath, QString *errorMessage) const +{ + if (!validateReadableFile(filePath, errorMessage)) + { + return false; + } + + const QFileInfo info(filePath); + if (!isSupportedTextSuffix(info.suffix())) + { + return setError(errorMessage, QStringLiteral("只允许读取常见文本文件:txt、md、log、json、csv、ini、xml、yaml。")); + } + return true; +} + +bool FileSandbox::validateReadableDirectory(const QString &directoryPath, QString *errorMessage) const +{ + const QFileInfo info(directoryPath); + if (!info.exists() || !info.isDir()) + { + return setError(errorMessage, QStringLiteral("请选择一个存在的文件夹。")); + } + if (!info.isReadable()) + { + return setError(errorMessage, QStringLiteral("文件夹不可读取。")); + } + if (hasSymlinkSegment(info.absoluteFilePath())) + { + return setError(errorMessage, QStringLiteral("不允许操作符号链接或符号链接目录。")); + } + if (isWindowsSystemPath(info.absoluteFilePath())) + { + return setError(errorMessage, QStringLiteral("不允许访问系统目录。")); + } + return true; +} + +bool FileSandbox::validateWritableDirectory(const QString &directoryPath, QString *errorMessage) const +{ + const QFileInfo info(directoryPath); + if (!info.exists() || !info.isDir()) + { + return setError(errorMessage, QStringLiteral("请选择一个存在的目标文件夹。")); + } + if (!info.isWritable()) + { + return setError(errorMessage, QStringLiteral("目标文件夹不可写。")); + } + if (hasSymlinkSegment(info.absoluteFilePath())) + { + return setError(errorMessage, QStringLiteral("不允许写入符号链接目录。")); + } + if (isWindowsSystemPath(info.absoluteFilePath())) + { + return setError(errorMessage, QStringLiteral("不允许写入系统目录。")); + } + return true; +} + +bool FileSandbox::validateNewFileTarget(const QString &filePath, QString *errorMessage) const +{ + const QFileInfo info(filePath); + if (info.exists()) + { + return setError(errorMessage, QStringLiteral("目标文件已存在,本版不会覆盖文件。")); + } + return validateWritableDirectory(info.absolutePath(), errorMessage); +} + +bool FileSandbox::validateSafeFileName(const QString &fileName, QString *errorMessage) const +{ + const QString trimmed = fileName.trimmed(); + if (trimmed.isEmpty() || trimmed == QStringLiteral(".") || trimmed == QStringLiteral("..")) + { + return setError(errorMessage, QStringLiteral("文件名不能为空。")); + } + if (trimmed.contains(QLatin1Char('/')) || trimmed.contains(QLatin1Char('\\')) || trimmed.contains(QLatin1Char(':'))) + { + return setError(errorMessage, QStringLiteral("文件名不能包含路径分隔符或冒号。")); + } + if (trimmed.contains(QLatin1Char('*')) || trimmed.contains(QLatin1Char('?')) || trimmed.contains(QLatin1Char('"')) + || trimmed.contains(QLatin1Char('<')) || trimmed.contains(QLatin1Char('>')) || trimmed.contains(QLatin1Char('|'))) + { + return setError(errorMessage, QStringLiteral("文件名包含 Windows 不允许的字符。")); + } + return true; +} diff --git a/src/fileops/FileSandbox.h b/src/fileops/FileSandbox.h new file mode 100644 index 0000000..abcea17 --- /dev/null +++ b/src/fileops/FileSandbox.h @@ -0,0 +1,14 @@ +#pragma once + +#include + +class FileSandbox +{ +public: + bool validateReadableFile(const QString &filePath, QString *errorMessage = nullptr) const; + bool validateReadableTextFile(const QString &filePath, QString *errorMessage = nullptr) const; + bool validateReadableDirectory(const QString &directoryPath, QString *errorMessage = nullptr) const; + bool validateWritableDirectory(const QString &directoryPath, QString *errorMessage = nullptr) const; + bool validateNewFileTarget(const QString &filePath, QString *errorMessage = nullptr) const; + bool validateSafeFileName(const QString &fileName, QString *errorMessage = nullptr) const; +}; diff --git a/src/launcher/AppDiscovery.cpp b/src/launcher/AppDiscovery.cpp new file mode 100644 index 0000000..fbdd342 --- /dev/null +++ b/src/launcher/AppDiscovery.cpp @@ -0,0 +1,194 @@ +#include "AppDiscovery.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +QString normalizedName(QString value) +{ + value = value.trimmed().toLower(); + value.remove(QChar::Space); + value.remove(QStringLiteral(".exe")); + value.remove(QStringLiteral(".lnk")); + return value; +} + +QStringList startMenuRoots() +{ + QStringList roots; + const QProcessEnvironment environment = QProcessEnvironment::systemEnvironment(); + const QString appData = environment.value(QStringLiteral("APPDATA")); + const QString programData = environment.value(QStringLiteral("PROGRAMDATA")); + if (!appData.isEmpty()) + { + roots.append(QDir(appData).filePath(QStringLiteral("Microsoft/Windows/Start Menu/Programs"))); + } + if (!programData.isEmpty()) + { + roots.append(QDir(programData).filePath(QStringLiteral("Microsoft/Windows/Start Menu/Programs"))); + } + return roots; +} + +RegisteredApp appFromShortcut(const QString &shortcutPath) +{ + RegisteredApp app; + const QFileInfo info(shortcutPath); + app.id = info.completeBaseName().toLower(); + app.displayName = info.completeBaseName(); + app.aliases = {app.displayName}; + app.shortcutPath = info.absoluteFilePath(); + app.workingDirectory = info.absolutePath(); + app.userDefined = false; + return app; +} + +RegisteredApp appFromExecutable(const QString &executablePath) +{ + RegisteredApp app; + const QFileInfo info(executablePath); + app.id = info.completeBaseName().toLower(); + app.displayName = info.completeBaseName(); + app.aliases = {app.displayName, info.fileName()}; + app.executablePath = info.absoluteFilePath(); + app.workingDirectory = info.absolutePath(); + app.userDefined = false; + return app; +} +} + +bool AppDiscovery::findRegisteredApp(const QString &requestedName, const AppLaunchConfig &config, RegisteredApp *app) const +{ + for (const RegisteredApp ®isteredApp : config.apps) + { + if (matchesName(requestedName, registeredApp)) + { + if (app != nullptr) + { + *app = registeredApp; + } + return true; + } + } + return false; +} + +bool AppDiscovery::findStartMenuShortcut(const QString &requestedName, RegisteredApp *app) const +{ + const QString requested = normalizedName(requestedName); + if (requested.isEmpty()) + { + return false; + } + + for (const QString &root : startMenuRoots()) + { + if (!QDir(root).exists()) + { + continue; + } + + QDirIterator iterator(root, QStringList{QStringLiteral("*.lnk")}, QDir::Files, QDirIterator::Subdirectories); + while (iterator.hasNext()) + { + const QString path = iterator.next(); + const QFileInfo info(path); + if (normalizedName(info.completeBaseName()) == requested) + { + if (app != nullptr) + { + *app = appFromShortcut(path); + } + return true; + } + } + } + return false; +} + +bool AppDiscovery::findAppPathRegistryEntry(const QString &requestedName, RegisteredApp *app) const +{ +#ifdef Q_OS_WIN + const QString requested = normalizedName(requestedName); + if (requested.isEmpty()) + { + return false; + } + + const QStringList roots = { + QStringLiteral("HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\App Paths"), + QStringLiteral("HKEY_LOCAL_MACHINE\\Software\\Microsoft\\Windows\\CurrentVersion\\App Paths"), + }; + for (const QString &root : roots) + { + QSettings settings(root, QSettings::NativeFormat); + for (const QString &group : settings.childGroups()) + { + const QFileInfo groupInfo(group); + if (normalizedName(groupInfo.completeBaseName()) != requested && normalizedName(group) != requested) + { + continue; + } + + settings.beginGroup(group); + QString executablePath = settings.value(QStringLiteral(".")).toString().trimmed(); + if (executablePath.isEmpty()) + { + executablePath = settings.value(QStringLiteral("")).toString().trimmed(); + } + settings.endGroup(); + if (executablePath.isEmpty()) + { + continue; + } + + if (app != nullptr) + { + *app = appFromExecutable(executablePath); + } + return true; + } + } +#else + Q_UNUSED(requestedName); + Q_UNUSED(app); +#endif + return false; +} + +bool AppDiscovery::matchesName(const QString &requestedName, const RegisteredApp &app) const +{ + const QString requested = normalizedName(requestedName); + if (requested.isEmpty()) + { + return false; + } + + if (normalizedName(app.displayName) == requested) + { + return true; + } + + for (const QString &alias : app.aliases) + { + if (normalizedName(alias) == requested) + { + return true; + } + } + + const QFileInfo executableInfo(app.executablePath); + if (!app.executablePath.isEmpty() && normalizedName(executableInfo.completeBaseName()) == requested) + { + return true; + } + + const QFileInfo shortcutInfo(app.shortcutPath); + return !app.shortcutPath.isEmpty() && normalizedName(shortcutInfo.completeBaseName()) == requested; +} diff --git a/src/launcher/AppDiscovery.h b/src/launcher/AppDiscovery.h new file mode 100644 index 0000000..c5be2c0 --- /dev/null +++ b/src/launcher/AppDiscovery.h @@ -0,0 +1,16 @@ +#pragma once + +#include "AppLaunchTypes.h" + +#include + +class AppDiscovery +{ +public: + bool findRegisteredApp(const QString &requestedName, const AppLaunchConfig &config, RegisteredApp *app) const; + bool findStartMenuShortcut(const QString &requestedName, RegisteredApp *app) const; + bool findAppPathRegistryEntry(const QString &requestedName, RegisteredApp *app) const; + +private: + bool matchesName(const QString &requestedName, const RegisteredApp &app) const; +}; diff --git a/src/launcher/AppLaunchManager.cpp b/src/launcher/AppLaunchManager.cpp new file mode 100644 index 0000000..74cd620 --- /dev/null +++ b/src/launcher/AppLaunchManager.cpp @@ -0,0 +1,291 @@ +#include "AppLaunchManager.h" + +#include "../util/Logger.h" + +#include +#include +#include +#include +#include + +namespace +{ +bool containsAny(const QString &text, const QStringList &keywords) +{ + for (const QString &keyword : keywords) + { + if (text.contains(keyword, Qt::CaseInsensitive)) + { + return true; + } + } + return false; +} + +QString sanitizedId(QString value) +{ + value = value.trimmed().toLower(); + value.replace(QRegularExpression(QStringLiteral("[^a-z0-9._-]+")), QStringLiteral("_")); + if (value.isEmpty()) + { + value = QStringLiteral("app"); + } + return value.left(80); +} + +QString normalizedExecutablePath(const QString &path) +{ + const QFileInfo info(path.trimmed()); + return info.exists() ? info.absoluteFilePath() : path.trimmed(); +} + +QString fileSuffix(const QString &path) +{ + return QFileInfo(path.trimmed()).suffix().toLower(); +} +} + +bool AppLaunchManager::isLaunchIntentText(const QString &text) const +{ + const QString normalized = text.trimmed(); + if (normalized.isEmpty() || isBlockedFileOperationText(normalized)) + { + return false; + } + + return containsAny(normalized, { + QStringLiteral("打开"), + QStringLiteral("启动"), + QStringLiteral("运行"), + QStringLiteral("唤起"), + }); +} + +QString AppLaunchManager::requestedAppNameFromText(const QString &text) const +{ + QString requested = text.trimmed(); + requested.remove(QRegularExpression(QStringLiteral("^(请|麻烦|帮我|帮忙|给我)"))); + requested.remove(QRegularExpression(QStringLiteral("(打开一下|启动一下|运行一下|打开|启动|运行|唤起)"))); + requested.remove(QRegularExpression(QStringLiteral("(这个|一下|应用|软件|程序)$"))); + requested = requested.simplified(); + return requested; +} + +AppLaunchPlan AppLaunchManager::resolveLaunchPlan(const QString &text, const AppLaunchConfig &config) const +{ + AppLaunchPlan plan; + plan.requestedName = requestedAppNameFromText(text); + if (!config.enabled) + { + plan.errorMessage = QStringLiteral("应用启动未启用,请先在设置页开启。"); + return plan; + } + + if (plan.requestedName.isEmpty() || !isLaunchIntentText(text)) + { + plan.errorMessage = QStringLiteral("没有识别到要启动的应用名称。"); + return plan; + } + + RegisteredApp app; + if (m_discovery.findRegisteredApp(plan.requestedName, config, &app)) + { + return planFromRegisteredApp(plan.requestedName, app, QStringLiteral("用户登记")); + } + + if (m_discovery.findStartMenuShortcut(plan.requestedName, &app)) + { + return planFromRegisteredApp(plan.requestedName, app, QStringLiteral("开始菜单")); + } + + if (m_discovery.findAppPathRegistryEntry(plan.requestedName, &app)) + { + return planFromRegisteredApp(plan.requestedName, app, QStringLiteral("App Paths 注册表")); + } + + if (config.allowManualExeSelection) + { + plan.needsManualSelection = true; + plan.errorMessage = QStringLiteral("没有找到匹配的应用,需要手动选择 exe。"); + return plan; + } + + plan.errorMessage = QStringLiteral("没有找到匹配的应用。可在设置页登记应用别名,或开启未知应用手选 exe。"); + return plan; +} + +AppLaunchPlan AppLaunchManager::manualSelectionPlan(const QString &text, const QString &executablePath) const +{ + AppLaunchPlan plan; + plan.requestedName = requestedAppNameFromText(text); + const QFileInfo info(executablePath); + plan.displayName = info.completeBaseName(); + plan.executablePath = normalizedExecutablePath(executablePath); + plan.workingDirectory = info.absolutePath(); + plan.matchSource = QStringLiteral("手动选择"); + plan.canRemember = true; + QString errorMessage; + plan.success = validateLaunchPlan(plan, &errorMessage); + plan.errorMessage = errorMessage; + return plan; +} + +AppLaunchResult AppLaunchManager::executeLaunchPlan(const AppLaunchPlan &plan) const +{ + QString errorMessage; + if (!validateLaunchPlan(plan, &errorMessage)) + { + return {false, {}, errorMessage}; + } + + bool started = false; + if (!plan.executablePath.isEmpty()) + { + started = QProcess::startDetached(plan.executablePath, QStringList(), plan.workingDirectory); + } + else + { + started = QDesktopServices::openUrl(QUrl::fromLocalFile(plan.shortcutPath)); + } + + if (!started) + { + Logger::warning(QStringLiteral("Failed to launch app: %1").arg(plan.displayName)); + return {false, {}, QStringLiteral("启动应用失败。")}; + } + + Logger::info(QStringLiteral("App launched: source=%1").arg(plan.matchSource)); + return {true, QStringLiteral("已启动:%1").arg(plan.displayName), {}}; +} + +RegisteredApp AppLaunchManager::registeredAppFromPlan(const AppLaunchPlan &plan, const QStringList &aliases) const +{ + RegisteredApp app; + app.id = sanitizedId(plan.requestedName.isEmpty() ? plan.displayName : plan.requestedName); + app.displayName = plan.displayName; + app.aliases = aliases; + if (app.aliases.isEmpty()) + { + app.aliases = {plan.requestedName.isEmpty() ? plan.displayName : plan.requestedName}; + } + if (!app.aliases.contains(app.displayName, Qt::CaseInsensitive)) + { + app.aliases.prepend(app.displayName); + } + app.executablePath = plan.executablePath; + app.shortcutPath = plan.shortcutPath; + app.workingDirectory = plan.workingDirectory; + app.userDefined = true; + return app; +} + +bool AppLaunchManager::isBlockedFileOperationText(const QString &text) const +{ + return containsAny(text, { + QStringLiteral("打开文件"), + QStringLiteral("打开文件夹"), + QStringLiteral("打开目录"), + QStringLiteral("读取文件"), + QStringLiteral("复制文件"), + QStringLiteral("备份文件"), + QStringLiteral("重命名文件"), + QStringLiteral("删除文件"), + QStringLiteral("移动文件"), + QStringLiteral("保存到"), + QStringLiteral("截图"), + QStringLiteral("打包"), + QStringLiteral("压缩"), + QStringLiteral("脚本"), + QStringLiteral("命令"), + }); +} + +bool AppLaunchManager::validateLaunchPlan(const AppLaunchPlan &plan, QString *errorMessage) const +{ + const QString executablePath = plan.executablePath.trimmed(); + const QString shortcutPath = plan.shortcutPath.trimmed(); + if (executablePath.isEmpty() && shortcutPath.isEmpty()) + { + if (errorMessage != nullptr) + { + *errorMessage = QStringLiteral("启动路径为空。"); + } + return false; + } + + if (!executablePath.isEmpty()) + { + const QFileInfo info(executablePath); + const QString suffix = fileSuffix(executablePath); + if (suffix != QStringLiteral("exe")) + { + if (errorMessage != nullptr) + { + *errorMessage = QStringLiteral("只允许启动 .exe 应用,不支持脚本、安装包或命令文件。"); + } + return false; + } + if (!info.exists() || !info.isFile()) + { + if (errorMessage != nullptr) + { + *errorMessage = QStringLiteral("应用文件不存在。"); + } + return false; + } + } + + if (!shortcutPath.isEmpty()) + { + const QString suffix = fileSuffix(shortcutPath); + if (suffix != QStringLiteral("lnk")) + { + if (errorMessage != nullptr) + { + *errorMessage = QStringLiteral("只允许打开开始菜单快捷方式。"); + } + return false; + } + if (!QFileInfo(shortcutPath).exists()) + { + if (errorMessage != nullptr) + { + *errorMessage = QStringLiteral("快捷方式不存在。"); + } + return false; + } + } + + if (containsAny(executablePath + shortcutPath, { + QStringLiteral(".bat"), + QStringLiteral(".cmd"), + QStringLiteral(".ps1"), + QStringLiteral(".vbs"), + QStringLiteral(".js"), + QStringLiteral(".msi"), + })) + { + if (errorMessage != nullptr) + { + *errorMessage = QStringLiteral("不支持启动脚本、命令文件或安装包。"); + } + return false; + } + + return true; +} + +AppLaunchPlan AppLaunchManager::planFromRegisteredApp(const QString &requestedName, const RegisteredApp &app, const QString &matchSource) const +{ + AppLaunchPlan plan; + plan.requestedName = requestedName; + plan.displayName = app.displayName; + plan.executablePath = app.executablePath; + plan.shortcutPath = app.shortcutPath; + plan.workingDirectory = app.workingDirectory; + plan.matchSource = matchSource; + QString errorMessage; + plan.success = validateLaunchPlan(plan, &errorMessage); + plan.errorMessage = errorMessage; + return plan; +} diff --git a/src/launcher/AppLaunchManager.h b/src/launcher/AppLaunchManager.h new file mode 100644 index 0000000..38ab808 --- /dev/null +++ b/src/launcher/AppLaunchManager.h @@ -0,0 +1,25 @@ +#pragma once + +#include "AppDiscovery.h" +#include "AppLaunchTypes.h" + +#include + +class AppLaunchManager +{ +public: + bool isLaunchIntentText(const QString &text) const; + QString requestedAppNameFromText(const QString &text) const; + + AppLaunchPlan resolveLaunchPlan(const QString &text, const AppLaunchConfig &config) const; + AppLaunchPlan manualSelectionPlan(const QString &text, const QString &executablePath) const; + AppLaunchResult executeLaunchPlan(const AppLaunchPlan &plan) const; + RegisteredApp registeredAppFromPlan(const AppLaunchPlan &plan, const QStringList &aliases) const; + +private: + bool isBlockedFileOperationText(const QString &text) const; + bool validateLaunchPlan(const AppLaunchPlan &plan, QString *errorMessage) const; + AppLaunchPlan planFromRegisteredApp(const QString &requestedName, const RegisteredApp &app, const QString &matchSource) const; + + AppDiscovery m_discovery; +}; diff --git a/src/launcher/AppLaunchStore.cpp b/src/launcher/AppLaunchStore.cpp new file mode 100644 index 0000000..b32a42a --- /dev/null +++ b/src/launcher/AppLaunchStore.cpp @@ -0,0 +1,302 @@ +#include "AppLaunchStore.h" + +#include "../util/Logger.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +const QString LauncherConfigFileName = QStringLiteral("launcher_config.json"); + +QString sanitizedId(const QString &value) +{ + QString id = value.trimmed().toLower(); + id.replace(QRegularExpression(QStringLiteral("[^a-z0-9._-]+")), QStringLiteral("_")); + id = id.trimmed(); + if (id.isEmpty()) + { + id = QStringLiteral("app"); + } + return id.left(80); +} + +QStringList aliasesFromJson(const QJsonArray &array) +{ + QStringList aliases; + for (const QJsonValue &value : array) + { + const QString alias = value.toString().trimmed(); + if (!alias.isEmpty() && !aliases.contains(alias, Qt::CaseInsensitive)) + { + aliases.append(alias); + } + } + return aliases; +} + +QJsonArray aliasesToJson(const QStringList &aliases) +{ + QJsonArray array; + for (const QString &alias : aliases) + { + const QString normalized = alias.trimmed(); + if (!normalized.isEmpty()) + { + array.append(normalized); + } + } + return array; +} + +RegisteredApp appFromJson(const QJsonObject &object) +{ + RegisteredApp app; + app.id = sanitizedId(object.value(QStringLiteral("id")).toString()); + app.displayName = object.value(QStringLiteral("displayName")).toString().trimmed(); + app.aliases = aliasesFromJson(object.value(QStringLiteral("aliases")).toArray()); + app.executablePath = object.value(QStringLiteral("executablePath")).toString().trimmed(); + app.shortcutPath = object.value(QStringLiteral("shortcutPath")).toString().trimmed(); + app.workingDirectory = object.value(QStringLiteral("workingDirectory")).toString().trimmed(); + app.userDefined = object.value(QStringLiteral("userDefined")).toBool(true); + if (app.displayName.isEmpty()) + { + app.displayName = QFileInfo(app.executablePath.isEmpty() ? app.shortcutPath : app.executablePath).completeBaseName(); + } + if (app.aliases.isEmpty() && !app.displayName.isEmpty()) + { + app.aliases.append(app.displayName); + } + return app; +} + +QJsonObject jsonFromApp(const RegisteredApp &app) +{ + QJsonObject object; + object.insert(QStringLiteral("id"), sanitizedId(app.id.isEmpty() ? app.displayName : app.id)); + object.insert(QStringLiteral("displayName"), app.displayName.trimmed()); + object.insert(QStringLiteral("aliases"), aliasesToJson(app.aliases)); + object.insert(QStringLiteral("executablePath"), app.executablePath.trimmed()); + object.insert(QStringLiteral("shortcutPath"), app.shortcutPath.trimmed()); + object.insert(QStringLiteral("workingDirectory"), app.workingDirectory.trimmed()); + object.insert(QStringLiteral("userDefined"), app.userDefined); + return object; +} + +AppLaunchConfig normalizedConfig(AppLaunchConfig config) +{ + QVector apps; + QSet ids; + for (RegisteredApp app : config.apps) + { + app.id = sanitizedId(app.id.isEmpty() ? app.displayName : app.id); + if (ids.contains(app.id)) + { + int suffix = 2; + QString candidate; + do + { + candidate = app.id + QStringLiteral("_") + QString::number(suffix++); + } while (ids.contains(candidate)); + app.id = candidate; + } + + app.displayName = app.displayName.trimmed(); + app.executablePath = app.executablePath.trimmed(); + app.shortcutPath = app.shortcutPath.trimmed(); + app.workingDirectory = app.workingDirectory.trimmed(); + QStringList aliases; + for (const QString &alias : app.aliases) + { + const QString normalized = alias.trimmed(); + if (!normalized.isEmpty() && !aliases.contains(normalized, Qt::CaseInsensitive)) + { + aliases.append(normalized); + } + } + if (!app.displayName.isEmpty() && !aliases.contains(app.displayName, Qt::CaseInsensitive)) + { + aliases.prepend(app.displayName); + } + app.aliases = aliases; + if (app.displayName.isEmpty() || (app.executablePath.isEmpty() && app.shortcutPath.isEmpty())) + { + continue; + } + + ids.insert(app.id); + apps.append(app); + } + config.apps = apps; + return config; +} +} + +AppLaunchConfig AppLaunchStore::load(QString *errorMessage) const +{ + AppLaunchConfig config; + + QFile file(storePath()); + if (!file.exists()) + { + return config; + } + + if (!file.open(QIODevice::ReadOnly)) + { + if (errorMessage != nullptr) + { + *errorMessage = QStringLiteral("无法读取应用启动配置文件。"); + } + Logger::warning(QStringLiteral("Unable to read launcher config.")); + return config; + } + + QJsonParseError parseError; + const QJsonDocument document = QJsonDocument::fromJson(file.readAll(), &parseError); + file.close(); + if (parseError.error != QJsonParseError::NoError || !document.isObject()) + { + backupBrokenConfig(storePath()); + if (errorMessage != nullptr) + { + *errorMessage = QStringLiteral("应用启动配置文件损坏,已备份并使用默认配置。"); + } + Logger::warning(QStringLiteral("Launcher config is broken; default config will be used.")); + return config; + } + + const QJsonObject root = document.object(); + config.enabled = root.value(QStringLiteral("enabled")).toBool(config.enabled); + config.allowManualExeSelection = root.value(QStringLiteral("allowManualExeSelection")).toBool(config.allowManualExeSelection); + config.alwaysConfirmBeforeLaunch = root.value(QStringLiteral("alwaysConfirmBeforeLaunch")).toBool(config.alwaysConfirmBeforeLaunch); + + const QJsonArray appArray = root.value(QStringLiteral("apps")).toArray(); + for (const QJsonValue &value : appArray) + { + if (value.isObject()) + { + config.apps.append(appFromJson(value.toObject())); + } + } + return normalizedConfig(config); +} + +bool AppLaunchStore::save(const AppLaunchConfig &config, QString *errorMessage) const +{ + QDir directory(configDirectoryPath()); + if (!directory.exists() && !directory.mkpath(QStringLiteral("."))) + { + if (errorMessage != nullptr) + { + *errorMessage = QStringLiteral("无法创建应用启动配置目录。"); + } + Logger::warning(QStringLiteral("Unable to create launcher config directory.")); + return false; + } + + const AppLaunchConfig normalized = normalizedConfig(config); + QJsonObject root; + root.insert(QStringLiteral("enabled"), normalized.enabled); + root.insert(QStringLiteral("allowManualExeSelection"), normalized.allowManualExeSelection); + root.insert(QStringLiteral("alwaysConfirmBeforeLaunch"), normalized.alwaysConfirmBeforeLaunch); + QJsonArray appArray; + for (const RegisteredApp &app : normalized.apps) + { + appArray.append(jsonFromApp(app)); + } + root.insert(QStringLiteral("apps"), appArray); + + const QByteArray payload = QJsonDocument(root).toJson(QJsonDocument::Indented); + QSaveFile file(storePath()); + if (!file.open(QIODevice::WriteOnly)) + { + if (errorMessage != nullptr) + { + *errorMessage = QStringLiteral("无法写入应用启动配置文件。"); + } + Logger::warning(QStringLiteral("Unable to open launcher config for writing.")); + return false; + } + + const qint64 written = file.write(payload); + if (written != payload.size()) + { + file.cancelWriting(); + if (errorMessage != nullptr) + { + *errorMessage = QStringLiteral("写入应用启动配置文件不完整。"); + } + Logger::warning(QStringLiteral("Launcher config write was incomplete.")); + return false; + } + + if (!file.commit()) + { + if (errorMessage != nullptr) + { + *errorMessage = QStringLiteral("提交应用启动配置文件失败。"); + } + Logger::warning(QStringLiteral("Unable to commit launcher config.")); + return false; + } + + return true; +} + +QString AppLaunchStore::storePath() const +{ + return QDir(configDirectoryPath()).filePath(LauncherConfigFileName); +} + +QString AppLaunchStore::configDirectoryPath() const +{ + const QString path = QStandardPaths::writableLocation(QStandardPaths::AppConfigLocation); + if (!path.isEmpty()) + { + return path; + } + return QDir::currentPath(); +} + +void AppLaunchStore::backupBrokenConfig(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("launcher_config.broken.") + timestamp + QStringLiteral(".json")); + int suffix = 1; + while (QFile::exists(backupPath)) + { + backupPath = fileInfo.dir().filePath( + QStringLiteral("launcher_config.broken.") + + timestamp + + QStringLiteral("-") + + QString::number(suffix) + + QStringLiteral(".json")); + ++suffix; + } + + if (file.rename(backupPath)) + { + Logger::warning(QStringLiteral("Broken launcher config was backed up: %1").arg(backupPath)); + return; + } + + Logger::warning(QStringLiteral("Failed to back up broken launcher config: %1").arg(filePath)); +} diff --git a/src/launcher/AppLaunchStore.h b/src/launcher/AppLaunchStore.h new file mode 100644 index 0000000..ea491bd --- /dev/null +++ b/src/launcher/AppLaunchStore.h @@ -0,0 +1,17 @@ +#pragma once + +#include "AppLaunchTypes.h" + +#include + +class AppLaunchStore +{ +public: + AppLaunchConfig load(QString *errorMessage = nullptr) const; + bool save(const AppLaunchConfig &config, QString *errorMessage = nullptr) const; + QString storePath() const; + +private: + QString configDirectoryPath() const; + void backupBrokenConfig(const QString &filePath) const; +}; diff --git a/src/launcher/AppLaunchTypes.h b/src/launcher/AppLaunchTypes.h new file mode 100644 index 0000000..8ab957f --- /dev/null +++ b/src/launcher/AppLaunchTypes.h @@ -0,0 +1,45 @@ +#pragma once + +#include +#include +#include + +struct RegisteredApp +{ + QString id; + QString displayName; + QStringList aliases; + QString executablePath; + QString shortcutPath; + QString workingDirectory; + bool userDefined = true; +}; + +struct AppLaunchConfig +{ + bool enabled = true; + bool allowManualExeSelection = true; + bool alwaysConfirmBeforeLaunch = true; + QVector apps; +}; + +struct AppLaunchPlan +{ + bool success = false; + bool needsManualSelection = false; + QString requestedName; + QString displayName; + QString executablePath; + QString shortcutPath; + QString workingDirectory; + QString matchSource; + QString errorMessage; + bool canRemember = false; +}; + +struct AppLaunchResult +{ + bool success = false; + QString message; + QString errorMessage; +}; diff --git a/src/system/StartupManager.cpp b/src/system/StartupManager.cpp new file mode 100644 index 0000000..25ba2ea --- /dev/null +++ b/src/system/StartupManager.cpp @@ -0,0 +1,76 @@ +#include "StartupManager.h" + +#include +#include +#include +#include + +namespace +{ +const QString RunRegistryPath = QStringLiteral("HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Run"); +const QString StartupEntryName = QStringLiteral("QtDesktopPet"); + +QString quotedCommand(const QString &filePath) +{ + QString nativePath = QDir::toNativeSeparators(filePath.trimmed()); + nativePath.replace(QLatin1Char('"'), QStringLiteral("\\\"")); + return QStringLiteral("\"") + nativePath + QStringLiteral("\""); +} +} + +namespace StartupManager +{ +QString startupEntryName() +{ + return StartupEntryName; +} + +QString startupCommandForCurrentExecutable() +{ + const QString executablePath = QFileInfo(QCoreApplication::applicationFilePath()).absoluteFilePath(); + return executablePath.trimmed().isEmpty() ? QString() : quotedCommand(executablePath); +} + +bool isLaunchAtStartupEnabled() +{ + QSettings settings(RunRegistryPath, QSettings::NativeFormat); + return !settings.value(StartupEntryName).toString().trimmed().isEmpty(); +} + +bool setLaunchAtStartupEnabled(bool enabled, QString *errorMessage) +{ + QSettings settings(RunRegistryPath, QSettings::NativeFormat); + if (enabled) + { + const QString command = startupCommandForCurrentExecutable(); + if (command.isEmpty()) + { + if (errorMessage != nullptr) + { + *errorMessage = QStringLiteral("无法获取当前程序路径,开机自启动设置未保存。"); + } + return false; + } + + settings.setValue(StartupEntryName, command); + } + else + { + settings.remove(StartupEntryName); + } + + settings.sync(); + if (settings.status() != QSettings::NoError) + { + if (errorMessage != nullptr) + { + *errorMessage = enabled + ? QStringLiteral("写入开机自启动注册表失败。") + : QStringLiteral("移除开机自启动注册表失败。"); + } + return false; + } + + return true; +} +} diff --git a/src/system/StartupManager.h b/src/system/StartupManager.h new file mode 100644 index 0000000..6a107a7 --- /dev/null +++ b/src/system/StartupManager.h @@ -0,0 +1,11 @@ +#pragma once + +#include + +namespace StartupManager +{ +QString startupEntryName(); +QString startupCommandForCurrentExecutable(); +bool isLaunchAtStartupEnabled(); +bool setLaunchAtStartupEnabled(bool enabled, QString *errorMessage = nullptr); +} diff --git a/src/ui/ChatInputDialog.cpp b/src/ui/ChatInputDialog.cpp index 04545b6..e78df94 100644 --- a/src/ui/ChatInputDialog.cpp +++ b/src/ui/ChatInputDialog.cpp @@ -1,6 +1,7 @@ #include "ChatInputDialog.h" #include +#include #include #include #include @@ -16,9 +17,12 @@ ChatInputDialog::ChatInputDialog(int maxLength, QWidget *parent) : QDialog(parent) , m_textEdit(new QTextEdit(this)) , m_counterLabel(new QLabel(this)) + , m_webToggleCheckBox(new QCheckBox(QStringLiteral("联网"), this)) , m_sendButton(new QPushButton(QStringLiteral("↗"), this)) , m_maxLength(maxLength) { + m_webToggleCheckBox->setObjectName(QStringLiteral("WebToggle")); + setWindowFlags(Qt::Tool | Qt::FramelessWindowHint | Qt::WindowStaysOnTopHint); setAttribute(Qt::WA_TranslucentBackground); setModal(false); @@ -59,6 +63,27 @@ ChatInputDialog::ChatInputDialog(int maxLength, QWidget *parent) "QPushButton:disabled {" "color: rgba(32, 33, 36, 80);" "background: rgba(255, 255, 255, 170);" + "}" + "QCheckBox#WebToggle {" + "color: #202124;" + "font-size: 13px;" + "font-weight: 600;" + "spacing: 5px;" + "}" + "QCheckBox#WebToggle::indicator {" + "width: 16px;" + "height: 16px;" + "border: 1px solid rgba(32, 33, 36, 70);" + "border-radius: 8px;" + "background: rgba(255, 255, 255, 235);" + "}" + "QCheckBox#WebToggle::indicator:checked {" + "background: #175cd3;" + "border: 1px solid #175cd3;" + "}" + "QCheckBox#WebToggle::indicator:disabled {" + "background: rgba(255, 255, 255, 120);" + "border: 1px solid rgba(32, 33, 36, 28);" "}")); m_textEdit->setPlaceholderText(QStringLiteral("输入消息,Enter 发送,Shift+Enter 换行")); @@ -73,6 +98,8 @@ ChatInputDialog::ChatInputDialog(int maxLength, QWidget *parent) m_counterLabel->setStyleSheet(QStringLiteral("color: #b3261e; font-size: 12px;")); m_counterLabel->setVisible(false); + m_webToggleCheckBox->setToolTip(QStringLiteral("开启后使用当前 AI Provider 的原生联网能力。")); + connect(m_sendButton, &QPushButton::clicked, this, [this]() { submitIfValid(); }); @@ -89,6 +116,7 @@ ChatInputDialog::ChatInputDialog(int maxLength, QWidget *parent) panelLayout->setSpacing(8); panelLayout->addWidget(m_textEdit, 1); panelLayout->addWidget(m_counterLabel); + panelLayout->addWidget(m_webToggleCheckBox); panelLayout->addWidget(m_sendButton); auto *layout = new QHBoxLayout(this); @@ -104,6 +132,38 @@ QString ChatInputDialog::message() const return m_textEdit->toPlainText().trimmed(); } +bool ChatInputDialog::webEnabled() const +{ + return m_webToggleCheckBox != nullptr + && m_webToggleCheckBox->isEnabled() + && m_webToggleCheckBox->isChecked(); +} + +void ChatInputDialog::setWebEnabled(bool enabled) +{ + if (m_webToggleCheckBox) + { + m_webToggleCheckBox->setChecked(enabled); + } +} + +void ChatInputDialog::setWebToggleAvailable(bool available, const QString &toolTip) +{ + if (!m_webToggleCheckBox) + { + return; + } + + m_webToggleCheckBox->setEnabled(available); + if (!available) + { + m_webToggleCheckBox->setChecked(false); + } + m_webToggleCheckBox->setToolTip(toolTip.trimmed().isEmpty() + ? QStringLiteral("开启后使用当前 AI Provider 的原生联网能力。") + : toolTip); +} + void ChatInputDialog::setSubmitCallback(SubmitCallback callback) { m_submitCallback = std::move(callback); @@ -217,7 +277,7 @@ bool ChatInputDialog::submitIfValid() } const QString submittedMessage = message(); - const bool accepted = m_submitCallback ? m_submitCallback(submittedMessage) : true; + const bool accepted = m_submitCallback ? m_submitCallback(submittedMessage, webEnabled()) : true; if (accepted) { clearMessage(); diff --git a/src/ui/ChatInputDialog.h b/src/ui/ChatInputDialog.h index 919e7d2..b48bd8c 100644 --- a/src/ui/ChatInputDialog.h +++ b/src/ui/ChatInputDialog.h @@ -7,6 +7,7 @@ #include class QEvent; +class QCheckBox; class QLabel; class QMouseEvent; class QPushButton; @@ -15,11 +16,14 @@ class QTextEdit; class ChatInputDialog : public QDialog { public: - using SubmitCallback = std::function; + using SubmitCallback = std::function; explicit ChatInputDialog(int maxLength, QWidget *parent = nullptr); QString message() const; + bool webEnabled() const; + void setWebEnabled(bool enabled); + void setWebToggleAvailable(bool available, const QString &toolTip = QString()); void setSubmitCallback(SubmitCallback callback); void showAt(const QPoint &anchorPosition); @@ -41,6 +45,7 @@ private: QTextEdit *m_textEdit = nullptr; QLabel *m_counterLabel = nullptr; + QCheckBox *m_webToggleCheckBox = nullptr; QPushButton *m_sendButton = nullptr; SubmitCallback m_submitCallback; QPoint m_dragStartPosition; diff --git a/src/ui/PetWindow.cpp b/src/ui/PetWindow.cpp index a7d4098..19751f9 100644 --- a/src/ui/PetWindow.cpp +++ b/src/ui/PetWindow.cpp @@ -7,12 +7,21 @@ #include "../character/CharacterPackageLoader.h" #include "../character/CharacterPackageRepository.h" #include "../config/ConfigManager.h" +#include "../fileops/FileOperationManager.h" +#include "../launcher/AppLaunchManager.h" +#include "../launcher/AppLaunchStore.h" #include "../notification/NotificationDispatcher.h" #include "../reminder/ReminderCommandHandler.h" #include "../reminder/ReminderManager.h" #include "../reminder/ReminderSoundPlayer.h" #include "../reminder/ReminderSoundRepository.h" +#include "../system/StartupManager.h" #include "../util/Logger.h" +#include "../web/WebCapabilityDetector.h" +#include "../web/WebChatManager.h" +#include "../web/WebStore.h" +#include "../weather/WeatherManager.h" +#include "../weather/WeatherStore.h" #include "ChatBubble.h" #include "ChatHistoryPanel.h" #include "ChatInputDialog.h" @@ -24,16 +33,25 @@ #include #include #include +#include +#include +#include +#include #include #include #include +#include +#include #include +#include #include +#include #include #include #include #include #include +#include #include #include #include @@ -81,6 +99,7 @@ QString megabytesText(qint64 bytes) AppConfig normalizedAppConfig(AppConfig config) { + config.launchAtStartup = StartupManager::isLaunchAtStartupEnabled(); config.scale = qBound(0.5, config.scale, 2.0); if (config.performanceMode != QStringLiteral("standard") && config.performanceMode != QStringLiteral("low-power")) @@ -157,6 +176,22 @@ QString userVisibleErrorMessage(const ChatResponse &response) return message; } + +QString webCitationListText(const QVector &citations) +{ + QStringList lines; + const int count = qMin(citations.size(), 10); + for (int index = 0; index < count; ++index) + { + const WebCitation &citation = citations.at(index); + lines.append(QStringLiteral("[%1] %2\n%3") + .arg(index + 1) + .arg(citation.title.trimmed().isEmpty() ? QStringLiteral("来源") : citation.title.trimmed()) + .arg(citation.url)); + } + return lines.join(QStringLiteral("\n")); +} + } PetWindow::PetWindow(QWidget *parent) @@ -166,9 +201,13 @@ PetWindow::PetWindow(QWidget *parent) , m_chatInputDialog(std::make_unique(MaxUserMessageLength, this)) , m_conversationManager(std::make_unique()) , m_conversationStore(std::make_unique(ConfigManager().conversationHistoryPath())) + , m_fileOperationManager(std::make_unique()) + , m_appLaunchManager(std::make_unique()) , m_notificationDispatcher(std::make_unique()) , m_reminderManager(std::make_unique()) , m_reminderSoundPlayer(std::make_unique()) + , m_webChatManager(std::make_unique()) + , m_weatherManager(std::make_unique()) , m_petView(new PetView(this)) , m_dragging(false) , m_alwaysOnTop(true) @@ -212,8 +251,9 @@ PetWindow::PetWindow(QWidget *parent) }); QPointer window(this); - m_chatInputDialog->setSubmitCallback([window](const QString &message) { - return !window.isNull() && window->submitChatMessage(message); + refreshChatInputWebToggle(); + m_chatInputDialog->setSubmitCallback([window](const QString &message, bool webEnabled) { + return !window.isNull() && window->submitChatMessage(message, webEnabled); }); m_reminderManager->setTriggeredCallback([window](const ReminderItem &item) { @@ -299,6 +339,7 @@ AppConfig PetWindow::currentAppConfig() const config.windowPosition = pos(); config.hasWindowPosition = true; config.alwaysOnTop = m_alwaysOnTop; + config.launchAtStartup = StartupManager::isLaunchAtStartupEnabled(); return config; } @@ -342,9 +383,35 @@ void PetWindow::showBubbleMessage(const QString &message) void PetWindow::openSettingsDialog() { ConfigManager configManager; + WeatherStore weatherStore; + WebStore webStore; + AppLaunchStore appLaunchStore; + QString weatherConfigError; + const WeatherConfig weatherConfig = weatherStore.load(&weatherConfigError); + if (!weatherConfigError.isEmpty()) + { + Logger::warning(QStringLiteral("Weather config load warning: ") + weatherConfigError); + } + QString webConfigError; + const WebConfig webConfig = webStore.load(&webConfigError); + if (!webConfigError.isEmpty()) + { + Logger::warning(QStringLiteral("Web config load warning: ") + webConfigError); + } + QString appLaunchConfigError; + const AppLaunchConfig appLaunchConfig = appLaunchStore.load(&appLaunchConfigError); + if (!appLaunchConfigError.isEmpty()) + { + Logger::warning(QStringLiteral("Launcher config load warning: ") + appLaunchConfigError); + } + SettingsDialog dialog( configManager.loadAIConfigStore(), currentAppConfig(), + weatherConfig, + webConfig, + appLaunchConfig, + m_conversationManager ? m_conversationManager->history() : QVector(), m_reminderManager ? m_reminderManager->allReminders() : QVector(), [this]() { return isManualStateSwitchLocked(); @@ -415,11 +482,42 @@ void PetWindow::openSettingsDialog() Logger::warning(QStringLiteral("Failed to save AI config from settings dialog.")); } - applyAppConfig(dialog.appConfig()); + AppConfig acceptedAppConfig = dialog.appConfig(); + QString startupError; + if (!StartupManager::setLaunchAtStartupEnabled(acceptedAppConfig.launchAtStartup, &startupError)) + { + Logger::warning(QStringLiteral("Failed to update startup setting: ") + startupError); + acceptedAppConfig.launchAtStartup = StartupManager::isLaunchAtStartupEnabled(); + QMessageBox::warning( + this, + QStringLiteral("开机自启动"), + startupError.isEmpty() ? QStringLiteral("开机自启动设置保存失败。") : startupError); + } + + applyAppConfig(acceptedAppConfig); if (!configManager.saveAppConfig(currentAppConfig())) { Logger::warning(QStringLiteral("Failed to save app config from settings dialog.")); } + + QString saveWeatherConfigError; + if (!weatherStore.save(dialog.weatherConfig(), &saveWeatherConfigError)) + { + Logger::warning(QStringLiteral("Failed to save weather config from settings dialog: ") + saveWeatherConfigError); + } + + QString saveWebConfigError; + if (!webStore.save(dialog.webConfig(), &saveWebConfigError)) + { + Logger::warning(QStringLiteral("Failed to save web config from settings dialog: ") + saveWebConfigError); + } + refreshChatInputWebToggle(); + + QString saveLauncherConfigError; + if (!appLaunchStore.save(dialog.appLaunchConfig(), &saveLauncherConfigError)) + { + Logger::warning(QStringLiteral("Failed to save launcher config from settings dialog: ") + saveLauncherConfigError); + } } void PetWindow::activateFromExternalInstance() @@ -478,7 +576,7 @@ void PetWindow::contextMenuEvent(QContextMenuEvent *event) QAction *chatAction = menu.addAction(QStringLiteral("聊天")); chatAction->setEnabled(!aiRequestRunning); QAction *showConversationAction = menu.addAction(QStringLiteral("显示对话")); - QAction *cancelAIAction = menu.addAction(QStringLiteral("取消 AI 请求")); + QAction *cancelAIAction = menu.addAction(QStringLiteral("取消当前请求")); cancelAIAction->setEnabled(aiRequestRunning); QAction *clearConversationAction = menu.addAction(QStringLiteral("清空对话")); clearConversationAction->setEnabled(m_conversationManager && m_conversationManager->hasHistory()); @@ -540,10 +638,11 @@ void PetWindow::startChat() return; } + refreshChatInputWebToggle(); m_chatInputDialog->showAt(chatInputAnchorPosition()); } -bool PetWindow::submitChatMessage(const QString &message) +bool PetWindow::submitChatMessage(const QString &message, bool webEnabled) { const QString normalizedMessage = message.trimmed(); if (normalizedMessage.isEmpty()) @@ -565,6 +664,21 @@ bool PetWindow::submitChatMessage(const QString &message) return handleReminderChatMessage(result.message); } + if (result.action == CommandDispatchAction::Weather) + { + return handleWeatherChatMessage(result.message); + } + + if (result.action == CommandDispatchAction::FileOperation) + { + return handleFileOperationChatMessage(result.message); + } + + if (result.action == CommandDispatchAction::LaunchApp) + { + return handleLaunchAppChatMessage(result.message); + } + if (result.action == CommandDispatchAction::UnsupportedTool) { playState(QStringLiteral("talk"), false); @@ -572,7 +686,8 @@ bool PetWindow::submitChatMessage(const QString &message) return true; } - return submitAiChatMessage(result.message); + saveWebTogglePreference(webEnabled); + return webEnabled ? submitWebChatMessage(result.message) : submitAiChatMessage(result.message); } bool PetWindow::handleReminderChatMessage(const QString &message) @@ -592,6 +707,562 @@ bool PetWindow::handleReminderChatMessage(const QString &message) return result.success; } +bool PetWindow::handleWeatherChatMessage(const QString &message) +{ + if (!m_weatherManager) + { + playState(QStringLiteral("error"), false); + showBubbleMessage(QStringLiteral("天气功能初始化失败。")); + return false; + } + + if (hasActiveAIRequest() || m_streamingChatActive) + { + playState(QStringLiteral("talk"), false); + showBubbleMessage(QStringLiteral("当前请求正在进行,请稍后再查天气。")); + return false; + } + + if (m_weatherManager->isBusy()) + { + playState(QStringLiteral("talk"), false); + showBubbleMessage(QStringLiteral("天气查询正在进行,请稍后。")); + return false; + } + + WeatherStore weatherStore; + QString weatherConfigError; + const WeatherConfig weatherConfig = weatherStore.load(&weatherConfigError); + if (!weatherConfigError.isEmpty()) + { + Logger::warning(QStringLiteral("Weather config load warning: ") + weatherConfigError); + } + + stopAnimationPrewarm(); + playState(QStringLiteral("think"), false); + hideReminderActions(); + showBubbleMessage(QStringLiteral("正在查询天气...")); + + QPointer window(this); + m_weatherManager->queryWeather(message, weatherConfig, [window](const WeatherQueryResult &result) { + if (window.isNull()) + { + return; + } + + if (result.success) + { + window->playState(QStringLiteral("talk"), false); + window->showBubbleMessage(result.message); + window->m_behaviorReturnTimer.start(ChatFinishedReturnDelayMs); + return; + } + + window->playState(QStringLiteral("error"), false); + window->showBubbleMessage(result.errorMessage.isEmpty() ? QStringLiteral("天气查询失败。") : result.errorMessage); + window->m_behaviorReturnTimer.start(ChatFinishedReturnDelayMs); + }); + return true; +} + +bool PetWindow::handleFileOperationChatMessage(const QString &message) +{ + if (!m_fileOperationManager) + { + playState(QStringLiteral("error"), false); + showBubbleMessage(QStringLiteral("文件操作功能初始化失败。")); + return false; + } + + const QString text = message.trimmed(); + const auto contains = [&text](const QString &keyword) { + return text.contains(keyword, Qt::CaseInsensitive); + }; + if (contains(QStringLiteral("删除")) || contains(QStringLiteral("移动")) || contains(QStringLiteral("覆盖")) + || contains(QStringLiteral("执行")) || contains(QStringLiteral("运行")) || contains(QStringLiteral("脚本")) || contains(QStringLiteral("命令"))) + { + playState(QStringLiteral("error"), false); + showBubbleMessage(QStringLiteral("文件操作 v1 不支持删除、覆盖、移动、执行脚本或运行命令。")); + return false; + } + + if (contains(QStringLiteral("截图")) || contains(QStringLiteral("保存到"))) + { + playState(QStringLiteral("talk"), false); + showBubbleMessage(QStringLiteral("文件操作 v1 暂不支持截图或把当前内容保存到指定位置。")); + return false; + } + + if (contains(QStringLiteral("打包")) || contains(QStringLiteral("压缩")) || contains(QStringLiteral("zip"))) + { + playState(QStringLiteral("talk"), false); + showBubbleMessage(QStringLiteral("zip 打包需要额外稳定的压缩实现,本版暂不启用;可以先使用复制或备份。")); + return false; + } + + const auto confirmPlan = [this](const FileOperationPlan &plan) { + QString messageText = plan.description; + if (!plan.warnings.isEmpty()) + { + messageText += QStringLiteral("\n\n注意:\n") + plan.warnings.join(QLatin1Char('\n')); + } + messageText += QStringLiteral("\n\n请确认是否执行。"); + return QMessageBox::warning( + this, + plan.title, + messageText, + QMessageBox::Yes | QMessageBox::Cancel, + QMessageBox::Cancel) == QMessageBox::Yes; + }; + + FileOperationResult operationResult; + if (contains(QStringLiteral("列出")) || contains(QStringLiteral("目录")) || contains(QStringLiteral("文件夹"))) + { + const QString directoryPath = QFileDialog::getExistingDirectory( + this, + QStringLiteral("选择要列出的文件夹"), + QString(), + QFileDialog::ShowDirsOnly | QFileDialog::DontResolveSymlinks); + if (directoryPath.isEmpty()) + { + return false; + } + + operationResult = m_fileOperationManager->executeListDirectory(m_fileOperationManager->listDirectoryPlan(directoryPath)); + } + else if (contains(QStringLiteral("复制"))) + { + const QString sourceFilePath = QFileDialog::getOpenFileName(this, QStringLiteral("选择要复制的文件")); + if (sourceFilePath.isEmpty()) + { + return false; + } + const QString targetDirectoryPath = QFileDialog::getExistingDirectory( + this, + QStringLiteral("选择复制到的文件夹"), + QString(), + QFileDialog::ShowDirsOnly | QFileDialog::DontResolveSymlinks); + if (targetDirectoryPath.isEmpty()) + { + return false; + } + + const FileOperationPlan plan = m_fileOperationManager->copyFilePlan(sourceFilePath, targetDirectoryPath); + if (!confirmPlan(plan)) + { + return false; + } + operationResult = m_fileOperationManager->executeCopyFile(plan); + } + else if (contains(QStringLiteral("备份"))) + { + const QString sourceFilePath = QFileDialog::getOpenFileName(this, QStringLiteral("选择要备份的文件")); + if (sourceFilePath.isEmpty()) + { + return false; + } + + const FileOperationPlan plan = m_fileOperationManager->backupFilePlan(sourceFilePath); + if (!confirmPlan(plan)) + { + return false; + } + operationResult = m_fileOperationManager->executeBackupFile(plan); + } + else if (contains(QStringLiteral("重命名"))) + { + const QString sourceFilePath = QFileDialog::getOpenFileName(this, QStringLiteral("选择要重命名的文件")); + if (sourceFilePath.isEmpty()) + { + return false; + } + + bool ok = false; + const QString newFileName = QInputDialog::getText( + this, + QStringLiteral("重命名文件"), + QStringLiteral("新文件名"), + QLineEdit::Normal, + QFileInfo(sourceFilePath).fileName(), + &ok).trimmed(); + if (!ok || newFileName.isEmpty()) + { + return false; + } + + const FileOperationPlan plan = m_fileOperationManager->renameFilePlan(sourceFilePath, newFileName); + if (!confirmPlan(plan)) + { + return false; + } + operationResult = m_fileOperationManager->executeRenameFile(plan); + } + else + { + const QString sourceFilePath = QFileDialog::getOpenFileName( + this, + QStringLiteral("选择要读取的文本文件"), + QString(), + QStringLiteral("Text Files (*.txt *.md *.markdown *.log *.json *.csv *.ini *.xml *.yaml *.yml);;All Files (*)")); + if (sourceFilePath.isEmpty()) + { + return false; + } + + operationResult = m_fileOperationManager->executeReadTextFile(m_fileOperationManager->readTextFilePlan(sourceFilePath)); + } + + if (!operationResult.success) + { + playState(QStringLiteral("error"), false); + showBubbleMessage(operationResult.errorMessage.isEmpty() ? QStringLiteral("文件操作失败。") : operationResult.errorMessage); + return false; + } + + playState(QStringLiteral("talk"), false); + const QString output = operationResult.outputText.trimmed(); + showBubbleMessage(output.isEmpty() ? operationResult.message : output); + return true; +} + +bool PetWindow::handleLaunchAppChatMessage(const QString &message) +{ + if (!m_appLaunchManager) + { + playState(QStringLiteral("error"), false); + showBubbleMessage(QStringLiteral("应用启动功能初始化失败。")); + return false; + } + + AppLaunchStore store; + QString loadError; + AppLaunchConfig config = store.load(&loadError); + if (!loadError.isEmpty()) + { + Logger::warning(QStringLiteral("Launcher config load warning: ") + loadError); + } + + AppLaunchPlan plan = m_appLaunchManager->resolveLaunchPlan(message, config); + if (plan.needsManualSelection) + { + const QString executablePath = QFileDialog::getOpenFileName( + this, + QStringLiteral("选择要启动的应用"), + QString(), + QStringLiteral("Applications (*.exe)")); + if (executablePath.isEmpty()) + { + return false; + } + + plan = m_appLaunchManager->manualSelectionPlan(message, executablePath); + } + + if (!plan.success) + { + playState(QStringLiteral("error"), false); + showBubbleMessage(plan.errorMessage.isEmpty() ? QStringLiteral("没有找到可启动的应用。") : plan.errorMessage); + return false; + } + + QString confirmText = QStringLiteral("应用:%1\n来源:%2") + .arg(plan.displayName.isEmpty() ? plan.requestedName : plan.displayName, + plan.matchSource.isEmpty() ? QStringLiteral("未知") : plan.matchSource); + if (!plan.executablePath.trimmed().isEmpty()) + { + confirmText += QStringLiteral("\n路径:") + plan.executablePath; + } + if (!plan.shortcutPath.trimmed().isEmpty()) + { + confirmText += QStringLiteral("\n快捷方式:") + plan.shortcutPath; + } + if (!plan.workingDirectory.trimmed().isEmpty()) + { + confirmText += QStringLiteral("\n工作目录:") + plan.workingDirectory; + } + QDialog confirmDialog(this); + confirmDialog.setWindowTitle(QStringLiteral("启动应用")); + confirmDialog.setModal(true); + + auto *confirmLayout = new QVBoxLayout(&confirmDialog); + confirmLayout->setContentsMargins(18, 18, 18, 14); + confirmLayout->setSpacing(12); + + auto *questionLabel = new QLabel(QStringLiteral("确认启动该应用?"), &confirmDialog); + QFont questionFont = questionLabel->font(); + questionFont.setBold(true); + questionLabel->setFont(questionFont); + confirmLayout->addWidget(questionLabel); + + auto *detailLabel = new QLabel(confirmText, &confirmDialog); + detailLabel->setWordWrap(true); + detailLabel->setTextInteractionFlags(Qt::TextSelectableByMouse); + detailLabel->setMinimumWidth(520); + confirmLayout->addWidget(detailLabel); + + auto *rememberCheckBox = new QCheckBox(QStringLiteral("记住为此名称,下次直接匹配"), &confirmDialog); + rememberCheckBox->setVisible(plan.canRemember); + confirmLayout->addWidget(rememberCheckBox); + + auto *buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, &confirmDialog); + if (QPushButton *okButton = buttonBox->button(QDialogButtonBox::Ok)) + { + okButton->setText(QStringLiteral("启动")); + } + if (QPushButton *cancelButton = buttonBox->button(QDialogButtonBox::Cancel)) + { + cancelButton->setText(QStringLiteral("取消")); + } + QObject::connect(buttonBox, &QDialogButtonBox::accepted, &confirmDialog, &QDialog::accept); + QObject::connect(buttonBox, &QDialogButtonBox::rejected, &confirmDialog, &QDialog::reject); + confirmLayout->addWidget(buttonBox); + + if (confirmDialog.exec() != QDialog::Accepted) + { + return false; + } + + const AppLaunchResult result = m_appLaunchManager->executeLaunchPlan(plan); + if (!result.success) + { + playState(QStringLiteral("error"), false); + showBubbleMessage(result.errorMessage.isEmpty() ? QStringLiteral("启动应用失败。") : result.errorMessage); + return false; + } + + QString bubbleText = result.message; + if (plan.canRemember && rememberCheckBox->isChecked()) + { + RegisteredApp app = m_appLaunchManager->registeredAppFromPlan( + plan, + {plan.requestedName, plan.displayName}); + + bool replaced = false; + for (RegisteredApp &existingApp : config.apps) + { + if (existingApp.id == app.id) + { + existingApp = app; + replaced = true; + break; + } + } + if (!replaced) + { + config.apps.append(app); + } + + QString saveError; + if (!store.save(config, &saveError)) + { + Logger::warning(QStringLiteral("Failed to save launcher config after manual app selection: ") + saveError); + bubbleText += QStringLiteral("\n但保存应用别名失败。"); + } + } + + playState(QStringLiteral("talk"), false); + showBubbleMessage(bubbleText.isEmpty() ? QStringLiteral("应用已启动。") : bubbleText); + return true; +} + + +bool PetWindow::submitWebChatMessage(const QString &message) +{ + if (!m_webChatManager) + { + playState(QStringLiteral("error"), false); + showBubbleMessage(QStringLiteral("联网模式初始化失败。")); + return false; + } + + if (hasActiveWebRequest()) + { + playState(QStringLiteral("talk"), false); + showBubbleMessage(QStringLiteral("联网请求正在进行,请稍后。")); + return false; + } + + if ((m_conversationManager && m_conversationManager->isBusy()) || m_streamingChatActive) + { + playState(QStringLiteral("talk"), false); + showBubbleMessage(QStringLiteral("AI 回复正在进行,请稍后再使用联网模式。")); + return false; + } + + ConfigManager configManager; + AIConfig aiConfig = configManager.loadAIConfig(); + + WebStore webStore; + QString webConfigError; + WebConfig webConfig = webStore.load(&webConfigError); + if (!webConfigError.isEmpty()) + { + Logger::warning(QStringLiteral("Web config load warning: ") + webConfigError); + } + + const WebCapability capability = WebCapabilityDetector::detect(aiConfig, webConfig); + if (!capability.supported) + { + playState(QStringLiteral("talk"), false); + showBubbleMessage(capability.userMessage); + refreshChatInputWebToggle(); + return false; + } + + QString runtimeError; + AIConfig runtimeConfig = aiConfig; + if (!AIProviderFactory::prepareRuntimeConfig(runtimeConfig, &runtimeError)) + { + playState(QStringLiteral("error"), false); + showBubbleMessage(runtimeError); + return false; + } + + const QString chatMessage = message.trimmed(); + if (chatMessage.isEmpty()) + { + return false; + } + + if (!m_conversationManager) + { + playState(QStringLiteral("error"), false); + showBubbleMessage(QStringLiteral("AI 对话功能初始化失败。")); + return false; + } + + m_conversationManager->setConversationMetadata(runtimeConfig.provider, runtimeConfig.model); + ChatRequest request = m_conversationManager->buildRequestForUserMessage(chatMessage); + if (request.messages.isEmpty()) + { + playState(QStringLiteral("error"), false); + showBubbleMessage(QStringLiteral("联网请求内容为空。")); + return false; + } + + stopAnimationPrewarm(); + playState(QStringLiteral("think"), false); + hideReminderActions(); + showBubbleMessage(QStringLiteral("正在联网思考...")); + + QPointer window(this); + WebChatRequest webRequest; + webRequest.chatRequest = request; + webRequest.aiConfig = runtimeConfig; + webRequest.webConfig = webConfig; + m_webChatManager->sendWebChat(webRequest, [window, chatMessage](const WebChatResponse &response) { + if (window.isNull()) + { + return; + } + + if (!response.success) + { + window->playState(QStringLiteral("error"), false); + window->showBubbleMessage(response.errorMessage.isEmpty() + ? QStringLiteral("联网请求失败。") + : response.errorMessage); + window->m_behaviorReturnTimer.start(ChatFinishedReturnDelayMs); + return; + } + + window->playState(QStringLiteral("talk"), false); + const QString displayText = window->formatWebChatResponseForDisplay(response); + if (window->m_conversationManager) + { + window->m_conversationManager->appendExternalExchange(chatMessage, displayText); + } + window->saveConversationHistoryIfNeeded(); + window->refreshChatHistoryPanel(); + window->showBubbleMessage(displayText); + window->m_behaviorReturnTimer.start(ChatFinishedReturnDelayMs); + }); + return true; +} + +bool PetWindow::hasActiveWebRequest() const +{ + return m_webChatManager && m_webChatManager->isBusy(); +} + +QString PetWindow::formatWebChatResponseForDisplay(const WebChatResponse &response) const +{ + QString message = response.content.trimmed(); + if (message.isEmpty()) + { + message = QStringLiteral("联网请求已完成,但没有返回内容。"); + } + + WebStore webStore; + const WebConfig webConfig = webStore.load(); + if (!webConfig.showCitations) + { + return message; + } + + if (!response.citations.isEmpty()) + { + message += QStringLiteral("\n\n来源:\n") + webCitationListText(response.citations); + } + else if (!response.usedWeb) + { + message += QStringLiteral("\n\n(模型未使用联网来源)"); + } + else + { + message += QStringLiteral("\n\n(模型使用了联网能力,但未返回可展示来源)"); + } + + return message; +} + +void PetWindow::refreshChatInputWebToggle() +{ + if (!m_chatInputDialog) + { + return; + } + + WebStore webStore; + QString errorMessage; + const WebConfig webConfig = webStore.load(&errorMessage); + if (!errorMessage.isEmpty()) + { + Logger::warning(QStringLiteral("Web config load warning: ") + errorMessage); + } + + ConfigManager configManager; + const AIConfig aiConfig = configManager.loadAIConfig(); + const WebCapability capability = WebCapabilityDetector::detect(aiConfig, webConfig); + const bool available = webConfig.enabled; + m_chatInputDialog->setWebToggleAvailable(available, capability.userMessage); + const bool checked = webConfig.rememberLastToggle ? webConfig.lastToggleOn : webConfig.defaultToggleOn; + m_chatInputDialog->setWebEnabled(available && checked); +} + +void PetWindow::saveWebTogglePreference(bool webEnabled) +{ + WebStore webStore; + QString loadError; + WebConfig webConfig = webStore.load(&loadError); + if (!loadError.isEmpty()) + { + Logger::warning(QStringLiteral("Web config load warning: ") + loadError); + } + + if (!webConfig.rememberLastToggle) + { + return; + } + + webConfig.lastToggleOn = webEnabled; + QString saveError; + if (!webStore.save(webConfig, &saveError)) + { + Logger::warning(QStringLiteral("Failed to save web toggle preference: ") + saveError); + } +} + void PetWindow::handleTriggeredReminder(const ReminderItem &item) { playReminderSound(); @@ -831,6 +1502,12 @@ void PetWindow::snoozeTriggeredReminder(const ReminderItem &item) bool PetWindow::submitAiChatMessage(const QString &message) { + if (hasActiveWebRequest()) + { + showBubbleMessage(QStringLiteral("当前联网请求正在进行,请稍后。")); + return false; + } + if (!m_conversationManager || m_conversationManager->isBusy()) { showBubbleMessage(QStringLiteral("AI 回复正在进行。")); @@ -861,6 +1538,7 @@ bool PetWindow::submitAiChatMessage(const QString &message) return false; } + m_conversationManager->setConversationMetadata(config.provider, config.model); if (!m_conversationManager->setProvider(std::move(provider))) { showBubbleMessage(QStringLiteral("AI 回复正在进行。")); @@ -935,9 +1613,13 @@ void PetWindow::clearConversation() Logger::warning(QStringLiteral("Failed to clear persisted conversation history.")); } cancelStreamingChat(); + if (m_webChatManager && m_webChatManager->isBusy()) + { + m_webChatManager->cancel(); + } refreshChatHistoryPanel(); showBubbleMessage(hadActiveRequest - ? QStringLiteral("已取消 AI 请求,并清空对话。") + ? QStringLiteral("已取消当前请求,并清空对话。") : QStringLiteral("对话已清空。")); playState(QStringLiteral("idle"), false); } @@ -953,12 +1635,20 @@ void PetWindow::cancelActiveAIRequest() return; } - showBubbleMessage(QStringLiteral("没有正在进行的 AI 请求。")); + if (m_webChatManager && m_webChatManager->isBusy()) + { + m_webChatManager->cancel(); + showBubbleMessage(QStringLiteral("联网请求已取消。")); + playState(QStringLiteral("idle"), false); + return; + } + + showBubbleMessage(QStringLiteral("没有正在进行的 AI 或联网请求。")); } bool PetWindow::hasActiveAIRequest() const { - return m_conversationManager && m_conversationManager->isBusy(); + return (m_conversationManager && m_conversationManager->isBusy()) || hasActiveWebRequest(); } bool PetWindow::isManualStateSwitchLocked() const diff --git a/src/ui/PetWindow.h b/src/ui/PetWindow.h index 24186e8..e2a8e8b 100644 --- a/src/ui/PetWindow.h +++ b/src/ui/PetWindow.h @@ -5,6 +5,7 @@ #include "../character/FrameAnimator.h" #include "../config/AppConfig.h" #include "../reminder/ReminderTypes.h" +#include "../web/WebChatTypes.h" #include "../state/PetStateMachine.h" #include @@ -29,10 +30,14 @@ class ChatHistoryPanel; class ChatInputDialog; class ConversationManager; class ConversationStore; +class FileOperationManager; +class AppLaunchManager; class NotificationDispatcher; class PetView; class ReminderManager; class ReminderSoundPlayer; +class WebChatManager; +class WeatherManager; class PetWindow : public QWidget { @@ -66,9 +71,17 @@ private: void buildAnimationClips(); void addStateTestActions(QMenu *menu); void startChat(); - bool submitChatMessage(const QString &message); + bool submitChatMessage(const QString &message, bool webEnabled); bool submitAiChatMessage(const QString &message); + bool submitWebChatMessage(const QString &message); bool handleReminderChatMessage(const QString &message); + bool handleWeatherChatMessage(const QString &message); + bool handleFileOperationChatMessage(const QString &message); + bool handleLaunchAppChatMessage(const QString &message); + bool hasActiveWebRequest() const; + QString formatWebChatResponseForDisplay(const WebChatResponse &response) const; + void refreshChatInputWebToggle(); + void saveWebTogglePreference(bool webEnabled); void handleTriggeredReminder(const ReminderItem &item); void playReminderSound(); void showReminderNotification(const ReminderItem &item); @@ -129,9 +142,13 @@ private: std::unique_ptr m_chatInputDialog; std::unique_ptr m_conversationManager; std::unique_ptr m_conversationStore; + std::unique_ptr m_fileOperationManager; + std::unique_ptr m_appLaunchManager; std::unique_ptr m_notificationDispatcher; std::unique_ptr m_reminderManager; std::unique_ptr m_reminderSoundPlayer; + std::unique_ptr m_webChatManager; + std::unique_ptr m_weatherManager; std::unique_ptr m_reminderActionPanel; PetView *m_petView; QTimer m_idleBehaviorTimer; diff --git a/src/ui/SettingsDialog.cpp b/src/ui/SettingsDialog.cpp index f2c127c..041c3fe 100644 --- a/src/ui/SettingsDialog.cpp +++ b/src/ui/SettingsDialog.cpp @@ -4,18 +4,24 @@ #include "../ai/LLMTypes.h" #include "../character/CharacterPackageRepository.h" #include "../config/SecretStore.h" +#include "../launcher/AppLaunchManager.h" #include "../reminder/ReminderSoundRepository.h" +#include "../web/WebCapabilityDetector.h" +#include "../web/WebChatManager.h" +#include "../weather/WeatherManager.h" #include #include #include #include +#include #include #include #include #include #include #include +#include #include #include #include @@ -26,12 +32,21 @@ #include #include #include +#include +#include +#include +#include +#include +#include +#include #include #include #include #include #include +#include #include +#include #include #include #include @@ -146,6 +161,214 @@ const ReminderItem *findReminderById(const QVector &reminders, con return nullptr; } +QString weatherLocationDisplayName(const WeatherLocation &location) +{ + QStringList parts; + if (!location.cityName.trimmed().isEmpty()) + { + parts.append(location.cityName.trimmed()); + } + if (!location.adminName.trimmed().isEmpty() && location.adminName.trimmed() != location.cityName.trimmed()) + { + parts.append(location.adminName.trimmed()); + } + if (!location.countryName.trimmed().isEmpty() && location.countryName.trimmed() != location.adminName.trimmed()) + { + parts.append(location.countryName.trimmed()); + } + return parts.isEmpty() ? QStringLiteral("未知地点") : parts.join(QStringLiteral(",")); +} + +QString weatherCandidateDisplayName(const WeatherLocationCandidate &candidate) +{ + QStringList parts; + if (!candidate.cityName.trimmed().isEmpty()) + { + parts.append(candidate.cityName.trimmed()); + } + if (!candidate.adminName.trimmed().isEmpty() && candidate.adminName.trimmed() != candidate.cityName.trimmed()) + { + parts.append(candidate.adminName.trimmed()); + } + if (!candidate.countryName.trimmed().isEmpty() && candidate.countryName.trimmed() != candidate.adminName.trimmed()) + { + parts.append(candidate.countryName.trimmed()); + } + return parts.isEmpty() ? QStringLiteral("未知地点") : parts.join(QStringLiteral(",")); +} + +QString weatherLocationTestDisplayText(const LocationTestResult &result) +{ + QString text = QStringLiteral("匹配结果:%1。").arg(weatherLocationDisplayName(result.selectedLocation)); + if (!result.hasAmbiguity || result.candidates.size() <= 1) + { + return text; + } + + QStringList otherCandidates; + const int maxOtherCandidateCount = qMin(3, result.candidates.size() - 1); + for (int index = 1; index <= maxOtherCandidateCount; ++index) + { + otherCandidates.append(weatherCandidateDisplayName(result.candidates.at(index))); + } + + if (!otherCandidates.isEmpty()) + { + text += QStringLiteral(" 可能存在同名城市,其他候选:%1。").arg(otherCandidates.join(QStringLiteral(";"))); + } + return text; +} + +QString webCapabilityDisplayText(const WebCapability &capability) +{ + const QString state = capability.supported ? QStringLiteral("可用") : QStringLiteral("不可用"); + return QStringLiteral("当前联网能力:%1。%2").arg(state, capability.userMessage); +} + +QString launchAppDisplayText(const RegisteredApp &app) +{ + QStringList lines; + lines.append(app.displayName); + if (!app.aliases.isEmpty()) + { + lines.append(QStringLiteral("别名:%1").arg(app.aliases.join(QStringLiteral(" / ")))); + } + if (!app.executablePath.trimmed().isEmpty()) + { + lines.append(QStringLiteral("路径:%1").arg(app.executablePath)); + } + else if (!app.shortcutPath.trimmed().isEmpty()) + { + lines.append(QStringLiteral("快捷方式:%1").arg(app.shortcutPath)); + } + return lines.join(QLatin1Char('\n')); +} + +QStringList aliasesFromUserText(const QString &text) +{ + QString normalized = text; + normalized.replace(QStringLiteral(","), QStringLiteral(",")); + normalized.replace(QStringLiteral(";"), QStringLiteral(",")); + normalized.replace(QStringLiteral(";"), QStringLiteral(",")); + + QStringList aliases; + for (const QString &part : normalized.split(QLatin1Char(','), Qt::SkipEmptyParts)) + { + const QString alias = part.trimmed(); + if (!alias.isEmpty() && !aliases.contains(alias, Qt::CaseInsensitive)) + { + aliases.append(alias); + } + } + return aliases; +} + +QString chatRoleDisplayText(const QString &role) +{ + if (role == QStringLiteral("user")) + { + return QStringLiteral("用户"); + } + if (role == QStringLiteral("assistant")) + { + return QStringLiteral("桌宠"); + } + if (role == QStringLiteral("system")) + { + return QStringLiteral("系统"); + } + return role.trimmed().isEmpty() ? QStringLiteral("未知") : role.trimmed(); +} + +QString chatMetaText(const ChatMessage &message) +{ + QStringList parts; + if (message.timestamp.isValid()) + { + parts.append(message.timestamp.toString(QStringLiteral("yyyy-MM-dd HH:mm"))); + } + if (!message.provider.trimmed().isEmpty()) + { + parts.append(message.provider.trimmed()); + } + if (!message.model.trimmed().isEmpty()) + { + parts.append(message.model.trimmed()); + } + return parts.join(QStringLiteral(" / ")); +} + +QString compactChatContent(const QString &content) +{ + QString compact = content.simplified(); + if (compact.size() > 120) + { + compact = compact.left(120) + QStringLiteral("..."); + } + return compact; +} + +QString markdownEscaped(QString text) +{ + text.replace(QLatin1Char('\r'), QString()); + return text; +} + +QJsonObject jsonFromChatMessage(const ChatMessage &message) +{ + QJsonObject object; + object.insert(QStringLiteral("role"), message.role); + object.insert(QStringLiteral("content"), message.content); + if (message.timestamp.isValid()) + { + object.insert(QStringLiteral("timestamp"), message.timestamp.toString(Qt::ISODate)); + } + if (!message.provider.trimmed().isEmpty()) + { + object.insert(QStringLiteral("provider"), message.provider.trimmed()); + } + if (!message.model.trimmed().isEmpty()) + { + object.insert(QStringLiteral("model"), message.model.trimmed()); + } + return object; +} + +bool writeBytesAtomically(const QString &filePath, const QByteArray &payload, QString *errorMessage) +{ + QSaveFile file(filePath); + if (!file.open(QIODevice::WriteOnly)) + { + if (errorMessage != nullptr) + { + *errorMessage = QStringLiteral("无法写入文件。"); + } + return false; + } + + const qint64 written = file.write(payload); + if (written != payload.size()) + { + file.cancelWriting(); + if (errorMessage != nullptr) + { + *errorMessage = QStringLiteral("写入文件不完整。"); + } + return false; + } + + if (!file.commit()) + { + if (errorMessage != nullptr) + { + *errorMessage = QStringLiteral("提交文件失败。"); + } + return false; + } + + return true; +} + class ReminderEditDialog : public QDialog { public: @@ -289,6 +512,10 @@ private: SettingsDialog::SettingsDialog( const AIConfigStore &configStore, const AppConfig &appConfig, + const WeatherConfig &weatherConfig, + const WebConfig &webConfig, + const AppLaunchConfig &appLaunchConfig, + const QVector &conversationHistory, const QVector &reminders, std::function aiTestBlocked, std::function clearConversationHistoryCallback, @@ -308,6 +535,7 @@ SettingsDialog::SettingsDialog( , m_maxTokensSpinBox(new QSpinBox(this)) , m_testConnectionButton(new QPushButton(QStringLiteral("测试连接"), this)) , m_allowPlainApiKeyCheckBox(new QCheckBox(QStringLiteral("允许在非 Windows 环境明文保存 API Key"), this)) + , m_launchAtStartupCheckBox(new QCheckBox(QStringLiteral("开机自启动"), this)) , m_scaleSpinBox(new QSpinBox(this)) , m_performanceModeComboBox(new QComboBox(this)) , m_pauseWhenHiddenCheckBox(new QCheckBox(QStringLiteral("隐藏到托盘时暂停动画"), this)) @@ -320,9 +548,38 @@ SettingsDialog::SettingsDialog( , m_saveConversationHistoryCheckBox(new QCheckBox(QStringLiteral("保存聊天记录到本地"), this)) , m_savedHistoryMessageLimitSpinBox(new QSpinBox(this)) , m_clearConversationHistoryButton(new QPushButton(QStringLiteral("清空聊天记录"), this)) + , m_historySearchEdit(new QLineEdit(this)) + , m_historyProviderFilterComboBox(new QComboBox(this)) + , m_historyModelFilterComboBox(new QComboBox(this)) + , m_historyListWidget(new QListWidget(this)) + , m_exportHistoryMarkdownButton(new QPushButton(QStringLiteral("导出 Markdown"), this)) + , m_exportHistoryJsonButton(new QPushButton(QStringLiteral("导出 JSON"), this)) + , m_weatherDefaultCityEdit(new QLineEdit(this)) + , m_weatherAutoLocateCheckBox(new QCheckBox(QStringLiteral("无默认城市时根据公网 IP 判断城市"), this)) + , m_testWeatherDefaultCityButton(new QPushButton(QStringLiteral("测试默认城市"), this)) + , m_weatherStatusLabel(new QLabel(this)) + , m_webCapabilityStatusLabel(new QLabel(this)) + , m_webEnabledCheckBox(new QCheckBox(QStringLiteral("启用联网模式"), this)) + , m_webRememberToggleCheckBox(new QCheckBox(QStringLiteral("记住输入框联网开关状态"), this)) + , m_webDefaultOnCheckBox(new QCheckBox(QStringLiteral("默认打开输入框联网开关"), this)) + , m_webProviderModeComboBox(new QComboBox(this)) + , m_webTimeoutSpinBox(new QSpinBox(this)) + , m_webShowCitationsCheckBox(new QCheckBox(QStringLiteral("显示来源引用"), this)) + , m_testWebButton(new QPushButton(QStringLiteral("测试联网模式"), this)) + , m_webStatusLabel(new QLabel(this)) + , m_launchEnabledCheckBox(new QCheckBox(QStringLiteral("启用应用启动"), this)) + , m_launchManualSelectionCheckBox(new QCheckBox(QStringLiteral("未知应用允许手选 exe"), this)) + , m_launchAppListWidget(new QListWidget(this)) + , m_addLaunchAppButton(new QPushButton(QStringLiteral("添加应用"), this)) + , m_editLaunchAppButton(new QPushButton(QStringLiteral("编辑选中"), this)) + , m_deleteLaunchAppButton(new QPushButton(QStringLiteral("删除选中"), this)) + , m_testLaunchAppButton(new QPushButton(QStringLiteral("测试启动"), this)) + , m_launchStatusLabel(new QLabel(this)) , m_characterComboBox(new QComboBox(this)) , m_importCharacterButton(new QPushButton(QStringLiteral("导入角色文件夹"), this)) , m_deleteCharacterButton(new QPushButton(QStringLiteral("删除角色"), this)) + , m_exportCharacterButton(new QPushButton(QStringLiteral("导出角色"), this)) + , m_openUserCharacterDirButton(new QPushButton(QStringLiteral("打开用户角色目录"), this)) , m_characterStatusLabel(new QLabel(this)) , m_reminderStatusFilterComboBox(new QComboBox(this)) , m_reminderListWidget(new QListWidget(this)) @@ -339,6 +596,10 @@ SettingsDialog::SettingsDialog( , m_reminderSoundStatusLabel(new QLabel(this)) , m_configStore(configStore) , m_appConfig(appConfig) + , m_weatherConfig(weatherConfig) + , m_webConfig(webConfig) + , m_appLaunchConfig(appLaunchConfig) + , m_conversationHistory(conversationHistory) , m_reminders(reminders) , m_aiTestBlocked(std::move(aiTestBlocked)) , m_clearConversationHistory(std::move(clearConversationHistoryCallback)) @@ -346,6 +607,9 @@ SettingsDialog::SettingsDialog( , m_updateReminder(std::move(updateReminderCallback)) , m_clearFinishedReminders(std::move(clearFinishedRemindersCallback)) , m_playReminderSound(std::move(playReminderSoundCallback)) + , m_launchTestManager(std::make_unique()) + , m_webTestManager(std::make_unique()) + , m_weatherTestManager(std::make_unique()) { setWindowTitle(QStringLiteral("设置")); setModal(true); @@ -382,6 +646,7 @@ SettingsDialog::SettingsDialog( m_scaleSpinBox->setSingleStep(10); m_scaleSpinBox->setSuffix(QStringLiteral("%")); m_scaleSpinBox->setValue(qRound(qBound(0.5, m_appConfig.scale, 2.0) * 100.0)); + m_launchAtStartupCheckBox->setChecked(m_appConfig.launchAtStartup); m_performanceModeComboBox->addItem(QStringLiteral("标准"), QStringLiteral("standard")); m_performanceModeComboBox->addItem(QStringLiteral("低功耗"), QStringLiteral("low-power")); @@ -475,6 +740,7 @@ SettingsDialog::SettingsDialog( appFormLayout->setHorizontalSpacing(18); appFormLayout->setVerticalSpacing(12); appFormLayout->addRow(QStringLiteral("缩放"), m_scaleSpinBox); + appFormLayout->addRow(QString(), m_launchAtStartupCheckBox); appFormLayout->addRow(QStringLiteral("性能模式"), m_performanceModeComboBox); appFormLayout->addRow(QString(), m_pauseWhenHiddenCheckBox); appFormLayout->addRow(QString(), m_enableLazyLoadCheckBox); @@ -513,6 +779,49 @@ SettingsDialog::SettingsDialog( clearHistoryLayout->addWidget(m_clearConversationHistoryButton); clearHistoryLayout->addWidget(m_clearConversationStatusLabel, 1); + m_historySearchEdit->setPlaceholderText(QStringLiteral("搜索聊天内容")); + m_historyProviderFilterComboBox->addItem(QStringLiteral("全部 Provider"), QStringLiteral("all")); + m_historyModelFilterComboBox->addItem(QStringLiteral("全部模型"), QStringLiteral("all")); + QSet historyProviders; + QSet historyModels; + for (const ChatMessage &message : m_conversationHistory) + { + if (!message.provider.trimmed().isEmpty()) + { + historyProviders.insert(message.provider.trimmed()); + } + if (!message.model.trimmed().isEmpty()) + { + historyModels.insert(message.model.trimmed()); + } + } + QStringList sortedProviders = historyProviders.values(); + QStringList sortedModels = historyModels.values(); + sortedProviders.sort(Qt::CaseInsensitive); + sortedModels.sort(Qt::CaseInsensitive); + for (const QString &provider : sortedProviders) + { + m_historyProviderFilterComboBox->addItem(provider, provider); + } + for (const QString &model : sortedModels) + { + m_historyModelFilterComboBox->addItem(model, model); + } + m_historyListWidget->setFrameShape(QFrame::NoFrame); + m_historyListWidget->setMinimumHeight(150); + m_historyListWidget->setSelectionMode(QAbstractItemView::NoSelection); + reloadConversationHistoryList(); + + auto *historyFilterLayout = new QHBoxLayout(); + historyFilterLayout->addWidget(m_historySearchEdit, 2); + historyFilterLayout->addWidget(m_historyProviderFilterComboBox); + historyFilterLayout->addWidget(m_historyModelFilterComboBox); + + auto *historyExportLayout = new QHBoxLayout(); + historyExportLayout->addWidget(m_exportHistoryMarkdownButton); + historyExportLayout->addWidget(m_exportHistoryJsonButton); + historyExportLayout->addStretch(); + auto *chatPageLayout = new QVBoxLayout(); chatPageLayout->setContentsMargins(24, 24, 24, 24); chatPageLayout->setSpacing(16); @@ -520,11 +829,152 @@ SettingsDialog::SettingsDialog( chatPageLayout->addWidget(chatHintLabel); chatPageLayout->addLayout(chatFormLayout); chatPageLayout->addLayout(clearHistoryLayout); + chatPageLayout->addLayout(historyFilterLayout); + chatPageLayout->addWidget(m_historyListWidget); + chatPageLayout->addLayout(historyExportLayout); chatPageLayout->addStretch(); auto *chatPage = new QWidget(this); chatPage->setLayout(chatPageLayout); + m_weatherStatusLabel->setObjectName(QStringLiteral("HintLabel")); + m_weatherStatusLabel->setWordWrap(true); + m_weatherStatusLabel->setText(QStringLiteral("天气源:Open-Meteo。当前建议填写市级城市名;区县、乡镇、街道可能无法识别,或被匹配到上级/同名城市。没有默认城市时,可按公网 IP 判断城市;回复会明确说明城市来源。")); + m_weatherDefaultCityEdit->setText(m_weatherConfig.defaultCityName); + m_weatherDefaultCityEdit->setPlaceholderText(QStringLiteral("例如:西安 / 北京 / New York;区县乡镇不保证精确")); + m_weatherAutoLocateCheckBox->setChecked(m_weatherConfig.autoLocateWhenNoDefault); + + auto *weatherTitleLabel = new QLabel(QStringLiteral("天气"), this); + weatherTitleLabel->setObjectName(QStringLiteral("PageTitle")); + auto *weatherHintLabel = new QLabel(QStringLiteral("天气查询使用 Open-Meteo,无需 API Key。当前使用基础地理编码,推荐填写市级城市名。默认城市为空时,可通过公网 IP 定位推断城市。"), this); + weatherHintLabel->setObjectName(QStringLiteral("HintLabel")); + weatherHintLabel->setWordWrap(true); + + auto *weatherFormLayout = new QFormLayout(); + weatherFormLayout->setFieldGrowthPolicy(QFormLayout::ExpandingFieldsGrow); + weatherFormLayout->setLabelAlignment(Qt::AlignRight); + weatherFormLayout->setFormAlignment(Qt::AlignTop); + weatherFormLayout->setHorizontalSpacing(18); + weatherFormLayout->setVerticalSpacing(12); + weatherFormLayout->addRow(QStringLiteral("默认城市(建议市级)"), m_weatherDefaultCityEdit); + weatherFormLayout->addRow(QString(), m_weatherAutoLocateCheckBox); + + auto *weatherActionLayout = new QHBoxLayout(); + weatherActionLayout->addWidget(m_testWeatherDefaultCityButton); + weatherActionLayout->addWidget(m_weatherStatusLabel, 1); + + auto *weatherPageLayout = new QVBoxLayout(); + weatherPageLayout->setContentsMargins(24, 24, 24, 24); + weatherPageLayout->setSpacing(16); + weatherPageLayout->addWidget(weatherTitleLabel); + weatherPageLayout->addWidget(weatherHintLabel); + weatherPageLayout->addLayout(weatherFormLayout); + weatherPageLayout->addLayout(weatherActionLayout); + weatherPageLayout->addStretch(); + + auto *weatherPage = new QWidget(this); + weatherPage->setLayout(weatherPageLayout); + + m_webCapabilityStatusLabel->setObjectName(QStringLiteral("HintLabel")); + m_webCapabilityStatusLabel->setWordWrap(true); + m_webStatusLabel->setObjectName(QStringLiteral("HintLabel")); + m_webStatusLabel->setWordWrap(true); + m_webStatusLabel->setText(QStringLiteral("联网模式使用当前 AI Provider 的原生联网能力;OpenAI 官方和 Gemini 官方可用,DeepSeek 与第三方兼容接口默认不支持。")); + m_webEnabledCheckBox->setChecked(m_webConfig.enabled); + m_webRememberToggleCheckBox->setChecked(m_webConfig.rememberLastToggle); + m_webDefaultOnCheckBox->setChecked(m_webConfig.defaultToggleOn); + m_webProviderModeComboBox->addItem(QStringLiteral("自动"), QStringLiteral("auto")); + m_webProviderModeComboBox->addItem(QStringLiteral("仅 OpenAI 原生联网"), QStringLiteral("openai")); + m_webProviderModeComboBox->addItem(QStringLiteral("仅 Gemini 原生联网"), QStringLiteral("gemini")); + const int webProviderModeIndex = m_webProviderModeComboBox->findData(m_webConfig.providerMode); + m_webProviderModeComboBox->setCurrentIndex(webProviderModeIndex >= 0 ? webProviderModeIndex : 0); + m_webTimeoutSpinBox->setRange(3000, 120000); + m_webTimeoutSpinBox->setSingleStep(1000); + m_webTimeoutSpinBox->setSuffix(QStringLiteral(" ms")); + m_webTimeoutSpinBox->setValue(qBound(3000, m_webConfig.timeoutMs, 120000)); + m_webShowCitationsCheckBox->setChecked(m_webConfig.showCitations); + + auto *webTitleLabel = new QLabel(QStringLiteral("联网模式"), this); + webTitleLabel->setObjectName(QStringLiteral("PageTitle")); + auto *webHintLabel = new QLabel(QStringLiteral("普通用户只需要在输入框打开“联网”开关。当前模型没有原生联网能力时,会直接提示不可用,不再使用旧搜索引擎页面解析。"), this); + webHintLabel->setObjectName(QStringLiteral("HintLabel")); + webHintLabel->setWordWrap(true); + + auto *webFormLayout = new QFormLayout(); + webFormLayout->setFieldGrowthPolicy(QFormLayout::ExpandingFieldsGrow); + webFormLayout->setLabelAlignment(Qt::AlignRight); + webFormLayout->setFormAlignment(Qt::AlignTop); + webFormLayout->setHorizontalSpacing(18); + webFormLayout->setVerticalSpacing(12); + webFormLayout->addRow(QString(), m_webEnabledCheckBox); + webFormLayout->addRow(QString(), m_webRememberToggleCheckBox); + webFormLayout->addRow(QString(), m_webDefaultOnCheckBox); + webFormLayout->addRow(QStringLiteral("Provider 模式"), m_webProviderModeComboBox); + webFormLayout->addRow(QStringLiteral("超时"), m_webTimeoutSpinBox); + webFormLayout->addRow(QString(), m_webShowCitationsCheckBox); + + auto *webActionLayout = new QHBoxLayout(); + webActionLayout->addWidget(m_testWebButton); + webActionLayout->addWidget(m_webStatusLabel, 1); + + auto *webPageLayout = new QVBoxLayout(); + webPageLayout->setContentsMargins(24, 24, 24, 24); + webPageLayout->setSpacing(16); + webPageLayout->addWidget(webTitleLabel); + webPageLayout->addWidget(webHintLabel); + webPageLayout->addWidget(m_webCapabilityStatusLabel); + webPageLayout->addLayout(webFormLayout); + webPageLayout->addLayout(webActionLayout); + webPageLayout->addStretch(); + + auto *webPage = new QWidget(this); + webPage->setLayout(webPageLayout); + + m_launchStatusLabel->setObjectName(QStringLiteral("HintLabel")); + m_launchStatusLabel->setWordWrap(true); + m_launchStatusLabel->setText(QStringLiteral("应用启动只支持 .exe 和开始菜单快捷方式;不执行脚本、命令、安装包,也不会拼接聊天文本参数。")); + m_launchEnabledCheckBox->setChecked(m_appLaunchConfig.enabled); + m_launchManualSelectionCheckBox->setChecked(m_appLaunchConfig.allowManualExeSelection); + m_launchAppListWidget->setObjectName(QStringLiteral("LaunchAppList")); + m_launchAppListWidget->setFrameShape(QFrame::NoFrame); + m_launchAppListWidget->setMinimumHeight(150); + m_launchAppListWidget->setSelectionMode(QAbstractItemView::SingleSelection); + reloadLaunchAppList(); + + auto *launchTitleLabel = new QLabel(QStringLiteral("应用启动"), this); + launchTitleLabel->setObjectName(QStringLiteral("PageTitle")); + auto *launchHintLabel = new QLabel(QStringLiteral("通过聊天输入“打开 Codex / 启动酷狗音乐”等触发。未知应用可手选 exe,启动前始终二次确认。"), this); + launchHintLabel->setObjectName(QStringLiteral("HintLabel")); + launchHintLabel->setWordWrap(true); + + auto *launchFormLayout = new QFormLayout(); + launchFormLayout->setFieldGrowthPolicy(QFormLayout::ExpandingFieldsGrow); + launchFormLayout->setLabelAlignment(Qt::AlignRight); + launchFormLayout->setHorizontalSpacing(18); + launchFormLayout->setVerticalSpacing(12); + launchFormLayout->addRow(QString(), m_launchEnabledCheckBox); + launchFormLayout->addRow(QString(), m_launchManualSelectionCheckBox); + + auto *launchActionLayout = new QHBoxLayout(); + launchActionLayout->addWidget(m_addLaunchAppButton); + launchActionLayout->addWidget(m_editLaunchAppButton); + launchActionLayout->addWidget(m_deleteLaunchAppButton); + launchActionLayout->addWidget(m_testLaunchAppButton); + launchActionLayout->addWidget(m_launchStatusLabel, 1); + + auto *launchPageLayout = new QVBoxLayout(); + launchPageLayout->setContentsMargins(24, 24, 24, 24); + launchPageLayout->setSpacing(16); + launchPageLayout->addWidget(launchTitleLabel); + launchPageLayout->addWidget(launchHintLabel); + launchPageLayout->addLayout(launchFormLayout); + launchPageLayout->addWidget(m_launchAppListWidget); + launchPageLayout->addLayout(launchActionLayout); + launchPageLayout->addStretch(); + + auto *launchPage = new QWidget(this); + launchPage->setLayout(launchPageLayout); + m_reminderStatusLabel->setObjectName(QStringLiteral("HintLabel")); m_reminderStatusLabel->setWordWrap(true); m_reminderSoundStatusLabel->setObjectName(QStringLiteral("HintLabel")); @@ -615,6 +1065,8 @@ SettingsDialog::SettingsDialog( auto *characterActionLayout = new QHBoxLayout(); characterActionLayout->addWidget(m_importCharacterButton); characterActionLayout->addWidget(m_deleteCharacterButton); + characterActionLayout->addWidget(m_exportCharacterButton); + characterActionLayout->addWidget(m_openUserCharacterDirButton); characterActionLayout->addWidget(m_characterStatusLabel, 1); auto *characterPageLayout = new QVBoxLayout(); @@ -636,6 +1088,9 @@ SettingsDialog::SettingsDialog( navigationList->setSpacing(4); 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("角色")); @@ -644,6 +1099,9 @@ SettingsDialog::SettingsDialog( pageStack->setObjectName(QStringLiteral("SettingsPages")); pageStack->addWidget(aiPage); pageStack->addWidget(chatPage); + pageStack->addWidget(weatherPage); + pageStack->addWidget(webPage); + pageStack->addWidget(launchPage); pageStack->addWidget(reminderPage); pageStack->addWidget(appPage); pageStack->addWidget(characterPage); @@ -695,12 +1153,22 @@ SettingsDialog::SettingsDialog( " background: #ffffff; border: 1px solid #98a2b3; border-radius: 0px;" " padding: 0px;" "}" + "QListWidget#LaunchAppList {" + " background: #ffffff; border: 1px solid #98a2b3; border-radius: 0px;" + " padding: 0px;" + "}" "QListWidget#ReminderList::item {" " min-height: 28px; padding: 5px 8px; color: #344054;" "}" + "QListWidget#LaunchAppList::item {" + " min-height: 34px; padding: 6px 8px; color: #344054;" + "}" "QListWidget#ReminderList::item:selected {" " background: #eaf3ff; color: #175cd3;" "}" + "QListWidget#LaunchAppList::item:selected {" + " background: #eaf3ff; color: #175cd3;" + "}" "QStackedWidget#SettingsPages {" " background: #ffffff; border: 1px solid #eaecf0; border-radius: 8px;" "}" @@ -714,6 +1182,9 @@ SettingsDialog::SettingsDialog( connect(m_providerComboBox, &QComboBox::currentTextChanged, this, [this]() { switchProvider(m_providerComboBox->currentData().toString()); }); + connect(m_baseUrlEdit, &QLineEdit::textChanged, this, [this]() { + refreshWebCapabilityStatus(); + }); connect(m_testConnectionButton, &QPushButton::clicked, this, [this]() { testConnection(); }); @@ -728,12 +1199,60 @@ SettingsDialog::SettingsDialog( connect(m_clearConversationHistoryButton, &QPushButton::clicked, this, [this]() { this->clearConversationHistory(); }); + connect(m_historySearchEdit, &QLineEdit::textChanged, this, [this]() { + reloadConversationHistoryList(); + }); + connect(m_historyProviderFilterComboBox, &QComboBox::currentIndexChanged, this, [this]() { + reloadConversationHistoryList(); + }); + connect(m_historyModelFilterComboBox, &QComboBox::currentIndexChanged, this, [this]() { + reloadConversationHistoryList(); + }); + connect(m_exportHistoryMarkdownButton, &QPushButton::clicked, this, [this]() { + exportConversationHistoryMarkdown(); + }); + connect(m_exportHistoryJsonButton, &QPushButton::clicked, this, [this]() { + exportConversationHistoryJson(); + }); + connect(m_testWeatherDefaultCityButton, &QPushButton::clicked, this, [this]() { + testWeatherDefaultCity(); + }); + connect(m_webEnabledCheckBox, &QCheckBox::toggled, this, [this]() { + refreshWebCapabilityStatus(); + }); + connect(m_webProviderModeComboBox, &QComboBox::currentIndexChanged, this, [this]() { + refreshWebCapabilityStatus(); + }); + connect(m_testWebButton, &QPushButton::clicked, this, [this]() { + testWebMode(); + }); + connect(m_launchAppListWidget, &QListWidget::currentItemChanged, this, [this]() { + updateLaunchAppButtons(); + }); + connect(m_addLaunchAppButton, &QPushButton::clicked, this, [this]() { + addLaunchApp(); + }); + connect(m_editLaunchAppButton, &QPushButton::clicked, this, [this]() { + editSelectedLaunchApp(); + }); + connect(m_deleteLaunchAppButton, &QPushButton::clicked, this, [this]() { + deleteSelectedLaunchApp(); + }); + connect(m_testLaunchAppButton, &QPushButton::clicked, this, [this]() { + testSelectedLaunchApp(); + }); connect(m_importCharacterButton, &QPushButton::clicked, this, [this]() { importCharacterFolder(); }); connect(m_deleteCharacterButton, &QPushButton::clicked, this, [this]() { deleteSelectedCharacter(); }); + connect(m_exportCharacterButton, &QPushButton::clicked, this, [this]() { + exportSelectedCharacter(); + }); + connect(m_openUserCharacterDirButton, &QPushButton::clicked, this, [this]() { + openUserCharacterDirectory(); + }); connect(m_reminderStatusFilterComboBox, &QComboBox::currentIndexChanged, this, [this]() { reloadReminderList(); }); @@ -763,6 +1282,8 @@ SettingsDialog::SettingsDialog( }); updateReminderActionButtons(); updateReminderSoundButtons(); + updateLaunchAppButtons(); + refreshWebCapabilityStatus(); } SettingsDialog::~SettingsDialog() @@ -771,6 +1292,14 @@ SettingsDialog::~SettingsDialog() { m_testProvider->cancel(); } + if (m_webTestManager) + { + m_webTestManager->cancel(); + } + if (m_weatherTestManager) + { + m_weatherTestManager->cancel(); + } } AIConfigStore SettingsDialog::aiConfigStore() const @@ -794,6 +1323,7 @@ AppConfig SettingsDialog::appConfig() const { AppConfig config = m_appConfig; config.scale = m_scaleSpinBox->value() / 100.0; + config.launchAtStartup = m_launchAtStartupCheckBox->isChecked(); config.performanceMode = m_performanceModeComboBox->currentData().toString(); if (config.performanceMode.isEmpty()) { @@ -823,6 +1353,389 @@ AppConfig SettingsDialog::appConfig() const return config; } +WeatherConfig SettingsDialog::weatherConfig() const +{ + WeatherConfig config = m_weatherConfig; + config.provider = QStringLiteral("open-meteo"); + config.defaultCityName = m_weatherDefaultCityEdit->text().trimmed(); + config.autoLocateWhenNoDefault = m_weatherAutoLocateCheckBox->isChecked(); + config.language = QStringLiteral("zh"); + config.timeoutMs = qBound(3000, config.timeoutMs, 60000); + return config; +} + +WebConfig SettingsDialog::webConfig() const +{ + WebConfig config = m_webConfig; + config.enabled = m_webEnabledCheckBox->isChecked(); + config.rememberLastToggle = m_webRememberToggleCheckBox->isChecked(); + config.defaultToggleOn = m_webDefaultOnCheckBox->isChecked(); + config.providerMode = m_webProviderModeComboBox->currentData().toString(); + if (config.providerMode.isEmpty()) + { + config.providerMode = QStringLiteral("auto"); + } + config.timeoutMs = qBound(3000, m_webTimeoutSpinBox->value(), 120000); + config.showCitations = m_webShowCitationsCheckBox->isChecked(); + return config; +} + +AppLaunchConfig SettingsDialog::appLaunchConfig() const +{ + AppLaunchConfig config = m_appLaunchConfig; + config.enabled = m_launchEnabledCheckBox->isChecked(); + config.allowManualExeSelection = m_launchManualSelectionCheckBox->isChecked(); + config.alwaysConfirmBeforeLaunch = true; + return config; +} + +void SettingsDialog::testWeatherDefaultCity() +{ + const QString cityName = m_weatherDefaultCityEdit->text().trimmed(); + if (cityName.isEmpty()) + { + m_weatherStatusLabel->setText(QStringLiteral("请先填写默认城市。")); + return; + } + + if (!m_weatherTestManager) + { + m_weatherStatusLabel->setText(QStringLiteral("天气城市测试初始化失败。")); + return; + } + + m_testWeatherDefaultCityButton->setEnabled(false); + m_weatherStatusLabel->setText(QStringLiteral("正在测试默认城市...")); + + WeatherConfig config = weatherConfig(); + config.defaultCityName = cityName; + QPointer dialog(this); + m_weatherTestManager->resolveLocationForTest(cityName, config, [dialog](const LocationTestResult &result) { + if (dialog.isNull()) + { + return; + } + + dialog->m_testWeatherDefaultCityButton->setEnabled(true); + if (!result.success) + { + dialog->m_weatherStatusLabel->setText(result.errorMessage.isEmpty() + ? QStringLiteral("城市测试失败。") + : result.errorMessage); + return; + } + + dialog->m_weatherStatusLabel->setText(weatherLocationTestDisplayText(result)); + }); +} + +void SettingsDialog::refreshWebCapabilityStatus() +{ + if (!m_webCapabilityStatusLabel) + { + return; + } + + const QString provider = normalizedProviderName(m_providerComboBox->currentData().toString()); + const AIConfig config = runtimeConfigFromForm(provider); + const WebCapability capability = WebCapabilityDetector::detect(config, webConfig()); + m_webCapabilityStatusLabel->setText(webCapabilityDisplayText(capability)); +} + +void SettingsDialog::testWebMode() +{ + if (!m_webTestManager) + { + m_webStatusLabel->setText(QStringLiteral("联网模式测试初始化失败。")); + return; + } + + if (m_webTestManager->isBusy()) + { + return; + } + + WebConfig config = webConfig(); + config.enabled = true; + + const QString provider = normalizedProviderName(m_providerComboBox->currentData().toString()); + AIConfig aiConfig = runtimeConfigFromForm(provider); + aiConfig.maxTokens = qMax(256, qMin(aiConfig.maxTokens, 1024)); + + const WebCapability capability = WebCapabilityDetector::detect(aiConfig, config); + refreshWebCapabilityStatus(); + if (!capability.supported) + { + m_webStatusLabel->setText(capability.userMessage); + return; + } + + ChatRequest request; + request.messages.append(ChatMessage{ + QStringLiteral("system"), + QStringLiteral("你是桌宠助手。请用中文简短回答,并尽量返回来源。")}); + request.messages.append(ChatMessage{ + QStringLiteral("user"), + QStringLiteral("请联网确认 Qt 6 的最新稳定版本,并给出来源。")}); + + WebChatRequest webRequest; + webRequest.chatRequest = request; + webRequest.aiConfig = aiConfig; + webRequest.webConfig = config; + + m_testWebButton->setEnabled(false); + m_webStatusLabel->setText(QStringLiteral("正在测试联网模式...")); + + QPointer dialog(this); + m_webTestManager->sendWebChat(webRequest, [dialog](const WebChatResponse &response) { + if (dialog.isNull()) + { + return; + } + + dialog->m_testWebButton->setEnabled(true); + if (!response.success) + { + dialog->m_webStatusLabel->setText(response.errorMessage.isEmpty() + ? QStringLiteral("联网模式测试失败。") + : response.errorMessage); + return; + } + + dialog->m_webStatusLabel->setText(QStringLiteral("联网模式测试成功。实际联网:%1,来源数:%2。") + .arg(response.usedWeb ? QStringLiteral("是") : QStringLiteral("否")) + .arg(response.citations.size())); + }); +} + +void SettingsDialog::reloadLaunchAppList() +{ + m_launchAppListWidget->clear(); + for (const RegisteredApp &app : m_appLaunchConfig.apps) + { + auto *item = new QListWidgetItem(launchAppDisplayText(app)); + item->setData(Qt::UserRole, app.id); + item->setToolTip(launchAppDisplayText(app)); + m_launchAppListWidget->addItem(item); + } + updateLaunchAppButtons(); +} + +void SettingsDialog::updateLaunchAppButtons() +{ + const bool hasSelection = !selectedLaunchAppId().isEmpty(); + m_editLaunchAppButton->setEnabled(hasSelection); + m_deleteLaunchAppButton->setEnabled(hasSelection); + m_testLaunchAppButton->setEnabled(hasSelection); +} + +QString SettingsDialog::selectedLaunchAppId() const +{ + const QListWidgetItem *item = m_launchAppListWidget->currentItem(); + return item == nullptr ? QString() : item->data(Qt::UserRole).toString(); +} + +void SettingsDialog::addLaunchApp() +{ + const QString executablePath = QFileDialog::getOpenFileName( + this, + QStringLiteral("选择要登记的应用"), + QString(), + QStringLiteral("Applications (*.exe)")); + if (executablePath.isEmpty()) + { + return; + } + + const QFileInfo info(executablePath); + if (info.suffix().compare(QStringLiteral("exe"), Qt::CaseInsensitive) != 0) + { + QMessageBox::warning(this, QStringLiteral("不能添加"), QStringLiteral("应用启动只允许登记 .exe。")); + return; + } + + bool ok = false; + const QString displayName = QInputDialog::getText( + this, + QStringLiteral("应用名称"), + QStringLiteral("显示名称"), + QLineEdit::Normal, + info.completeBaseName(), + &ok).trimmed(); + if (!ok || displayName.isEmpty()) + { + return; + } + + const QString aliasText = QInputDialog::getText( + this, + QStringLiteral("应用别名"), + QStringLiteral("别名,用逗号分隔"), + QLineEdit::Normal, + displayName, + &ok).trimmed(); + if (!ok) + { + return; + } + + AppLaunchPlan plan; + plan.requestedName = displayName; + plan.displayName = displayName; + plan.executablePath = info.absoluteFilePath(); + plan.workingDirectory = info.absolutePath(); + plan.matchSource = QStringLiteral("设置页登记"); + RegisteredApp app = m_launchTestManager->registeredAppFromPlan(plan, aliasesFromUserText(aliasText)); + app.userDefined = true; + const QString baseId = app.id; + int suffix = 2; + const auto idExists = [this](const QString &id) { + for (const RegisteredApp &existingApp : m_appLaunchConfig.apps) + { + if (existingApp.id == id) + { + return true; + } + } + return false; + }; + while (idExists(app.id)) + { + app.id = baseId + QStringLiteral("_") + QString::number(suffix++); + } + m_appLaunchConfig.apps.append(app); + reloadLaunchAppList(); + m_launchStatusLabel->setText(QStringLiteral("已添加应用:%1。点击保存后生效。").arg(displayName)); +} + +void SettingsDialog::editSelectedLaunchApp() +{ + const QString appId = selectedLaunchAppId(); + if (appId.isEmpty()) + { + return; + } + + for (RegisteredApp &app : m_appLaunchConfig.apps) + { + if (app.id != appId) + { + continue; + } + + bool ok = false; + const QString displayName = QInputDialog::getText( + this, + QStringLiteral("编辑应用"), + QStringLiteral("显示名称"), + QLineEdit::Normal, + app.displayName, + &ok).trimmed(); + if (!ok || displayName.isEmpty()) + { + return; + } + + const QString aliasText = QInputDialog::getText( + this, + QStringLiteral("编辑别名"), + QStringLiteral("别名,用逗号分隔"), + QLineEdit::Normal, + app.aliases.join(QStringLiteral(", ")), + &ok).trimmed(); + if (!ok) + { + return; + } + + app.displayName = displayName; + app.aliases = aliasesFromUserText(aliasText); + if (!app.aliases.contains(displayName, Qt::CaseInsensitive)) + { + app.aliases.prepend(displayName); + } + reloadLaunchAppList(); + m_launchStatusLabel->setText(QStringLiteral("已更新应用:%1。点击保存后生效。").arg(displayName)); + return; + } +} + +void SettingsDialog::deleteSelectedLaunchApp() +{ + const QString appId = selectedLaunchAppId(); + if (appId.isEmpty()) + { + return; + } + + const int answer = QMessageBox::warning( + this, + QStringLiteral("删除登记应用"), + QStringLiteral("只会删除设置页中的应用登记,不会删除磁盘上的程序文件。是否继续?"), + QMessageBox::Yes | QMessageBox::Cancel, + QMessageBox::Cancel); + if (answer != QMessageBox::Yes) + { + return; + } + + for (int index = 0; index < m_appLaunchConfig.apps.size(); ++index) + { + if (m_appLaunchConfig.apps.at(index).id == appId) + { + const QString name = m_appLaunchConfig.apps.at(index).displayName; + m_appLaunchConfig.apps.removeAt(index); + reloadLaunchAppList(); + m_launchStatusLabel->setText(QStringLiteral("已删除登记:%1。点击保存后生效。").arg(name)); + return; + } + } +} + +void SettingsDialog::testSelectedLaunchApp() +{ + const QString appId = selectedLaunchAppId(); + if (appId.isEmpty() || !m_launchTestManager) + { + return; + } + + for (const RegisteredApp &app : m_appLaunchConfig.apps) + { + if (app.id != appId) + { + continue; + } + + AppLaunchPlan plan; + plan.success = true; + plan.requestedName = app.displayName; + plan.displayName = app.displayName; + plan.executablePath = app.executablePath; + plan.shortcutPath = app.shortcutPath; + plan.workingDirectory = app.workingDirectory; + plan.matchSource = QStringLiteral("设置页测试"); + + const int answer = QMessageBox::warning( + this, + QStringLiteral("测试启动应用"), + QStringLiteral("即将启动:%1\n路径:%2\n\n是否继续?") + .arg(plan.displayName, plan.executablePath.isEmpty() ? plan.shortcutPath : plan.executablePath), + QMessageBox::Yes | QMessageBox::Cancel, + QMessageBox::Cancel); + if (answer != QMessageBox::Yes) + { + return; + } + + const AppLaunchResult result = m_launchTestManager->executeLaunchPlan(plan); + m_launchStatusLabel->setText(result.success + ? result.message + : (result.errorMessage.isEmpty() ? QStringLiteral("测试启动失败。") : result.errorMessage)); + return; + } +} + void SettingsDialog::accept() { if (!SecretStore::isEncryptionAvailable() @@ -929,6 +1842,7 @@ void SettingsDialog::switchProvider(const QString &provider) cacheCurrentProvider(); loadProviderConfig(normalizedProvider); m_currentProvider = normalizedProvider; + refreshWebCapabilityStatus(); } AIConfig SettingsDialog::configFromForm(const QString &provider, QString *errorMessage) const @@ -1073,7 +1987,7 @@ void SettingsDialog::testConnection() setTestStatus(QStringLiteral("正在测试连接..."), QStringLiteral("info")); ChatRequest request; - request.messages.append({ + request.messages.append(ChatMessage{ QStringLiteral("user"), QStringLiteral("请只回复 OK,用于连接测试。") }); @@ -1124,9 +2038,149 @@ void SettingsDialog::clearConversationHistory() m_clearConversationHistory(); } + m_conversationHistory.clear(); + reloadConversationHistoryList(); m_clearConversationStatusLabel->setText(QStringLiteral("聊天记录已清空。")); } +QVector SettingsDialog::filteredConversationHistory() const +{ + QVector filtered; + const QString keyword = m_historySearchEdit->text().trimmed(); + const QString provider = m_historyProviderFilterComboBox->currentData().toString(); + const QString model = m_historyModelFilterComboBox->currentData().toString(); + + for (const ChatMessage &message : m_conversationHistory) + { + if (!keyword.isEmpty() && !message.content.contains(keyword, Qt::CaseInsensitive)) + { + continue; + } + if (provider != QStringLiteral("all") && message.provider.trimmed() != provider) + { + continue; + } + if (model != QStringLiteral("all") && message.model.trimmed() != model) + { + continue; + } + + filtered.append(message); + } + return filtered; +} + +void SettingsDialog::reloadConversationHistoryList() +{ + m_historyListWidget->clear(); + const QVector messages = filteredConversationHistory(); + for (const ChatMessage &message : messages) + { + QString title = QStringLiteral("%1 %2").arg(chatRoleDisplayText(message.role), compactChatContent(message.content)); + const QString meta = chatMetaText(message); + if (!meta.isEmpty()) + { + title.prepend(meta + QStringLiteral(" ")); + } + + auto *item = new QListWidgetItem(title, m_historyListWidget); + item->setToolTip(message.content); + } + + const bool hasMessages = !messages.isEmpty(); + m_exportHistoryMarkdownButton->setEnabled(hasMessages); + m_exportHistoryJsonButton->setEnabled(hasMessages); + if (!hasMessages) + { + auto *emptyItem = new QListWidgetItem(QStringLiteral("当前筛选条件下没有聊天记录。"), m_historyListWidget); + emptyItem->setFlags(emptyItem->flags() & ~Qt::ItemIsSelectable); + } +} + +void SettingsDialog::exportConversationHistoryMarkdown() +{ + const QVector messages = filteredConversationHistory(); + if (messages.isEmpty()) + { + m_clearConversationStatusLabel->setText(QStringLiteral("没有可导出的聊天记录。")); + return; + } + + const QString filePath = QFileDialog::getSaveFileName( + this, + QStringLiteral("导出聊天记录 Markdown"), + QStringLiteral("conversation_history.md"), + QStringLiteral("Markdown (*.md)")); + if (filePath.isEmpty()) + { + return; + } + + QString markdown; + QTextStream stream(&markdown); + stream << "# QtDesktopPet 聊天记录\n\n"; + for (const ChatMessage &message : messages) + { + stream << "## " << chatRoleDisplayText(message.role); + const QString meta = chatMetaText(message); + if (!meta.isEmpty()) + { + stream << " - " << meta; + } + stream << "\n\n"; + stream << markdownEscaped(message.content).trimmed() << "\n\n"; + } + + QString errorMessage; + if (!writeBytesAtomically(filePath, markdown.toUtf8(), &errorMessage)) + { + m_clearConversationStatusLabel->setText(errorMessage.isEmpty() ? QStringLiteral("导出 Markdown 失败。") : errorMessage); + return; + } + + m_clearConversationStatusLabel->setText(QStringLiteral("已导出 Markdown:%1").arg(filePath)); +} + +void SettingsDialog::exportConversationHistoryJson() +{ + const QVector messages = filteredConversationHistory(); + if (messages.isEmpty()) + { + m_clearConversationStatusLabel->setText(QStringLiteral("没有可导出的聊天记录。")); + return; + } + + const QString filePath = QFileDialog::getSaveFileName( + this, + QStringLiteral("导出聊天记录 JSON"), + QStringLiteral("conversation_history.json"), + QStringLiteral("JSON (*.json)")); + if (filePath.isEmpty()) + { + return; + } + + QJsonArray messageArray; + for (const ChatMessage &message : messages) + { + messageArray.append(jsonFromChatMessage(message)); + } + + QJsonObject root; + root.insert(QStringLiteral("version"), 1); + root.insert(QStringLiteral("exportedAt"), QDateTime::currentDateTime().toString(Qt::ISODate)); + root.insert(QStringLiteral("messages"), messageArray); + + QString errorMessage; + if (!writeBytesAtomically(filePath, QJsonDocument(root).toJson(QJsonDocument::Indented), &errorMessage)) + { + m_clearConversationStatusLabel->setText(errorMessage.isEmpty() ? QStringLiteral("导出 JSON 失败。") : errorMessage); + return; + } + + m_clearConversationStatusLabel->setText(QStringLiteral("已导出 JSON:%1").arg(filePath)); +} + void SettingsDialog::reloadReminderList() { m_reminderListWidget->clear(); @@ -1602,3 +2656,77 @@ void SettingsDialog::importCharacterFolder() reloadCharacterList(importedCharacterId); m_characterStatusLabel->setText(QStringLiteral("已导入角色:%1").arg(displayName.isEmpty() ? importedCharacterId : displayName)); } + +void SettingsDialog::exportSelectedCharacter() +{ + const QString characterId = m_characterComboBox->currentData().toString().trimmed(); + if (characterId.isEmpty()) + { + return; + } + + const QString targetParentDirectoryPath = QFileDialog::getExistingDirectory( + this, + QStringLiteral("选择角色导出目录"), + QString(), + QFileDialog::ShowDirsOnly | QFileDialog::DontResolveSymlinks); + if (targetParentDirectoryPath.isEmpty()) + { + return; + } + + const QString targetPath = QDir(targetParentDirectoryPath).filePath(characterId); + bool overwrite = false; + if (QFileInfo::exists(targetPath)) + { + const QMessageBox::StandardButton result = QMessageBox::warning( + this, + QStringLiteral("覆盖导出目标"), + QStringLiteral("目标目录已存在:\n%1\n\n继续会替换该导出目录。是否继续?").arg(targetPath), + QMessageBox::Yes | QMessageBox::Cancel, + QMessageBox::Cancel); + if (result != QMessageBox::Yes) + { + return; + } + overwrite = true; + } + + QString exportedPath; + QString errorMessage; + if (!CharacterPackageRepository::exportPackageDirectory(characterId, targetParentDirectoryPath, overwrite, &exportedPath, &errorMessage)) + { + QMessageBox::warning( + this, + QStringLiteral("角色导出失败"), + errorMessage.isEmpty() ? QStringLiteral("导出角色失败。") : errorMessage); + return; + } + + m_characterStatusLabel->setText(QStringLiteral("已导出角色到:%1").arg(exportedPath)); +} + +void SettingsDialog::openUserCharacterDirectory() +{ + const QString directoryPath = CharacterPackageRepository::userCharactersRootPath(); + QDir directory(directoryPath); + if (!directory.exists() && !directory.mkpath(QStringLiteral("."))) + { + QMessageBox::warning( + this, + QStringLiteral("打开用户角色目录失败"), + QStringLiteral("无法创建用户角色目录。")); + return; + } + + if (!QDesktopServices::openUrl(QUrl::fromLocalFile(directory.absolutePath()))) + { + QMessageBox::warning( + this, + QStringLiteral("打开用户角色目录失败"), + QStringLiteral("系统文件管理器无法打开该目录。")); + return; + } + + m_characterStatusLabel->setText(QStringLiteral("已打开用户角色目录。")); +} diff --git a/src/ui/SettingsDialog.h b/src/ui/SettingsDialog.h index 3ca0ecc..36ced80 100644 --- a/src/ui/SettingsDialog.h +++ b/src/ui/SettingsDialog.h @@ -2,7 +2,11 @@ #include "../config/AIConfig.h" #include "../config/AppConfig.h" +#include "../ai/LLMTypes.h" +#include "../launcher/AppLaunchTypes.h" #include "../reminder/ReminderTypes.h" +#include "../web/WebConfig.h" +#include "../weather/WeatherConfig.h" #include #include @@ -19,6 +23,9 @@ class QListWidget; class QPushButton; class QSpinBox; class LLMProvider; +class AppLaunchManager; +class WebChatManager; +class WeatherManager; class SettingsDialog : public QDialog { @@ -26,6 +33,10 @@ public: explicit SettingsDialog( const AIConfigStore &configStore, const AppConfig &appConfig, + const WeatherConfig &weatherConfig, + const WebConfig &webConfig, + const AppLaunchConfig &appLaunchConfig, + const QVector &conversationHistory, const QVector &reminders, std::function aiTestBlocked, std::function clearConversationHistoryCallback, @@ -38,6 +49,9 @@ public: AIConfigStore aiConfigStore() const; AppConfig appConfig() const; + WeatherConfig weatherConfig() const; + WebConfig webConfig() const; + AppLaunchConfig appLaunchConfig() const; protected: void accept() override; @@ -53,9 +67,15 @@ private: void testConnection(); void setTestStatus(const QString &message, const QString &state); void clearConversationHistory(); + void reloadConversationHistoryList(); + QVector filteredConversationHistory() const; + void exportConversationHistoryMarkdown(); + void exportConversationHistoryJson(); void reloadCharacterList(const QString &selectedCharacterId = {}); void importCharacterFolder(); void deleteSelectedCharacter(); + void exportSelectedCharacter(); + void openUserCharacterDirectory(); void reloadReminderList(); void reloadReminderSoundList(const QString &selectedSoundId = {}); QString selectedReminderSoundId() const; @@ -67,6 +87,16 @@ private: void importReminderSound(); void deleteSelectedReminderSound(); void testSelectedReminderSound(); + void testWeatherDefaultCity(); + void refreshWebCapabilityStatus(); + void testWebMode(); + void reloadLaunchAppList(); + void updateLaunchAppButtons(); + QString selectedLaunchAppId() const; + void addLaunchApp(); + void editSelectedLaunchApp(); + void deleteSelectedLaunchApp(); + void testSelectedLaunchApp(); QComboBox *m_providerComboBox = nullptr; QLineEdit *m_baseUrlEdit = nullptr; @@ -79,6 +109,7 @@ private: QPushButton *m_testConnectionButton = nullptr; QLabel *m_testStatusLabel = nullptr; QCheckBox *m_allowPlainApiKeyCheckBox = nullptr; + QCheckBox *m_launchAtStartupCheckBox = nullptr; QSpinBox *m_scaleSpinBox = nullptr; QComboBox *m_performanceModeComboBox = nullptr; QCheckBox *m_pauseWhenHiddenCheckBox = nullptr; @@ -92,9 +123,38 @@ private: QSpinBox *m_savedHistoryMessageLimitSpinBox = nullptr; QPushButton *m_clearConversationHistoryButton = nullptr; QLabel *m_clearConversationStatusLabel = nullptr; + QLineEdit *m_historySearchEdit = nullptr; + QComboBox *m_historyProviderFilterComboBox = nullptr; + QComboBox *m_historyModelFilterComboBox = nullptr; + QListWidget *m_historyListWidget = nullptr; + QPushButton *m_exportHistoryMarkdownButton = nullptr; + QPushButton *m_exportHistoryJsonButton = nullptr; + QLineEdit *m_weatherDefaultCityEdit = nullptr; + QCheckBox *m_weatherAutoLocateCheckBox = nullptr; + QPushButton *m_testWeatherDefaultCityButton = nullptr; + QLabel *m_weatherStatusLabel = nullptr; + QLabel *m_webCapabilityStatusLabel = nullptr; + QCheckBox *m_webEnabledCheckBox = nullptr; + QCheckBox *m_webRememberToggleCheckBox = nullptr; + QCheckBox *m_webDefaultOnCheckBox = nullptr; + QComboBox *m_webProviderModeComboBox = nullptr; + QSpinBox *m_webTimeoutSpinBox = nullptr; + QCheckBox *m_webShowCitationsCheckBox = nullptr; + QPushButton *m_testWebButton = nullptr; + QLabel *m_webStatusLabel = nullptr; + QCheckBox *m_launchEnabledCheckBox = nullptr; + QCheckBox *m_launchManualSelectionCheckBox = nullptr; + QListWidget *m_launchAppListWidget = nullptr; + QPushButton *m_addLaunchAppButton = nullptr; + QPushButton *m_editLaunchAppButton = nullptr; + QPushButton *m_deleteLaunchAppButton = nullptr; + QPushButton *m_testLaunchAppButton = nullptr; + QLabel *m_launchStatusLabel = nullptr; QComboBox *m_characterComboBox = nullptr; QPushButton *m_importCharacterButton = nullptr; QPushButton *m_deleteCharacterButton = nullptr; + QPushButton *m_exportCharacterButton = nullptr; + QPushButton *m_openUserCharacterDirButton = nullptr; QLabel *m_characterStatusLabel = nullptr; QComboBox *m_reminderStatusFilterComboBox = nullptr; QListWidget *m_reminderListWidget = nullptr; @@ -112,6 +172,10 @@ private: AIConfigStore m_configStore; AIConfigStore m_acceptedConfigStore; AppConfig m_appConfig; + WeatherConfig m_weatherConfig; + WebConfig m_webConfig; + AppLaunchConfig m_appLaunchConfig; + QVector m_conversationHistory; QVector m_reminders; QString m_currentProvider; std::function m_aiTestBlocked; @@ -121,5 +185,8 @@ private: std::function m_clearFinishedReminders; std::function m_playReminderSound; std::unique_ptr m_testProvider; + std::unique_ptr m_launchTestManager; + std::unique_ptr m_webTestManager; + std::unique_ptr m_weatherTestManager; bool m_hasAcceptedConfigStore = false; }; diff --git a/src/weather/WeatherConfig.h b/src/weather/WeatherConfig.h new file mode 100644 index 0000000..18847df --- /dev/null +++ b/src/weather/WeatherConfig.h @@ -0,0 +1,12 @@ +#pragma once + +#include + +struct WeatherConfig +{ + QString provider = QStringLiteral("open-meteo"); + QString defaultCityName; + bool autoLocateWhenNoDefault = true; + QString language = QStringLiteral("zh"); + int timeoutMs = 10000; +}; diff --git a/src/weather/WeatherManager.cpp b/src/weather/WeatherManager.cpp new file mode 100644 index 0000000..0d8aea4 --- /dev/null +++ b/src/weather/WeatherManager.cpp @@ -0,0 +1,622 @@ +#include "WeatherManager.h" + +#include "../util/Logger.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace +{ +constexpr int ForecastDays = 4; +constexpr int GeocodingCandidateCount = 5; + +QString normalizedProvider(const WeatherConfig &config) +{ + const QString provider = config.provider.trimmed().toLower(); + return provider.isEmpty() ? QStringLiteral("open-meteo") : provider; +} + +QString normalizedLanguage(const WeatherConfig &config) +{ + const QString language = config.language.trimmed().toLower(); + return language.isEmpty() ? QStringLiteral("zh") : language; +} + +QDateTime dateTimeFromOpenMeteo(const QString &text) +{ + QDateTime value = QDateTime::fromString(text, Qt::ISODate); + if (value.isValid()) + { + return value; + } + + value = QDateTime::fromString(text, QStringLiteral("yyyy-MM-ddTHH:mm")); + if (value.isValid()) + { + return value; + } + + return {}; +} + +bool jsonDouble(const QJsonObject &object, const QString &key, double *value) +{ + const QJsonValue jsonValue = object.value(key); + if (!jsonValue.isDouble()) + { + return false; + } + if (value != nullptr) + { + *value = jsonValue.toDouble(); + } + return true; +} + +bool jsonInt(const QJsonObject &object, const QString &key, int *value) +{ + const QJsonValue jsonValue = object.value(key); + if (!jsonValue.isDouble()) + { + return false; + } + if (value != nullptr) + { + *value = jsonValue.toInt(); + } + return true; +} + +QJsonArray arrayValue(const QJsonObject &object, const QString &key) +{ + const QJsonValue value = object.value(key); + return value.isArray() ? value.toArray() : QJsonArray(); +} + +QString normalizedCandidateText(const QString &text) +{ + return text.trimmed().toLower(); +} + +QString candidateKey(const WeatherLocationCandidate &candidate) +{ + return normalizedCandidateText(candidate.cityName) + + QLatin1Char('|') + + normalizedCandidateText(candidate.adminName) + + QLatin1Char('|') + + normalizedCandidateText(candidate.countryName); +} + +bool hasCandidateAmbiguity(const QVector &candidates) +{ + if (candidates.size() <= 1) + { + return false; + } + + const QString firstKey = candidateKey(candidates.first()); + for (int index = 1; index < candidates.size(); ++index) + { + if (candidateKey(candidates.at(index)) != firstKey) + { + return true; + } + } + return false; +} + +WeatherLocation locationFromCandidate(const WeatherLocationCandidate &candidate, WeatherLocationSource source) +{ + WeatherLocation location; + location.cityName = candidate.cityName; + location.adminName = candidate.adminName; + location.countryName = candidate.countryName; + location.timezone = candidate.timezone; + location.latitude = candidate.latitude; + location.longitude = candidate.longitude; + location.source = source; + return location; +} + +QVector candidatesFromGeocodingResults(const QJsonArray &results) +{ + QVector candidates; + for (const QJsonValue &value : results) + { + if (!value.isObject()) + { + continue; + } + + const QJsonObject city = value.toObject(); + WeatherLocationCandidate candidate; + if (!jsonDouble(city, QStringLiteral("latitude"), &candidate.latitude) + || !jsonDouble(city, QStringLiteral("longitude"), &candidate.longitude)) + { + continue; + } + + candidate.cityName = city.value(QStringLiteral("name")).toString(); + candidate.adminName = city.value(QStringLiteral("admin1")).toString(); + candidate.countryName = city.value(QStringLiteral("country")).toString(); + candidate.timezone = city.value(QStringLiteral("timezone")).toString(); + candidates.append(candidate); + } + return candidates; +} +} + +WeatherManager::WeatherManager() +{ + m_timeoutTimer.setSingleShot(true); + QObject::connect(&m_timeoutTimer, &QTimer::timeout, [this]() { + if (m_locationTestOnly) + { + finishLocationTestWithError(QStringLiteral("城市测试超时,请稍后再试。")); + return; + } + + finishWithError(QStringLiteral("天气查询超时,请稍后再试。")); + }); +} + +WeatherManager::~WeatherManager() +{ + cancel(); +} + +bool WeatherManager::isBusy() const +{ + return m_busy; +} + +void WeatherManager::queryWeather(const QString &text, const WeatherConfig &config, QueryCallback callback) +{ + if (isBusy()) + { + if (callback) + { + callback({false, {}, QStringLiteral("天气查询正在进行,请稍后。"), {}}); + } + return; + } + + m_query = m_parser.parse(text); + if (!m_query.unsupportedReason.isEmpty()) + { + if (callback) + { + callback({false, {}, m_query.unsupportedReason, {}}); + } + return; + } + + m_config = config; + m_config.provider = normalizedProvider(m_config); + m_config.language = normalizedLanguage(m_config); + m_config.timeoutMs = qBound(3000, m_config.timeoutMs, 60000); + m_callback = std::move(callback); + m_locationTestCallback = nullptr; + m_locationTestOnly = false; + m_locationCandidates.clear(); + m_hasLocationAmbiguity = false; + m_busy = true; + + if (m_config.provider != QStringLiteral("open-meteo")) + { + finishWithError(QStringLiteral("当前天气源暂不支持:%1。").arg(m_config.provider)); + return; + } + + if (!m_query.cityName.trimmed().isEmpty()) + { + m_query.locationSource = WeatherLocationSource::ExplicitCity; + startGeocodingRequest(m_query.cityName.trimmed(), WeatherLocationSource::ExplicitCity, false); + return; + } + + const QString defaultCity = m_config.defaultCityName.trimmed(); + if (!defaultCity.isEmpty()) + { + m_query.locationSource = WeatherLocationSource::SettingsDefault; + startGeocodingRequest(defaultCity, WeatherLocationSource::SettingsDefault, false); + return; + } + + if (m_config.autoLocateWhenNoDefault) + { + m_query.locationSource = WeatherLocationSource::IpFallback; + startIpLocationRequest(); + return; + } + + finishWithError(QStringLiteral("没有识别到城市。请在问题里写城市,或到设置页配置默认城市。")); +} + +void WeatherManager::resolveLocationForTest(const QString &cityName, const WeatherConfig &config, LocationTestCallback callback) +{ + if (isBusy()) + { + if (callback) + { + callback({false, QStringLiteral("城市测试正在进行,请稍后。"), {}, {}, false}); + } + return; + } + + const QString normalizedCityName = cityName.trimmed(); + if (normalizedCityName.isEmpty()) + { + if (callback) + { + callback({false, QStringLiteral("请先填写默认城市。"), {}, {}, false}); + } + return; + } + + m_config = config; + m_config.provider = normalizedProvider(m_config); + m_config.language = normalizedLanguage(m_config); + m_config.timeoutMs = qBound(3000, m_config.timeoutMs, 60000); + m_query = {}; + m_query.cityName = normalizedCityName; + m_query.locationSource = WeatherLocationSource::SettingsDefault; + m_callback = nullptr; + m_locationTestCallback = std::move(callback); + m_locationTestOnly = true; + m_locationCandidates.clear(); + m_hasLocationAmbiguity = false; + m_busy = true; + + if (m_config.provider != QStringLiteral("open-meteo")) + { + finishLocationTestWithError(QStringLiteral("当前天气源暂不支持:%1。").arg(m_config.provider)); + return; + } + + startGeocodingRequest(normalizedCityName, WeatherLocationSource::SettingsDefault, true); +} + +void WeatherManager::cancel() +{ + m_callback = nullptr; + m_locationTestCallback = nullptr; + m_locationTestOnly = false; + m_locationCandidates.clear(); + m_hasLocationAmbiguity = false; + clearReply(); + m_busy = false; +} + +void WeatherManager::startGeocodingRequest(const QString &cityName, WeatherLocationSource source, bool locationTestOnly) +{ + m_location = {}; + m_location.source = source; + m_locationTestOnly = locationTestOnly; + + QUrl url(QStringLiteral("https://geocoding-api.open-meteo.com/v1/search")); + QUrlQuery query; + query.addQueryItem(QStringLiteral("name"), cityName); + query.addQueryItem(QStringLiteral("count"), QString::number(GeocodingCandidateCount)); + query.addQueryItem(QStringLiteral("language"), m_config.language); + query.addQueryItem(QStringLiteral("format"), QStringLiteral("json")); + url.setQuery(query); + + startRequest(url, [this](QNetworkReply *reply) { + finishGeocodingRequest(reply); + }); +} + +void WeatherManager::startIpLocationRequest() +{ + m_location = {}; + m_location.source = WeatherLocationSource::IpFallback; + m_locationCandidates.clear(); + m_hasLocationAmbiguity = false; + + QUrl url(QStringLiteral("https://ipapi.co/json/")); + startRequest(url, [this](QNetworkReply *reply) { + finishIpLocationRequest(reply); + }); +} + +void WeatherManager::startForecastRequest() +{ + QUrl url(QStringLiteral("https://api.open-meteo.com/v1/forecast")); + QUrlQuery query; + query.addQueryItem(QStringLiteral("latitude"), QString::number(m_location.latitude, 'f', 6)); + query.addQueryItem(QStringLiteral("longitude"), QString::number(m_location.longitude, 'f', 6)); + query.addQueryItem(QStringLiteral("current"), QStringLiteral("temperature_2m,relative_humidity_2m,apparent_temperature,weather_code,wind_speed_10m,wind_direction_10m,precipitation")); + query.addQueryItem(QStringLiteral("daily"), QStringLiteral("weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max")); + query.addQueryItem(QStringLiteral("timezone"), QStringLiteral("auto")); + query.addQueryItem(QStringLiteral("forecast_days"), QString::number(ForecastDays)); + url.setQuery(query); + + startRequest(url, [this](QNetworkReply *reply) { + finishForecastRequest(reply); + }); +} + +void WeatherManager::finishGeocodingRequest(QNetworkReply *reply) +{ + const QByteArray body = reply->readAll(); + const int httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + if (reply->error() != QNetworkReply::NoError) + { + finishGeocodingWithError(QStringLiteral("城市解析失败:%1。").arg(reply->errorString())); + return; + } + + QJsonParseError parseError; + const QJsonDocument document = QJsonDocument::fromJson(body, &parseError); + if (parseError.error != QJsonParseError::NoError || !document.isObject()) + { + finishGeocodingWithError(QStringLiteral("城市解析结果格式无效。")); + return; + } + + const QJsonArray results = document.object().value(QStringLiteral("results")).toArray(); + m_locationCandidates = candidatesFromGeocodingResults(results); + if (m_locationCandidates.isEmpty()) + { + finishGeocodingWithError(QStringLiteral("没有找到匹配的城市。")); + return; + } + + m_hasLocationAmbiguity = hasCandidateAmbiguity(m_locationCandidates); + m_location = locationFromCandidate(m_locationCandidates.first(), m_location.source); + + if (m_locationTestOnly) + { + LocationTestResult result; + result.success = true; + result.selectedLocation = m_location; + result.candidates = m_locationCandidates; + result.hasAmbiguity = m_hasLocationAmbiguity; + invokeLocationTestCallback(result); + return; + } + + Logger::info(QStringLiteral("Weather geocoding succeeded: source=%1 http=%2") + .arg(QString::number(static_cast(m_location.source)), QString::number(httpStatus))); + startForecastRequest(); +} + +void WeatherManager::finishIpLocationRequest(QNetworkReply *reply) +{ + const QByteArray body = reply->readAll(); + if (reply->error() != QNetworkReply::NoError) + { + finishWithError(QStringLiteral("公网 IP 定位失败:%1。").arg(reply->errorString())); + return; + } + + QJsonParseError parseError; + const QJsonDocument document = QJsonDocument::fromJson(body, &parseError); + if (parseError.error != QJsonParseError::NoError || !document.isObject()) + { + finishWithError(QStringLiteral("公网 IP 定位结果格式无效。")); + return; + } + + const QJsonObject root = document.object(); + const bool hasLatitude = jsonDouble(root, QStringLiteral("latitude"), &m_location.latitude); + const bool hasLongitude = jsonDouble(root, QStringLiteral("longitude"), &m_location.longitude); + m_location.cityName = root.value(QStringLiteral("city")).toString(); + m_location.adminName = root.value(QStringLiteral("region")).toString(); + m_location.countryName = root.value(QStringLiteral("country_name")).toString(); + m_location.timezone = root.value(QStringLiteral("timezone")).toString(); + m_location.source = WeatherLocationSource::IpFallback; + if (!hasLatitude || !hasLongitude || m_location.cityName.trimmed().isEmpty()) + { + finishWithError(QStringLiteral("公网 IP 定位没有返回有效城市或经纬度。")); + return; + } + + Logger::info(QStringLiteral("Weather IP location succeeded.")); + startForecastRequest(); +} + +void WeatherManager::finishForecastRequest(QNetworkReply *reply) +{ + const QByteArray body = reply->readAll(); + if (reply->error() != QNetworkReply::NoError) + { + finishWithError(QStringLiteral("天气数据请求失败:%1。").arg(reply->errorString())); + return; + } + + QJsonParseError parseError; + const QJsonDocument document = QJsonDocument::fromJson(body, &parseError); + if (parseError.error != QJsonParseError::NoError || !document.isObject()) + { + finishWithError(QStringLiteral("天气数据格式无效。")); + return; + } + + const QJsonObject root = document.object(); + WeatherReport report; + report.query = m_query; + report.location = m_location; + report.dataSourceName = QStringLiteral("Open-Meteo"); + report.locationCandidates = m_locationCandidates; + report.hasLocationAmbiguity = m_hasLocationAmbiguity; + + const QJsonObject currentObject = root.value(QStringLiteral("current")).toObject(); + if (!currentObject.isEmpty()) + { + report.current.valid = true; + report.current.time = dateTimeFromOpenMeteo(currentObject.value(QStringLiteral("time")).toString()); + report.current.hasTemperature = jsonDouble(currentObject, QStringLiteral("temperature_2m"), &report.current.temperatureC); + report.current.hasApparentTemperature = jsonDouble(currentObject, QStringLiteral("apparent_temperature"), &report.current.apparentTemperatureC); + report.current.hasHumidity = jsonDouble(currentObject, QStringLiteral("relative_humidity_2m"), &report.current.humidityPercent); + report.current.hasWindSpeed = jsonDouble(currentObject, QStringLiteral("wind_speed_10m"), &report.current.windSpeedKmh); + report.current.hasWindDirection = jsonDouble(currentObject, QStringLiteral("wind_direction_10m"), &report.current.windDirectionDegree); + report.current.hasPrecipitation = jsonDouble(currentObject, QStringLiteral("precipitation"), &report.current.precipitationMm); + jsonInt(currentObject, QStringLiteral("weather_code"), &report.current.weatherCode); + } + + const QJsonObject dailyObject = root.value(QStringLiteral("daily")).toObject(); + const QJsonArray dates = arrayValue(dailyObject, QStringLiteral("time")); + const QJsonArray codes = arrayValue(dailyObject, QStringLiteral("weather_code")); + const QJsonArray maxTemperatures = arrayValue(dailyObject, QStringLiteral("temperature_2m_max")); + const QJsonArray minTemperatures = arrayValue(dailyObject, QStringLiteral("temperature_2m_min")); + const QJsonArray precipitationProbabilities = arrayValue(dailyObject, QStringLiteral("precipitation_probability_max")); + for (int index = 0; index < dates.size(); ++index) + { + WeatherDailyForecast forecast; + forecast.date = QDate::fromString(dates.at(index).toString(), Qt::ISODate); + if (!forecast.date.isValid()) + { + continue; + } + + if (index < codes.size() && codes.at(index).isDouble()) + { + forecast.weatherCode = codes.at(index).toInt(); + } + if (index < maxTemperatures.size() && maxTemperatures.at(index).isDouble()) + { + forecast.temperatureMaxC = maxTemperatures.at(index).toDouble(); + forecast.hasTemperatureMax = true; + } + if (index < minTemperatures.size() && minTemperatures.at(index).isDouble()) + { + forecast.temperatureMinC = minTemperatures.at(index).toDouble(); + forecast.hasTemperatureMin = true; + } + if (index < precipitationProbabilities.size() && precipitationProbabilities.at(index).isDouble()) + { + forecast.precipitationProbabilityPercent = precipitationProbabilities.at(index).toDouble(); + forecast.hasPrecipitationProbability = true; + } + report.dailyForecasts.append(forecast); + } + + if (!report.current.valid && report.dailyForecasts.isEmpty()) + { + finishWithError(QStringLiteral("天气数据缺少当前天气和未来预报。")); + return; + } + + finishWithReport(report); +} + +void WeatherManager::startRequest(const QUrl &url, std::function finishedHandler) +{ + clearReply(); + + QNetworkRequest request(url); + request.setRawHeader("User-Agent", "QtDesktopPet/0.1"); + Logger::info(QStringLiteral("Weather request started: host=%1 path=%2").arg(url.host(), url.path())); + + m_currentReply = m_networkManager.get(request); + m_replyFinishedConnection = QObject::connect(m_currentReply, &QNetworkReply::finished, [this, finishedHandler = std::move(finishedHandler)]() { + if (m_currentReply.isNull()) + { + return; + } + + QNetworkReply *reply = m_currentReply; + m_currentReply = nullptr; + m_timeoutTimer.stop(); + QObject::disconnect(m_replyFinishedConnection); + m_replyFinishedConnection = {}; + finishedHandler(reply); + if (reply != nullptr) + { + reply->deleteLater(); + } + }); + + m_timeoutTimer.start(m_config.timeoutMs); +} + +void WeatherManager::finishWithError(const QString &message) +{ + clearReply(); + WeatherQueryResult result; + result.success = false; + result.errorMessage = message.trimmed().isEmpty() ? QStringLiteral("天气查询失败。") : message.trimmed(); + invokeCallback(result); +} + +void WeatherManager::finishGeocodingWithError(const QString &message) +{ + if (m_locationTestOnly) + { + finishLocationTestWithError(message); + return; + } + + finishWithError(message); +} + +void WeatherManager::finishLocationTestWithError(const QString &message) +{ + clearReply(); + LocationTestResult result; + result.success = false; + result.errorMessage = message.trimmed().isEmpty() ? QStringLiteral("城市测试失败。") : message.trimmed(); + invokeLocationTestCallback(result); +} + +void WeatherManager::finishWithReport(const WeatherReport &report) +{ + clearReply(); + WeatherQueryResult result; + result.success = true; + result.report = report; + result.message = m_formatter.format(report); + invokeCallback(result); +} + +void WeatherManager::clearReply() +{ + m_timeoutTimer.stop(); + if (m_replyFinishedConnection) + { + QObject::disconnect(m_replyFinishedConnection); + m_replyFinishedConnection = {}; + } + + QPointer reply = m_currentReply; + m_currentReply = nullptr; + if (!reply.isNull()) + { + reply->abort(); + reply->deleteLater(); + } +} + +void WeatherManager::invokeCallback(const WeatherQueryResult &result) +{ + m_busy = false; + QueryCallback callback = std::move(m_callback); + m_callback = nullptr; + if (callback) + { + callback(result); + } +} + +void WeatherManager::invokeLocationTestCallback(const LocationTestResult &result) +{ + m_busy = false; + m_locationTestOnly = false; + LocationTestCallback callback = std::move(m_locationTestCallback); + m_locationTestCallback = nullptr; + if (callback) + { + callback(result); + } +} diff --git a/src/weather/WeatherManager.h b/src/weather/WeatherManager.h new file mode 100644 index 0000000..450a072 --- /dev/null +++ b/src/weather/WeatherManager.h @@ -0,0 +1,63 @@ +#pragma once + +#include "WeatherParser.h" +#include "WeatherSummaryFormatter.h" +#include "WeatherTypes.h" + +#include +#include +#include +#include +#include + +#include + +class QNetworkReply; +class QUrl; + +class WeatherManager +{ +public: + using QueryCallback = std::function; + using LocationTestCallback = std::function; + + WeatherManager(); + ~WeatherManager(); + + bool isBusy() const; + void queryWeather(const QString &text, const WeatherConfig &config, QueryCallback callback); + void resolveLocationForTest(const QString &cityName, const WeatherConfig &config, LocationTestCallback callback); + void cancel(); + +private: + void startGeocodingRequest(const QString &cityName, WeatherLocationSource source, bool locationTestOnly); + void startIpLocationRequest(); + void startForecastRequest(); + void finishGeocodingRequest(QNetworkReply *reply); + void finishIpLocationRequest(QNetworkReply *reply); + void finishForecastRequest(QNetworkReply *reply); + void startRequest(const QUrl &url, std::function finishedHandler); + void finishGeocodingWithError(const QString &message); + void finishWithError(const QString &message); + void finishLocationTestWithError(const QString &message); + void finishWithReport(const WeatherReport &report); + void clearReply(); + void invokeCallback(const WeatherQueryResult &result); + void invokeLocationTestCallback(const LocationTestResult &result); + + WeatherParser m_parser; + WeatherSummaryFormatter m_formatter; + QNetworkAccessManager m_networkManager; + QPointer m_currentReply; + QMetaObject::Connection m_replyFinishedConnection; + QTimer m_timeoutTimer; + QueryCallback m_callback; + LocationTestCallback m_locationTestCallback; + WeatherConfig m_config; + WeatherQuery m_query; + WeatherLocation m_location; + QVector m_locationCandidates; + bool m_locationTestOnly = false; + bool m_hasLocationAmbiguity = false; + bool m_busy = false; +}; diff --git a/src/weather/WeatherParser.cpp b/src/weather/WeatherParser.cpp new file mode 100644 index 0000000..deb79a7 --- /dev/null +++ b/src/weather/WeatherParser.cpp @@ -0,0 +1,176 @@ +#include "WeatherParser.h" + +#include +#include +#include + +namespace +{ +QString simplifiedDigits(QString text) +{ + const QList> replacements = { + {QStringLiteral("一"), QStringLiteral("1")}, + {QStringLiteral("二"), QStringLiteral("2")}, + {QStringLiteral("两"), QStringLiteral("2")}, + {QStringLiteral("三"), QStringLiteral("3")}, + }; + for (const auto &replacement : replacements) + { + text.replace(replacement.first, replacement.second); + } + return text; +} + +void removeAll(QString *text, const QStringList &patterns) +{ + for (const QString &pattern : patterns) + { + text->remove(pattern, Qt::CaseInsensitive); + } +} + +QString extractedCityName(QString text) +{ + text = simplifiedDigits(text.trimmed()); + text.remove(QRegularExpression(QStringLiteral("[,。!?、,.!?;;::]+"))); + text.replace(QRegularExpression(QStringLiteral("\\s+")), QStringLiteral(" ")); + + removeAll(&text, { + QStringLiteral("请问"), + QStringLiteral("帮我"), + QStringLiteral("帮忙"), + QStringLiteral("查询"), + QStringLiteral("查一下"), + QStringLiteral("查查"), + QStringLiteral("看看"), + QStringLiteral("看一下"), + QStringLiteral("搜索一下"), + QStringLiteral("搜一下"), + QStringLiteral("一下"), + QStringLiteral("今天"), + QStringLiteral("现在"), + QStringLiteral("当前"), + QStringLiteral("实时"), + QStringLiteral("明天"), + QStringLiteral("后天"), + QStringLiteral("未来1天"), + QStringLiteral("未来2天"), + QStringLiteral("未来3天"), + QStringLiteral("未来一天"), + QStringLiteral("未来二天"), + QStringLiteral("未来两天"), + QStringLiteral("未来三天"), + QStringLiteral("天气怎么样"), + QStringLiteral("天气如何"), + QStringLiteral("天气"), + QStringLiteral("气温"), + QStringLiteral("温度"), + QStringLiteral("冷不冷"), + QStringLiteral("热不热"), + QStringLiteral("会不会下雨"), + QStringLiteral("会下雨吗"), + QStringLiteral("下雨吗"), + QStringLiteral("下雨"), + QStringLiteral("降雨"), + QStringLiteral("降水"), + QStringLiteral("带伞"), + QStringLiteral("风力"), + QStringLiteral("风速"), + QStringLiteral("湿度"), + QStringLiteral("空气质量"), + QStringLiteral("AQI"), + QStringLiteral("aqi"), + QStringLiteral("预警"), + QStringLiteral("穿衣指数"), + QStringLiteral("穿什么"), + QStringLiteral("适合出门吗"), + QStringLiteral("适合出门"), + QStringLiteral("外面怎么样"), + QStringLiteral("怎么样"), + QStringLiteral("如何"), + QStringLiteral("吗"), + QStringLiteral("呢"), + QStringLiteral("的"), + }); + + text = text.trimmed(); + if (!text.contains(QRegularExpression(QStringLiteral("[A-Za-z]")))) + { + text.remove(QChar(' ')); + } + return text; +} + +int requestedRangeDays(const QString &text) +{ + QString normalized = simplifiedDigits(text); + const QRegularExpression rangeExpression(QStringLiteral("未来\\s*([123])\\s*天")); + const QRegularExpressionMatch match = rangeExpression.match(normalized); + if (match.hasMatch()) + { + return match.captured(1).toInt(); + } + return 0; +} +} + +WeatherQuery WeatherParser::parse(const QString &text) const +{ + WeatherQuery query; + query.originalText = text.trimmed(); + if (query.originalText.isEmpty()) + { + query.unsupportedReason = QStringLiteral("天气查询内容为空。"); + return query; + } + + if (query.originalText.contains(QStringLiteral("空气质量"), Qt::CaseInsensitive) + || query.originalText.contains(QStringLiteral("AQI"), Qt::CaseInsensitive)) + { + query.unsupportedReason = QStringLiteral("天气 v1 暂不支持空气质量查询,目前只能查询基础天气。"); + return query; + } + + if (query.originalText.contains(QStringLiteral("预警"), Qt::CaseInsensitive)) + { + query.unsupportedReason = QStringLiteral("天气 v1 暂不支持天气预警查询,目前只能查询基础天气。"); + return query; + } + + if (query.originalText.contains(QStringLiteral("穿衣指数"), Qt::CaseInsensitive) + || query.originalText.contains(QStringLiteral("穿什么"), Qt::CaseInsensitive)) + { + query.unsupportedReason = QStringLiteral("天气 v1 暂不支持穿衣指数查询,目前只能查询基础天气。"); + return query; + } + + const int rangeDays = requestedRangeDays(query.originalText); + if (rangeDays > 0) + { + query.kind = WeatherQueryKind::Range; + query.forecastDays = rangeDays; + query.dateOffset = 0; + } + else if (query.originalText.contains(QStringLiteral("后天"), Qt::CaseInsensitive)) + { + query.kind = WeatherQueryKind::Daily; + query.dateOffset = 2; + query.forecastDays = 1; + } + else if (query.originalText.contains(QStringLiteral("明天"), Qt::CaseInsensitive)) + { + query.kind = WeatherQueryKind::Daily; + query.dateOffset = 1; + query.forecastDays = 1; + } + else if (query.originalText.contains(QStringLiteral("今天"), Qt::CaseInsensitive)) + { + query.kind = WeatherQueryKind::Daily; + query.dateOffset = 0; + query.forecastDays = 1; + } + + query.cityName = extractedCityName(query.originalText); + query.forecastDays = qBound(1, query.forecastDays, 3); + return query; +} diff --git a/src/weather/WeatherParser.h b/src/weather/WeatherParser.h new file mode 100644 index 0000000..73a1ef8 --- /dev/null +++ b/src/weather/WeatherParser.h @@ -0,0 +1,11 @@ +#pragma once + +#include "WeatherTypes.h" + +#include + +class WeatherParser +{ +public: + WeatherQuery parse(const QString &text) const; +}; diff --git a/src/weather/WeatherStore.cpp b/src/weather/WeatherStore.cpp new file mode 100644 index 0000000..483c6af --- /dev/null +++ b/src/weather/WeatherStore.cpp @@ -0,0 +1,192 @@ +#include "WeatherStore.h" + +#include "../util/Logger.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +constexpr int MinimumWeatherTimeoutMs = 3000; +constexpr int MaximumWeatherTimeoutMs = 60000; +const QString WeatherConfigFileName = QStringLiteral("weather_config.json"); + +WeatherConfig normalizedConfig(WeatherConfig config) +{ + config.provider = config.provider.trimmed().toLower(); + if (config.provider.isEmpty()) + { + config.provider = QStringLiteral("open-meteo"); + } + + config.defaultCityName = config.defaultCityName.trimmed(); + config.language = config.language.trimmed().toLower(); + if (config.language.isEmpty()) + { + config.language = QStringLiteral("zh"); + } + config.timeoutMs = qBound(MinimumWeatherTimeoutMs, config.timeoutMs, MaximumWeatherTimeoutMs); + return config; +} + +QJsonObject objectFromConfig(const WeatherConfig &config) +{ + const WeatherConfig normalized = normalizedConfig(config); + QJsonObject root; + root.insert(QStringLiteral("provider"), normalized.provider); + root.insert(QStringLiteral("defaultCityName"), normalized.defaultCityName); + root.insert(QStringLiteral("autoLocateWhenNoDefault"), normalized.autoLocateWhenNoDefault); + root.insert(QStringLiteral("language"), normalized.language); + root.insert(QStringLiteral("timeoutMs"), normalized.timeoutMs); + return root; +} +} + +WeatherConfig WeatherStore::load(QString *errorMessage) const +{ + WeatherConfig config; + + QFile file(storePath()); + if (!file.exists()) + { + return config; + } + + if (!file.open(QIODevice::ReadOnly)) + { + if (errorMessage != nullptr) + { + *errorMessage = QStringLiteral("无法读取天气配置文件。"); + } + Logger::warning(QStringLiteral("Unable to read weather config.")); + return config; + } + + QJsonParseError parseError; + const QJsonDocument document = QJsonDocument::fromJson(file.readAll(), &parseError); + file.close(); + if (parseError.error != QJsonParseError::NoError || !document.isObject()) + { + backupBrokenConfig(storePath()); + if (errorMessage != nullptr) + { + *errorMessage = QStringLiteral("天气配置文件损坏,已备份并使用默认配置。"); + } + Logger::warning(QStringLiteral("Weather config is broken; default config will be used.")); + return config; + } + + const QJsonObject root = document.object(); + config.provider = root.value(QStringLiteral("provider")).toString(config.provider); + config.defaultCityName = root.value(QStringLiteral("defaultCityName")).toString(config.defaultCityName); + config.autoLocateWhenNoDefault = root.value(QStringLiteral("autoLocateWhenNoDefault")).toBool(config.autoLocateWhenNoDefault); + config.language = root.value(QStringLiteral("language")).toString(config.language); + config.timeoutMs = root.value(QStringLiteral("timeoutMs")).toInt(config.timeoutMs); + return normalizedConfig(config); +} + +bool WeatherStore::save(const WeatherConfig &config, QString *errorMessage) const +{ + QDir directory(configDirectoryPath()); + if (!directory.exists() && !directory.mkpath(QStringLiteral("."))) + { + if (errorMessage != nullptr) + { + *errorMessage = QStringLiteral("无法创建天气配置目录。"); + } + Logger::warning(QStringLiteral("Unable to create weather config directory.")); + return false; + } + + const QByteArray payload = QJsonDocument(objectFromConfig(config)).toJson(QJsonDocument::Indented); + QSaveFile file(storePath()); + if (!file.open(QIODevice::WriteOnly)) + { + if (errorMessage != nullptr) + { + *errorMessage = QStringLiteral("无法写入天气配置文件。"); + } + Logger::warning(QStringLiteral("Unable to open weather config for writing.")); + return false; + } + + const qint64 written = file.write(payload); + if (written != payload.size()) + { + file.cancelWriting(); + if (errorMessage != nullptr) + { + *errorMessage = QStringLiteral("写入天气配置文件不完整。"); + } + Logger::warning(QStringLiteral("Weather config write was incomplete.")); + return false; + } + + if (!file.commit()) + { + if (errorMessage != nullptr) + { + *errorMessage = QStringLiteral("提交天气配置文件失败。"); + } + Logger::warning(QStringLiteral("Unable to commit weather config.")); + return false; + } + + return true; +} + +QString WeatherStore::storePath() const +{ + return QDir(configDirectoryPath()).filePath(WeatherConfigFileName); +} + +QString WeatherStore::configDirectoryPath() const +{ + const QString path = QStandardPaths::writableLocation(QStandardPaths::AppConfigLocation); + if (!path.isEmpty()) + { + return path; + } + + return QDir::currentPath(); +} + +void WeatherStore::backupBrokenConfig(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("weather_config.broken.") + timestamp + QStringLiteral(".json")); + int suffix = 1; + while (QFile::exists(backupPath)) + { + backupPath = fileInfo.dir().filePath( + QStringLiteral("weather_config.broken.") + + timestamp + + QStringLiteral("-") + + QString::number(suffix) + + QStringLiteral(".json")); + ++suffix; + } + + if (file.rename(backupPath)) + { + Logger::warning(QStringLiteral("Broken weather config was backed up: %1").arg(backupPath)); + return; + } + + Logger::warning(QStringLiteral("Failed to back up broken weather config: %1").arg(filePath)); +} diff --git a/src/weather/WeatherStore.h b/src/weather/WeatherStore.h new file mode 100644 index 0000000..1574882 --- /dev/null +++ b/src/weather/WeatherStore.h @@ -0,0 +1,18 @@ +#pragma once + +#include "WeatherConfig.h" + +#include + +class WeatherStore +{ +public: + WeatherConfig load(QString *errorMessage = nullptr) const; + bool save(const WeatherConfig &config, QString *errorMessage = nullptr) const; + + QString storePath() const; + +private: + QString configDirectoryPath() const; + void backupBrokenConfig(const QString &filePath) const; +}; diff --git a/src/weather/WeatherSummaryFormatter.cpp b/src/weather/WeatherSummaryFormatter.cpp new file mode 100644 index 0000000..39b4a8f --- /dev/null +++ b/src/weather/WeatherSummaryFormatter.cpp @@ -0,0 +1,283 @@ +#include "WeatherSummaryFormatter.h" + +#include +#include + +namespace +{ +QString locationDisplayName(const WeatherLocation &location) +{ + QStringList parts; + if (!location.cityName.trimmed().isEmpty()) + { + parts.append(location.cityName.trimmed()); + } + if (!location.adminName.trimmed().isEmpty() && location.adminName.trimmed() != location.cityName.trimmed()) + { + parts.append(location.adminName.trimmed()); + } + if (!location.countryName.trimmed().isEmpty() && location.countryName.trimmed() != location.adminName.trimmed()) + { + parts.append(location.countryName.trimmed()); + } + return parts.isEmpty() ? QStringLiteral("当前位置") : parts.join(QStringLiteral(",")); +} + +QString candidateDisplayName(const WeatherLocationCandidate &candidate) +{ + QStringList parts; + if (!candidate.cityName.trimmed().isEmpty()) + { + parts.append(candidate.cityName.trimmed()); + } + if (!candidate.adminName.trimmed().isEmpty() && candidate.adminName.trimmed() != candidate.cityName.trimmed()) + { + parts.append(candidate.adminName.trimmed()); + } + if (!candidate.countryName.trimmed().isEmpty() && candidate.countryName.trimmed() != candidate.adminName.trimmed()) + { + parts.append(candidate.countryName.trimmed()); + } + return parts.isEmpty() ? QStringLiteral("未知地点") : parts.join(QStringLiteral(",")); +} + +QString sourcePrefix(const WeatherLocation &location) +{ + switch (location.source) + { + case WeatherLocationSource::SettingsDefault: + return QStringLiteral("使用设置页默认城市:%1。\n").arg(locationDisplayName(location)); + case WeatherLocationSource::IpFallback: + return QStringLiteral("根据公网 IP 判断城市为:%1。\n").arg(locationDisplayName(location)); + case WeatherLocationSource::ExplicitCity: + return {}; + } + + return {}; +} + +QString ambiguityPrefix(const WeatherReport &report) +{ + if (!report.hasLocationAmbiguity || report.locationCandidates.size() <= 1) + { + return {}; + } + + QStringList otherCandidates; + const int maxOtherCandidateCount = qMin(3, report.locationCandidates.size() - 1); + for (int index = 1; index <= maxOtherCandidateCount; ++index) + { + otherCandidates.append(candidateDisplayName(report.locationCandidates.at(index))); + } + + if (otherCandidates.isEmpty()) + { + return QStringLiteral("可能存在同名城市,当前使用:%1。\n").arg(locationDisplayName(report.location)); + } + + return QStringLiteral("可能存在同名城市,当前使用:%1。其他候选包括:%2。\n") + .arg(locationDisplayName(report.location), otherCandidates.join(QStringLiteral(";"))); +} + +QString temperatureText(double value) +{ + return QStringLiteral("%1℃").arg(QString::number(value, 'f', 1)); +} + +QString currentText(const WeatherReport &report) +{ + const WeatherCurrent ¤t = report.current; + QStringList details; + if (current.hasTemperature) + { + details.append(QStringLiteral("温度 %1").arg(temperatureText(current.temperatureC))); + } + if (current.hasApparentTemperature) + { + details.append(QStringLiteral("体感 %1").arg(temperatureText(current.apparentTemperatureC))); + } + if (current.hasHumidity) + { + details.append(QStringLiteral("湿度 %1%").arg(QString::number(current.humidityPercent, 'f', 0))); + } + if (current.hasWindSpeed) + { + const QString direction = current.hasWindDirection + ? WeatherSummaryFormatter::windDirectionText(current.windDirectionDegree) + : QStringLiteral("风"); + details.append(QStringLiteral("%1 %2 km/h").arg(direction, QString::number(current.windSpeedKmh, 'f', 1))); + } + if (current.hasPrecipitation) + { + details.append(QStringLiteral("降水 %1 mm").arg(QString::number(current.precipitationMm, 'f', 1))); + } + + QString text = QStringLiteral("%1当前天气:%2") + .arg(locationDisplayName(report.location), WeatherSummaryFormatter::weatherCodeText(current.weatherCode)); + if (!details.isEmpty()) + { + text += QStringLiteral(",") + details.join(QStringLiteral(",")); + } + if (current.time.isValid()) + { + text += QStringLiteral("。更新时间:%1").arg(current.time.toString(QStringLiteral("HH:mm"))); + } + text += QStringLiteral("。"); + return text; +} + +QString dailyText(const WeatherDailyForecast &daily, const QString &prefix) +{ + QStringList details; + if (daily.hasTemperatureMax && daily.hasTemperatureMin) + { + details.append(QStringLiteral("%1-%2").arg(temperatureText(daily.temperatureMinC), temperatureText(daily.temperatureMaxC))); + } + else if (daily.hasTemperatureMax) + { + details.append(QStringLiteral("最高 %1").arg(temperatureText(daily.temperatureMaxC))); + } + else if (daily.hasTemperatureMin) + { + details.append(QStringLiteral("最低 %1").arg(temperatureText(daily.temperatureMinC))); + } + if (daily.hasPrecipitationProbability) + { + details.append(QStringLiteral("降水概率 %1%").arg(QString::number(daily.precipitationProbabilityPercent, 'f', 0))); + } + + QString text = QStringLiteral("%1:%2").arg(prefix, WeatherSummaryFormatter::weatherCodeText(daily.weatherCode)); + if (!details.isEmpty()) + { + text += QStringLiteral(",") + details.join(QStringLiteral(",")); + } + return text; +} + +const WeatherDailyForecast *forecastForOffset(const QVector &forecasts, int offset) +{ + if (offset < 0 || offset >= forecasts.size()) + { + return nullptr; + } + return &forecasts[offset]; +} +} + +QString WeatherSummaryFormatter::format(const WeatherReport &report) const +{ + QString message = sourcePrefix(report.location) + ambiguityPrefix(report); + if (report.query.kind == WeatherQueryKind::Current) + { + if (!report.current.valid) + { + return message + QStringLiteral("天气数据缺少当前天气,暂时无法生成结果。"); + } + return message + currentText(report); + } + + if (report.query.kind == WeatherQueryKind::Daily) + { + const WeatherDailyForecast *daily = forecastForOffset(report.dailyForecasts, report.query.dateOffset); + if (daily == nullptr) + { + return message + QStringLiteral("天气数据缺少目标日期预报,暂时无法生成结果。"); + } + const QString dayName = report.query.dateOffset == 0 + ? QStringLiteral("今天") + : (report.query.dateOffset == 1 ? QStringLiteral("明天") : QStringLiteral("后天")); + return message + QStringLiteral("%1%2天气:%3。") + .arg(locationDisplayName(report.location), dayName, dailyText(*daily, daily->date.toString(QStringLiteral("MM-dd"))).section(QStringLiteral(":"), 1)); + } + + QStringList lines; + const int count = qMin(report.query.forecastDays, report.dailyForecasts.size()); + for (int index = 0; index < count; ++index) + { + const WeatherDailyForecast &daily = report.dailyForecasts.at(index); + lines.append(dailyText(daily, daily.date.toString(QStringLiteral("MM-dd")))); + } + + if (lines.isEmpty()) + { + return message + QStringLiteral("天气数据缺少未来预报,暂时无法生成结果。"); + } + + return message + + QStringLiteral("%1未来 %2 天天气:\n%3。") + .arg(locationDisplayName(report.location), QString::number(count), lines.join(QStringLiteral("\n"))); +} + +QString WeatherSummaryFormatter::weatherCodeText(int code) +{ + switch (code) + { + case 0: + return QStringLiteral("晴"); + case 1: + return QStringLiteral("大部晴朗"); + case 2: + return QStringLiteral("局部多云"); + case 3: + return QStringLiteral("阴"); + case 45: + case 48: + return QStringLiteral("雾"); + case 51: + case 53: + case 55: + return QStringLiteral("毛毛雨"); + case 56: + case 57: + return QStringLiteral("冻毛毛雨"); + case 61: + return QStringLiteral("小雨"); + case 63: + return QStringLiteral("中雨"); + case 65: + return QStringLiteral("大雨"); + case 66: + case 67: + return QStringLiteral("冻雨"); + case 71: + return QStringLiteral("小雪"); + case 73: + return QStringLiteral("中雪"); + case 75: + return QStringLiteral("大雪"); + case 77: + return QStringLiteral("雪粒"); + case 80: + return QStringLiteral("小阵雨"); + case 81: + return QStringLiteral("中阵雨"); + case 82: + return QStringLiteral("强阵雨"); + case 85: + case 86: + return QStringLiteral("阵雪"); + case 95: + return QStringLiteral("雷暴"); + case 96: + case 99: + return QStringLiteral("雷暴伴冰雹"); + default: + return QStringLiteral("未知天气"); + } +} + +QString WeatherSummaryFormatter::windDirectionText(double degree) +{ + const QStringList directions = { + QStringLiteral("北风"), + QStringLiteral("东北风"), + QStringLiteral("东风"), + QStringLiteral("东南风"), + QStringLiteral("南风"), + QStringLiteral("西南风"), + QStringLiteral("西风"), + QStringLiteral("西北风"), + }; + const int index = static_cast(qRound(degree / 45.0)) % directions.size(); + return directions.at(index); +} diff --git a/src/weather/WeatherSummaryFormatter.h b/src/weather/WeatherSummaryFormatter.h new file mode 100644 index 0000000..8cef78a --- /dev/null +++ b/src/weather/WeatherSummaryFormatter.h @@ -0,0 +1,14 @@ +#pragma once + +#include "WeatherTypes.h" + +#include + +class WeatherSummaryFormatter +{ +public: + QString format(const WeatherReport &report) const; + + static QString weatherCodeText(int code); + static QString windDirectionText(double degree); +}; diff --git a/src/weather/WeatherTypes.h b/src/weather/WeatherTypes.h new file mode 100644 index 0000000..af0bb3c --- /dev/null +++ b/src/weather/WeatherTypes.h @@ -0,0 +1,113 @@ +#pragma once + +#include "WeatherConfig.h" + +#include +#include +#include +#include + +enum class WeatherLocationSource +{ + ExplicitCity, + SettingsDefault, + IpFallback, +}; + +enum class WeatherQueryKind +{ + Current, + Daily, + Range, +}; + +struct WeatherQuery +{ + QString originalText; + QString cityName; + WeatherLocationSource locationSource = WeatherLocationSource::ExplicitCity; + WeatherQueryKind kind = WeatherQueryKind::Current; + int dateOffset = 0; + int forecastDays = 1; + QString unsupportedReason; +}; + +struct WeatherLocation +{ + QString cityName; + QString adminName; + QString countryName; + QString timezone; + double latitude = 0.0; + double longitude = 0.0; + WeatherLocationSource source = WeatherLocationSource::ExplicitCity; +}; + +struct WeatherLocationCandidate +{ + QString cityName; + QString adminName; + QString countryName; + QString timezone; + double latitude = 0.0; + double longitude = 0.0; +}; + +struct WeatherCurrent +{ + bool valid = false; + QDateTime time; + int weatherCode = -1; + double temperatureC = 0.0; + bool hasTemperature = false; + double apparentTemperatureC = 0.0; + bool hasApparentTemperature = false; + double humidityPercent = 0.0; + bool hasHumidity = false; + double windSpeedKmh = 0.0; + bool hasWindSpeed = false; + double windDirectionDegree = 0.0; + bool hasWindDirection = false; + double precipitationMm = 0.0; + bool hasPrecipitation = false; +}; + +struct WeatherDailyForecast +{ + QDate date; + int weatherCode = -1; + double temperatureMaxC = 0.0; + bool hasTemperatureMax = false; + double temperatureMinC = 0.0; + bool hasTemperatureMin = false; + double precipitationProbabilityPercent = 0.0; + bool hasPrecipitationProbability = false; +}; + +struct WeatherReport +{ + WeatherQuery query; + WeatherLocation location; + WeatherCurrent current; + QVector dailyForecasts; + QVector locationCandidates; + QString dataSourceName = QStringLiteral("Open-Meteo"); + bool hasLocationAmbiguity = false; +}; + +struct WeatherQueryResult +{ + bool success = false; + QString message; + QString errorMessage; + WeatherReport report; +}; + +struct LocationTestResult +{ + bool success = false; + QString errorMessage; + WeatherLocation selectedLocation; + QVector candidates; + bool hasAmbiguity = false; +}; diff --git a/src/web/WebCapabilityDetector.cpp b/src/web/WebCapabilityDetector.cpp new file mode 100644 index 0000000..5d4c326 --- /dev/null +++ b/src/web/WebCapabilityDetector.cpp @@ -0,0 +1,155 @@ +#include "WebCapabilityDetector.h" + +#include + +namespace +{ +QString normalizedProviderName(const QString &provider) +{ + const QString normalized = provider.trimmed().toLower(); + return normalized.isEmpty() ? QStringLiteral("custom") : normalized; +} + +QString normalizedMode(const QString &mode) +{ + const QString normalized = mode.trimmed().toLower(); + if (normalized == QStringLiteral("openai") || normalized == QStringLiteral("gemini")) + { + return normalized; + } + return QStringLiteral("auto"); +} + +bool isOfficialOpenAIBaseUrl(const QString &baseUrl) +{ + const QUrl url(baseUrl.trimmed()); + return url.isValid() + && url.scheme().startsWith(QStringLiteral("http")) + && url.host().compare(QStringLiteral("api.openai.com"), Qt::CaseInsensitive) == 0; +} + +bool isGeminiProtocol(const AIConfig &config) +{ + return config.protocol == QStringLiteral("google-generative-language"); +} + +bool hasApiKeyReference(const AIConfig &config) +{ + return !config.apiKey.trimmed().isEmpty() + || !config.apiKeyEncrypted.trimmed().isEmpty(); +} + +WebCapability missingAIConfigCapability() +{ + return { + false, + WebProviderKind::None, + QStringLiteral("AI 未配置完整"), + QStringLiteral("请先在设置页配置当前 AI Provider 的 Base URL、Model 和 API Key。") + }; +} +} + +namespace WebCapabilityDetector +{ +QString providerKindName(WebProviderKind kind) +{ + switch (kind) + { + case WebProviderKind::OpenAIResponses: + return QStringLiteral("OpenAI Web Search"); + case WebProviderKind::GeminiGrounding: + return QStringLiteral("Gemini Google Search"); + case WebProviderKind::None: + return QStringLiteral("None"); + } + + return QStringLiteral("None"); +} + +WebCapability detect(const AIConfig &config, const WebConfig &webConfig) +{ + if (!webConfig.enabled) + { + return { + false, + WebProviderKind::None, + QStringLiteral("联网模式已关闭"), + QStringLiteral("联网模式已在设置页关闭。") + }; + } + + const QString provider = normalizedProviderName(config.provider); + const QString mode = normalizedMode(webConfig.providerMode); + + if ((mode == QStringLiteral("auto") || mode == QStringLiteral("openai")) + && provider == QStringLiteral("openai") + && config.protocol == QStringLiteral("openai-compatible")) + { + if (config.baseUrl.trimmed().isEmpty() || config.model.trimmed().isEmpty() || !hasApiKeyReference(config)) + { + return missingAIConfigCapability(); + } + + if (!isOfficialOpenAIBaseUrl(config.baseUrl)) + { + return { + false, + WebProviderKind::None, + QStringLiteral("OpenAI 联网仅支持官方 API"), + QStringLiteral("OpenAI 联网模式仅在 Base URL 为 https://api.openai.com 时可用。") + }; + } + + return { + true, + WebProviderKind::OpenAIResponses, + QStringLiteral("OpenAI 官方联网能力可用"), + QStringLiteral("当前 OpenAI 官方配置支持联网模式。") + }; + } + + if ((mode == QStringLiteral("auto") || mode == QStringLiteral("gemini")) + && isGeminiProtocol(config)) + { + if (config.baseUrl.trimmed().isEmpty() || config.model.trimmed().isEmpty() || !hasApiKeyReference(config)) + { + return missingAIConfigCapability(); + } + + return { + true, + WebProviderKind::GeminiGrounding, + QStringLiteral("Gemini 官方联网能力可用"), + QStringLiteral("当前 Gemini 配置支持联网模式。") + }; + } + + if (provider == QStringLiteral("deepseek")) + { + return { + false, + WebProviderKind::None, + QStringLiteral("DeepSeek 不支持原生联网"), + QStringLiteral("当前 DeepSeek 官方 API 暂不提供原生联网模式。") + }; + } + + if (provider == QStringLiteral("custom")) + { + return { + false, + WebProviderKind::None, + QStringLiteral("自定义 Provider 无法确认联网能力"), + QStringLiteral("当前自定义或第三方兼容 API 无法确认支持联网模式。") + }; + } + + return { + false, + WebProviderKind::None, + QStringLiteral("当前 AI 配置不支持联网模式"), + QStringLiteral("当前 AI 配置没有可用的原生联网功能。") + }; +} +} diff --git a/src/web/WebCapabilityDetector.h b/src/web/WebCapabilityDetector.h new file mode 100644 index 0000000..f6799e4 --- /dev/null +++ b/src/web/WebCapabilityDetector.h @@ -0,0 +1,11 @@ +#pragma once + +#include "../config/AIConfig.h" +#include "WebChatTypes.h" +#include "WebConfig.h" + +namespace WebCapabilityDetector +{ +WebCapability detect(const AIConfig &config, const WebConfig &webConfig = WebConfig()); +QString providerKindName(WebProviderKind kind); +} diff --git a/src/web/WebChatManager.cpp b/src/web/WebChatManager.cpp new file mode 100644 index 0000000..8a04456 --- /dev/null +++ b/src/web/WebChatManager.cpp @@ -0,0 +1,729 @@ +#include "WebChatManager.h" + +#include "../ai/AIDiagnostics.h" +#include "../ai/AIProviderFactory.h" +#include "../util/Logger.h" +#include "WebCapabilityDetector.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace +{ +QString normalizedGeminiModel(QString model) +{ + model = model.trimmed(); + if (model.startsWith(QStringLiteral("models/"))) + { + model.remove(0, QStringLiteral("models/").size()); + } + return model; +} + +QString geminiRole(const QString &role) +{ + if (role == QStringLiteral("assistant") || role == QStringLiteral("model")) + { + return QStringLiteral("model"); + } + return QStringLiteral("user"); +} + +QJsonObject textPart(const QString &text) +{ + QJsonObject part; + part.insert(QStringLiteral("text"), text); + return part; +} + +QString openAIResponsesUrl(const AIConfig &config) +{ + QString baseUrl = config.baseUrl.trimmed(); + while (baseUrl.endsWith(QLatin1Char('/'))) + { + baseUrl.chop(1); + } + + if (baseUrl.endsWith(QStringLiteral("/v1"))) + { + return baseUrl + QStringLiteral("/responses"); + } + + return baseUrl + QStringLiteral("/v1/responses"); +} + +QUrl geminiRequestUrl(const AIConfig &config) +{ + QString baseUrl = config.baseUrl.trimmed(); + QString path = config.path.trimmed(); + if (path.isEmpty()) + { + path = QStringLiteral("/v1beta/models/{model}:generateContent"); + } + + while (baseUrl.endsWith(QLatin1Char('/'))) + { + baseUrl.chop(1); + } + + if (!path.startsWith(QLatin1Char('/'))) + { + path.prepend(QLatin1Char('/')); + } + + if (path.contains(QStringLiteral(":streamGenerateContent"))) + { + path.replace(QStringLiteral(":streamGenerateContent"), QStringLiteral(":generateContent")); + } + + const QString model = QString::fromUtf8(QUrl::toPercentEncoding(normalizedGeminiModel(config.model))); + path.replace(QStringLiteral("{model}"), model); + return QUrl(baseUrl + path); +} + +QJsonObject openAIInputMessage(const ChatMessage &message) +{ + QJsonObject item; + item.insert(QStringLiteral("role"), message.role == QStringLiteral("assistant") ? QStringLiteral("assistant") : QStringLiteral("user")); + item.insert(QStringLiteral("content"), message.content); + return item; +} + +QJsonObject openAIPayload(const ChatRequest &request, const AIConfig &config) +{ + QStringList instructions; + QJsonArray input; + for (const ChatMessage &message : request.messages) + { + const QString content = message.content.trimmed(); + if (content.isEmpty()) + { + continue; + } + + if (message.role == QStringLiteral("system")) + { + instructions.append(content); + continue; + } + + input.append(openAIInputMessage(message)); + } + + QJsonArray tools; + QJsonObject webSearchTool; + webSearchTool.insert(QStringLiteral("type"), QStringLiteral("web_search")); + tools.append(webSearchTool); + + QJsonArray include; + include.append(QStringLiteral("web_search_call.action.sources")); + + QJsonObject payload; + payload.insert(QStringLiteral("model"), config.model); + payload.insert(QStringLiteral("input"), input); + payload.insert(QStringLiteral("tools"), tools); + payload.insert(QStringLiteral("tool_choice"), QStringLiteral("auto")); + payload.insert(QStringLiteral("include"), include); + payload.insert(QStringLiteral("temperature"), config.temperature); + payload.insert(QStringLiteral("max_output_tokens"), config.maxTokens); + if (!instructions.isEmpty()) + { + payload.insert(QStringLiteral("instructions"), instructions.join(QStringLiteral("\n\n"))); + } + return payload; +} + +QJsonObject geminiPayload(const ChatRequest &request, const AIConfig &config) +{ + QStringList systemParts; + QJsonArray contents; + + for (const ChatMessage &message : request.messages) + { + const QString content = message.content.trimmed(); + if (content.isEmpty()) + { + continue; + } + + if (message.role == QStringLiteral("system")) + { + systemParts.append(content); + continue; + } + + QJsonArray parts; + parts.append(textPart(content)); + + QJsonObject item; + item.insert(QStringLiteral("role"), geminiRole(message.role)); + item.insert(QStringLiteral("parts"), parts); + contents.append(item); + } + + QJsonArray tools; + QJsonObject googleSearchTool; + googleSearchTool.insert(QStringLiteral("google_search"), QJsonObject()); + tools.append(googleSearchTool); + + QJsonObject payload; + payload.insert(QStringLiteral("contents"), contents); + payload.insert(QStringLiteral("tools"), tools); + + if (!systemParts.isEmpty()) + { + QJsonArray parts; + parts.append(textPart(systemParts.join(QStringLiteral("\n\n")))); + + QJsonObject systemInstruction; + systemInstruction.insert(QStringLiteral("parts"), parts); + payload.insert(QStringLiteral("systemInstruction"), systemInstruction); + } + + QJsonObject generationConfig; + generationConfig.insert(QStringLiteral("temperature"), config.temperature); + generationConfig.insert(QStringLiteral("maxOutputTokens"), config.maxTokens); + payload.insert(QStringLiteral("generationConfig"), generationConfig); + return payload; +} + +QString textFromGeminiCandidate(const QJsonObject &candidate) +{ + const QJsonObject content = candidate.value(QStringLiteral("content")).toObject(); + const QJsonArray parts = content.value(QStringLiteral("parts")).toArray(); + + QString result; + for (const QJsonValue &partValue : parts) + { + if (!partValue.isObject()) + { + continue; + } + + const QString text = partValue.toObject().value(QStringLiteral("text")).toString(); + if (!text.isEmpty()) + { + result += text; + } + } + + return result; +} + +void appendCitation(QVector *citations, QSet *seenUrls, QString title, QString url, const QString &sourceName) +{ + if (citations == nullptr || seenUrls == nullptr) + { + return; + } + + title = title.trimmed(); + url = url.trimmed(); + if (url.isEmpty() || seenUrls->contains(url)) + { + return; + } + + WebCitation citation; + citation.index = citations->size() + 1; + citation.title = title.isEmpty() ? url : title; + citation.url = url; + citation.sourceName = sourceName; + citations->append(citation); + seenUrls->insert(url); +} + +void collectOpenAIAnnotations( + const QJsonArray &annotations, + QVector *citations, + QSet *seenUrls) +{ + for (const QJsonValue &annotationValue : annotations) + { + if (!annotationValue.isObject()) + { + continue; + } + + const QJsonObject annotation = annotationValue.toObject(); + const QString type = annotation.value(QStringLiteral("type")).toString(); + if (type != QStringLiteral("url_citation")) + { + continue; + } + + appendCitation( + citations, + seenUrls, + annotation.value(QStringLiteral("title")).toString(), + annotation.value(QStringLiteral("url")).toString(), + QStringLiteral("OpenAI Web Search")); + } +} + +void collectOpenAISourceObjects( + const QJsonValue &value, + QVector *citations, + QSet *seenUrls) +{ + if (value.isArray()) + { + const QJsonArray array = value.toArray(); + for (const QJsonValue &item : array) + { + collectOpenAISourceObjects(item, citations, seenUrls); + } + return; + } + + if (!value.isObject()) + { + return; + } + + const QJsonObject object = value.toObject(); + const auto appendSourceArray = [&object, citations, seenUrls](const QString &fieldName) { + const QJsonArray sources = object.value(fieldName).toArray(); + for (const QJsonValue &sourceValue : sources) + { + if (!sourceValue.isObject()) + { + continue; + } + + const QJsonObject source = sourceValue.toObject(); + QString url = source.value(QStringLiteral("url")).toString().trimmed(); + if (url.isEmpty()) + { + url = source.value(QStringLiteral("uri")).toString().trimmed(); + } + + appendCitation( + citations, + seenUrls, + source.value(QStringLiteral("title")).toString(), + url, + QStringLiteral("OpenAI Web Search")); + } + }; + + appendSourceArray(QStringLiteral("sources")); + appendSourceArray(QStringLiteral("results")); + + for (auto iterator = object.constBegin(); iterator != object.constEnd(); ++iterator) + { + collectOpenAISourceObjects(iterator.value(), citations, seenUrls); + } +} + +void collectGeminiGroundingCitations( + const QJsonObject &candidate, + QVector *citations, + QSet *seenUrls) +{ + const QJsonObject grounding = candidate.value(QStringLiteral("groundingMetadata")).toObject(); + const QJsonArray chunks = grounding.value(QStringLiteral("groundingChunks")).toArray(); + for (const QJsonValue &chunkValue : chunks) + { + if (!chunkValue.isObject()) + { + continue; + } + + const QJsonObject web = chunkValue.toObject().value(QStringLiteral("web")).toObject(); + appendCitation( + citations, + seenUrls, + web.value(QStringLiteral("title")).toString(), + web.value(QStringLiteral("uri")).toString(), + QStringLiteral("Google Search")); + } + + const QJsonObject citationMetadata = candidate.value(QStringLiteral("citationMetadata")).toObject(); + const QJsonArray citationSources = citationMetadata.value(QStringLiteral("citationSources")).toArray(); + for (const QJsonValue &sourceValue : citationSources) + { + if (!sourceValue.isObject()) + { + continue; + } + + const QJsonObject source = sourceValue.toObject(); + appendCitation( + citations, + seenUrls, + source.value(QStringLiteral("title")).toString(), + source.value(QStringLiteral("uri")).toString(), + QStringLiteral("Google Search")); + } +} +} + +WebChatManager::WebChatManager() +{ + m_timeoutTimer.setSingleShot(true); + QObject::connect(&m_timeoutTimer, &QTimer::timeout, [this]() { + finishWithError(QStringLiteral("联网请求超时。")); + }); +} + +WebChatManager::~WebChatManager() +{ + cancel(); +} + +bool WebChatManager::isBusy() const +{ + return !m_currentReply.isNull(); +} + +void WebChatManager::sendWebChat(const WebChatRequest &request, WebChatCallback callback) +{ + if (isBusy()) + { + if (callback) + { + callback({false, false, {}, QStringLiteral("联网请求正在进行,请稍后。"), {}, 0}); + } + return; + } + + m_callback = std::move(callback); + AIConfig runtimeConfig = request.aiConfig; + WebConfig webConfig = request.webConfig; + const WebCapability capability = WebCapabilityDetector::detect(runtimeConfig, webConfig); + if (!capability.supported) + { + invokeCallback({false, false, {}, capability.userMessage, {}, 0}); + return; + } + + QString errorMessage; + if (!AIProviderFactory::prepareRuntimeConfig(runtimeConfig, &errorMessage)) + { + invokeCallback({false, false, {}, errorMessage, {}, 0}); + return; + } + runtimeConfig.timeoutMs = qBound(3000, webConfig.timeoutMs, 120000); + + if (request.chatRequest.messages.isEmpty()) + { + invokeCallback({false, false, {}, QStringLiteral("联网请求内容为空。"), {}, 0}); + return; + } + + m_currentConfig = runtimeConfig; + m_currentKind = capability.kind; + + if (capability.kind == WebProviderKind::OpenAIResponses) + { + startOpenAIResponses({request.chatRequest, runtimeConfig, webConfig}); + return; + } + + if (capability.kind == WebProviderKind::GeminiGrounding) + { + startGeminiGrounding({request.chatRequest, runtimeConfig, webConfig}); + return; + } + + invokeCallback({false, false, {}, QStringLiteral("当前 AI 配置没有可用的原生联网功能。"), {}, 0}); +} + +void WebChatManager::cancel() +{ + m_callback = nullptr; + QPointer reply = m_currentReply; + if (!reply.isNull()) + { + Logger::info(QStringLiteral("Web chat request canceled: %1") + .arg(AIDiagnostics::diagnosticContext(m_currentConfig, reply->request().url()))); + } + clearReply(); + + if (!reply.isNull()) + { + reply->abort(); + } +} + +void WebChatManager::startOpenAIResponses(const WebChatRequest &request) +{ + const QUrl url(openAIResponsesUrl(request.aiConfig)); + const QByteArray payload = QJsonDocument(openAIPayload(request.chatRequest, request.aiConfig)).toJson(QJsonDocument::Compact); + startRequest(url, payload, request.chatRequest.messages.size(), QByteArray("Bearer ") + request.aiConfig.apiKey.toUtf8()); +} + +void WebChatManager::startGeminiGrounding(const WebChatRequest &request) +{ + const QUrl url = geminiRequestUrl(request.aiConfig); + const QByteArray payload = QJsonDocument(geminiPayload(request.chatRequest, request.aiConfig)).toJson(QJsonDocument::Compact); + startRequest(url, payload, request.chatRequest.messages.size()); +} + +void WebChatManager::startRequest(const QUrl &url, const QByteArray &payload, int messageCount, const QByteArray &authorizationHeader) +{ + if (!url.isValid()) + { + invokeCallback({false, false, {}, QStringLiteral("联网请求 URL 无效。"), {}, 0}); + return; + } + + QNetworkRequest networkRequest(url); + networkRequest.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/json")); + if (!authorizationHeader.isEmpty()) + { + networkRequest.setRawHeader("Authorization", authorizationHeader); + } + if (m_currentKind == WebProviderKind::GeminiGrounding) + { + networkRequest.setRawHeader("x-goog-api-key", m_currentConfig.apiKey.toUtf8()); + } + + Logger::info(QStringLiteral("Web chat request started: %1 kind=%2 messageCount=%3 payloadBytes=%4") + .arg(AIDiagnostics::diagnosticContext(m_currentConfig, url)) + .arg(WebCapabilityDetector::providerKindName(m_currentKind)) + .arg(QString::number(messageCount)) + .arg(QString::number(payload.size()))); + + m_currentReply = m_networkManager.post(networkRequest, payload); + m_replyFinishedConnection = QObject::connect(m_currentReply, &QNetworkReply::finished, [this]() { + finishReply(); + }); + m_timeoutTimer.start(qBound(3000, m_currentConfig.timeoutMs, 120000)); +} + +void WebChatManager::finishReply() +{ + if (m_currentReply.isNull()) + { + return; + } + + QNetworkReply *reply = m_currentReply; + const QByteArray body = reply->readAll(); + WebChatResponse response; + if (m_currentKind == WebProviderKind::OpenAIResponses) + { + response = parseOpenAIResponse(reply, body); + } + else if (m_currentKind == WebProviderKind::GeminiGrounding) + { + response = parseGeminiResponse(reply, body); + } + else + { + response = {false, false, {}, QStringLiteral("未知联网 Provider。"), {}, 0}; + } + + clearReply(); + invokeCallback(response); +} + +void WebChatManager::finishWithError(const QString &message, int httpStatus) +{ + QPointer reply = m_currentReply; + const QUrl url = reply.isNull() ? QUrl() : reply->request().url(); + Logger::warning(QStringLiteral("Web chat request finished with error: %1 httpStatus=%2 error=\"%3\"") + .arg(AIDiagnostics::diagnosticContext(m_currentConfig, url)) + .arg(httpStatus) + .arg(AIDiagnostics::safeTextSummary(message))); + + clearReply(); + if (!reply.isNull()) + { + reply->abort(); + } + + invokeCallback({false, false, {}, message, {}, httpStatus}); +} + +void WebChatManager::invokeCallback(const WebChatResponse &response) +{ + if (!m_callback) + { + return; + } + + const WebChatCallback callback = std::move(m_callback); + m_callback = nullptr; + callback(response); +} + +void WebChatManager::clearReply() +{ + m_timeoutTimer.stop(); + if (m_replyFinishedConnection) + { + QObject::disconnect(m_replyFinishedConnection); + m_replyFinishedConnection = {}; + } + + if (!m_currentReply.isNull()) + { + m_currentReply->deleteLater(); + m_currentReply.clear(); + } + m_currentKind = WebProviderKind::None; +} + +WebChatResponse WebChatManager::parseOpenAIResponse(QNetworkReply *reply, const QByteArray &body) const +{ + const int httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + if (reply->error() != QNetworkReply::NoError) + { + const QString bodyError = AIDiagnostics::errorMessageFromBody(body); + Logger::warning(QStringLiteral("Web chat OpenAI request network error: %1 qtError=%2 errorString=\"%3\" httpStatus=%4 bodySummary=\"%5\"") + .arg(AIDiagnostics::diagnosticContext(m_currentConfig, reply->request().url())) + .arg(static_cast(reply->error())) + .arg(AIDiagnostics::oneLine(reply->errorString())) + .arg(httpStatus) + .arg(AIDiagnostics::responseBodySummary(body))); + return {false, false, {}, bodyError.isEmpty() ? reply->errorString() : bodyError, {}, httpStatus}; + } + + QJsonParseError parseError; + const QJsonDocument document = QJsonDocument::fromJson(body, &parseError); + if (parseError.error != QJsonParseError::NoError || !document.isObject()) + { + Logger::warning(QStringLiteral("Web chat OpenAI response JSON parse failed: %1 parseError=\"%2\" bodySummary=\"%3\"") + .arg(AIDiagnostics::diagnosticContext(m_currentConfig, reply->request().url())) + .arg(AIDiagnostics::oneLine(parseError.errorString())) + .arg(AIDiagnostics::responseBodySummary(body))); + return {false, false, {}, QStringLiteral("联网响应不是有效 JSON。"), {}, httpStatus}; + } + + const QJsonObject root = document.object(); + if (root.contains(QStringLiteral("error"))) + { + const QString bodyError = AIDiagnostics::errorMessageFromBody(body); + return {false, false, {}, bodyError.isEmpty() ? QStringLiteral("联网响应返回错误。") : bodyError, {}, httpStatus}; + } + + QString content = root.value(QStringLiteral("output_text")).toString(); + QVector citations; + QSet seenUrls; + bool usedWeb = false; + + const QJsonArray output = root.value(QStringLiteral("output")).toArray(); + for (const QJsonValue &outputValue : output) + { + if (!outputValue.isObject()) + { + continue; + } + + const QJsonObject outputItem = outputValue.toObject(); + const QString type = outputItem.value(QStringLiteral("type")).toString(); + if (type.contains(QStringLiteral("web_search"), Qt::CaseInsensitive)) + { + usedWeb = true; + collectOpenAISourceObjects(outputItem, &citations, &seenUrls); + } + + const QJsonArray contentItems = outputItem.value(QStringLiteral("content")).toArray(); + for (const QJsonValue &contentValue : contentItems) + { + if (!contentValue.isObject()) + { + continue; + } + + const QJsonObject contentItem = contentValue.toObject(); + const QString text = contentItem.value(QStringLiteral("text")).toString(); + if (content.trimmed().isEmpty() && !text.isEmpty()) + { + content += text; + } + collectOpenAIAnnotations(contentItem.value(QStringLiteral("annotations")).toArray(), &citations, &seenUrls); + } + } + + usedWeb = usedWeb || !citations.isEmpty(); + if (content.trimmed().isEmpty()) + { + return {false, usedWeb, {}, QStringLiteral("联网响应内容为空。"), citations, httpStatus}; + } + + Logger::info(QStringLiteral("Web chat OpenAI request completed: %1 httpStatus=%2 responseChars=%3 citations=%4 usedWeb=%5") + .arg(AIDiagnostics::diagnosticContext(m_currentConfig, reply->request().url())) + .arg(httpStatus) + .arg(QString::number(content.size())) + .arg(QString::number(citations.size())) + .arg(usedWeb ? QStringLiteral("true") : QStringLiteral("false"))); + + return {true, usedWeb, content, {}, citations, httpStatus}; +} + +WebChatResponse WebChatManager::parseGeminiResponse(QNetworkReply *reply, const QByteArray &body) const +{ + const int httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + if (reply->error() != QNetworkReply::NoError) + { + const QString bodyError = AIDiagnostics::errorMessageFromBody(body); + Logger::warning(QStringLiteral("Web chat Gemini request network error: %1 qtError=%2 errorString=\"%3\" httpStatus=%4 bodySummary=\"%5\"") + .arg(AIDiagnostics::diagnosticContext(m_currentConfig, reply->request().url())) + .arg(static_cast(reply->error())) + .arg(AIDiagnostics::oneLine(reply->errorString())) + .arg(httpStatus) + .arg(AIDiagnostics::responseBodySummary(body))); + return {false, false, {}, bodyError.isEmpty() ? reply->errorString() : bodyError, {}, httpStatus}; + } + + QJsonParseError parseError; + const QJsonDocument document = QJsonDocument::fromJson(body, &parseError); + if (parseError.error != QJsonParseError::NoError || !document.isObject()) + { + Logger::warning(QStringLiteral("Web chat Gemini response JSON parse failed: %1 parseError=\"%2\" bodySummary=\"%3\"") + .arg(AIDiagnostics::diagnosticContext(m_currentConfig, reply->request().url())) + .arg(AIDiagnostics::oneLine(parseError.errorString())) + .arg(AIDiagnostics::responseBodySummary(body))); + return {false, false, {}, QStringLiteral("联网响应不是有效 JSON。"), {}, httpStatus}; + } + + const QJsonObject root = document.object(); + if (root.contains(QStringLiteral("error"))) + { + const QString bodyError = AIDiagnostics::errorMessageFromBody(body); + return {false, false, {}, bodyError.isEmpty() ? QStringLiteral("联网响应返回错误。") : bodyError, {}, httpStatus}; + } + + const QJsonArray candidates = root.value(QStringLiteral("candidates")).toArray(); + if (candidates.isEmpty() || !candidates.first().isObject()) + { + return {false, false, {}, QStringLiteral("Gemini 联网响应没有候选结果。"), {}, httpStatus}; + } + + const QJsonObject candidate = candidates.first().toObject(); + const QString content = textFromGeminiCandidate(candidate); + QVector citations; + QSet seenUrls; + collectGeminiGroundingCitations(candidate, &citations, &seenUrls); + + const bool usedWeb = candidate.contains(QStringLiteral("groundingMetadata")) || !citations.isEmpty(); + if (content.trimmed().isEmpty()) + { + return {false, usedWeb, {}, QStringLiteral("Gemini 联网响应内容为空。"), citations, httpStatus}; + } + + Logger::info(QStringLiteral("Web chat Gemini request completed: %1 httpStatus=%2 responseChars=%3 citations=%4 usedWeb=%5") + .arg(AIDiagnostics::diagnosticContext(m_currentConfig, reply->request().url())) + .arg(httpStatus) + .arg(QString::number(content.size())) + .arg(QString::number(citations.size())) + .arg(usedWeb ? QStringLiteral("true") : QStringLiteral("false"))); + + return {true, usedWeb, content, {}, citations, httpStatus}; +} diff --git a/src/web/WebChatManager.h b/src/web/WebChatManager.h new file mode 100644 index 0000000..baf92f3 --- /dev/null +++ b/src/web/WebChatManager.h @@ -0,0 +1,47 @@ +#pragma once + +#include "WebChatTypes.h" + +#include +#include +#include +#include +#include +#include + +#include + +class QNetworkReply; + +class WebChatManager +{ +public: + using WebChatCallback = std::function; + + WebChatManager(); + ~WebChatManager(); + + bool isBusy() const; + void sendWebChat(const WebChatRequest &request, WebChatCallback callback); + void cancel(); + +private: + void startOpenAIResponses(const WebChatRequest &request); + void startGeminiGrounding(const WebChatRequest &request); + void startRequest(const QUrl &url, const QByteArray &payload, int messageCount, const QByteArray &authorizationHeader = {}); + void finishReply(); + void finishWithError(const QString &message, int httpStatus = 0); + void invokeCallback(const WebChatResponse &response); + void clearReply(); + + WebChatResponse parseOpenAIResponse(QNetworkReply *reply, const QByteArray &body) const; + WebChatResponse parseGeminiResponse(QNetworkReply *reply, const QByteArray &body) const; + + QNetworkAccessManager m_networkManager; + QPointer m_currentReply; + QMetaObject::Connection m_replyFinishedConnection; + QTimer m_timeoutTimer; + WebChatCallback m_callback; + WebProviderKind m_currentKind = WebProviderKind::None; + AIConfig m_currentConfig; +}; diff --git a/src/web/WebChatTypes.h b/src/web/WebChatTypes.h new file mode 100644 index 0000000..f0714da --- /dev/null +++ b/src/web/WebChatTypes.h @@ -0,0 +1,48 @@ +#pragma once + +#include "../ai/LLMTypes.h" +#include "../config/AIConfig.h" +#include "WebConfig.h" + +#include +#include + +enum class WebProviderKind +{ + None, + OpenAIResponses, + GeminiGrounding, +}; + +struct WebCapability +{ + bool supported = false; + WebProviderKind kind = WebProviderKind::None; + QString statusText; + QString userMessage; +}; + +struct WebCitation +{ + int index = 0; + QString title; + QString url; + QString sourceName; +}; + +struct WebChatRequest +{ + ChatRequest chatRequest; + AIConfig aiConfig; + WebConfig webConfig; +}; + +struct WebChatResponse +{ + bool success = false; + bool usedWeb = false; + QString content; + QString errorMessage; + QVector citations; + int httpStatus = 0; +}; diff --git a/src/web/WebConfig.h b/src/web/WebConfig.h new file mode 100644 index 0000000..52260f3 --- /dev/null +++ b/src/web/WebConfig.h @@ -0,0 +1,14 @@ +#pragma once + +#include + +struct WebConfig +{ + bool enabled = true; + bool rememberLastToggle = true; + bool defaultToggleOn = false; + bool lastToggleOn = false; + QString providerMode = QStringLiteral("auto"); + int timeoutMs = 60000; + bool showCitations = true; +}; diff --git a/src/web/WebStore.cpp b/src/web/WebStore.cpp new file mode 100644 index 0000000..098115b --- /dev/null +++ b/src/web/WebStore.cpp @@ -0,0 +1,195 @@ +#include "WebStore.h" + +#include "../util/Logger.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +constexpr int MinimumWebTimeoutMs = 3000; +constexpr int MaximumWebTimeoutMs = 120000; +const QString WebConfigFileName = QStringLiteral("web_config.json"); + +QString normalizedProviderMode(const QString &mode) +{ + const QString normalized = mode.trimmed().toLower(); + if (normalized == QStringLiteral("openai") || normalized == QStringLiteral("gemini")) + { + return normalized; + } + return QStringLiteral("auto"); +} + +WebConfig normalizedConfig(WebConfig config) +{ + config.providerMode = normalizedProviderMode(config.providerMode); + config.timeoutMs = qBound(MinimumWebTimeoutMs, config.timeoutMs, MaximumWebTimeoutMs); + return config; +} + +QJsonObject objectFromConfig(const WebConfig &config) +{ + const WebConfig normalized = normalizedConfig(config); + QJsonObject root; + root.insert(QStringLiteral("enabled"), normalized.enabled); + root.insert(QStringLiteral("rememberLastToggle"), normalized.rememberLastToggle); + root.insert(QStringLiteral("defaultToggleOn"), normalized.defaultToggleOn); + root.insert(QStringLiteral("lastToggleOn"), normalized.lastToggleOn); + root.insert(QStringLiteral("providerMode"), normalized.providerMode); + root.insert(QStringLiteral("timeoutMs"), normalized.timeoutMs); + root.insert(QStringLiteral("showCitations"), normalized.showCitations); + return root; +} +} + +WebConfig WebStore::load(QString *errorMessage) const +{ + WebConfig config; + + QFile file(storePath()); + if (!file.exists()) + { + return config; + } + + if (!file.open(QIODevice::ReadOnly)) + { + if (errorMessage != nullptr) + { + *errorMessage = QStringLiteral("无法读取联网模式配置文件。"); + } + Logger::warning(QStringLiteral("Unable to read web config.")); + return config; + } + + QJsonParseError parseError; + const QJsonDocument document = QJsonDocument::fromJson(file.readAll(), &parseError); + file.close(); + if (parseError.error != QJsonParseError::NoError || !document.isObject()) + { + backupBrokenConfig(storePath()); + if (errorMessage != nullptr) + { + *errorMessage = QStringLiteral("联网模式配置文件损坏,已备份并使用默认配置。"); + } + Logger::warning(QStringLiteral("Web config is broken; default config will be used.")); + return config; + } + + const QJsonObject root = document.object(); + config.enabled = root.value(QStringLiteral("enabled")).toBool(config.enabled); + config.rememberLastToggle = root.value(QStringLiteral("rememberLastToggle")).toBool(config.rememberLastToggle); + config.defaultToggleOn = root.value(QStringLiteral("defaultToggleOn")).toBool(config.defaultToggleOn); + config.lastToggleOn = root.value(QStringLiteral("lastToggleOn")).toBool(config.lastToggleOn); + config.providerMode = root.value(QStringLiteral("providerMode")).toString(config.providerMode); + config.timeoutMs = root.value(QStringLiteral("timeoutMs")).toInt(config.timeoutMs); + config.showCitations = root.value(QStringLiteral("showCitations")).toBool(config.showCitations); + return normalizedConfig(config); +} + +bool WebStore::save(const WebConfig &config, QString *errorMessage) const +{ + QDir directory(configDirectoryPath()); + if (!directory.exists() && !directory.mkpath(QStringLiteral("."))) + { + if (errorMessage != nullptr) + { + *errorMessage = QStringLiteral("无法创建联网模式配置目录。"); + } + Logger::warning(QStringLiteral("Unable to create web config directory.")); + return false; + } + + const QByteArray payload = QJsonDocument(objectFromConfig(config)).toJson(QJsonDocument::Indented); + QSaveFile file(storePath()); + if (!file.open(QIODevice::WriteOnly)) + { + if (errorMessage != nullptr) + { + *errorMessage = QStringLiteral("无法写入联网模式配置文件。"); + } + Logger::warning(QStringLiteral("Unable to open web config for writing.")); + return false; + } + + const qint64 written = file.write(payload); + if (written != payload.size()) + { + file.cancelWriting(); + if (errorMessage != nullptr) + { + *errorMessage = QStringLiteral("写入联网模式配置文件不完整。"); + } + Logger::warning(QStringLiteral("Web config write was incomplete.")); + return false; + } + + if (!file.commit()) + { + if (errorMessage != nullptr) + { + *errorMessage = QStringLiteral("提交联网模式配置文件失败。"); + } + Logger::warning(QStringLiteral("Unable to commit web config.")); + return false; + } + + return true; +} + +QString WebStore::storePath() const +{ + return QDir(configDirectoryPath()).filePath(WebConfigFileName); +} + +QString WebStore::configDirectoryPath() const +{ + const QString path = QStandardPaths::writableLocation(QStandardPaths::AppConfigLocation); + if (!path.isEmpty()) + { + return path; + } + + return QDir::currentPath(); +} + +void WebStore::backupBrokenConfig(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("web_config.broken.") + timestamp + QStringLiteral(".json")); + int suffix = 1; + while (QFile::exists(backupPath)) + { + backupPath = fileInfo.dir().filePath( + QStringLiteral("web_config.broken.") + + timestamp + + QStringLiteral("-") + + QString::number(suffix) + + QStringLiteral(".json")); + ++suffix; + } + + if (file.rename(backupPath)) + { + Logger::warning(QStringLiteral("Broken web config was backed up: %1").arg(backupPath)); + return; + } + + Logger::warning(QStringLiteral("Failed to back up broken web config: %1").arg(filePath)); +} diff --git a/src/web/WebStore.h b/src/web/WebStore.h new file mode 100644 index 0000000..213d6dd --- /dev/null +++ b/src/web/WebStore.h @@ -0,0 +1,17 @@ +#pragma once + +#include "WebConfig.h" + +#include + +class WebStore +{ +public: + WebConfig load(QString *errorMessage = nullptr) const; + bool save(const WebConfig &config, QString *errorMessage = nullptr) const; + QString storePath() const; + +private: + QString configDirectoryPath() const; + void backupBrokenConfig(const QString &filePath) const; +}; diff --git a/tools/prepare_github_export.ps1 b/tools/prepare_github_export.ps1 new file mode 100644 index 0000000..01c19d9 --- /dev/null +++ b/tools/prepare_github_export.ps1 @@ -0,0 +1,146 @@ +param( + [Parameter(Mandatory = $true)] + [string]$OutputDir, + + [switch]$Force +) + +$ErrorActionPreference = "Stop" + +function Get-FullPath([string]$Path) { + if ([System.IO.Path]::IsPathRooted($Path)) { + return [System.IO.Path]::GetFullPath($Path) + } + + return [System.IO.Path]::GetFullPath((Join-Path (Get-Location) $Path)) +} + +function Is-SubPath([string]$Candidate, [string]$Parent) { + $candidateFull = [System.IO.Path]::GetFullPath($Candidate).TrimEnd('\', '/') + $parentFull = [System.IO.Path]::GetFullPath($Parent).TrimEnd('\', '/') + return $candidateFull.Equals($parentFull, [System.StringComparison]::OrdinalIgnoreCase) -or + $candidateFull.StartsWith($parentFull + [System.IO.Path]::DirectorySeparatorChar, [System.StringComparison]::OrdinalIgnoreCase) +} + +function Should-ExcludeDirectory([string]$RelativePath) { + $segments = @($RelativePath -split '[\\/]' | Where-Object { $_ -ne "" }) + if ($segments.Count -eq 0) { + return $false + } + + $first = $segments[0].ToLowerInvariant() + if ($first -in @( + ".git", + ".vs", + ".vscode", + ".idea", + ".agents", + ".codex", + "build", + "dist", + "release_packages", + "docs", + "reports", + "config", + "logs" + )) { + return $true + } + + return $first.StartsWith("cmake-build-") +} + +function Should-ExcludeFile([string]$RelativePath) { + $fileName = [System.IO.Path]::GetFileName($RelativePath).ToLowerInvariant() + if ($fileName -in @( + "readme.dev.md" + )) { + return $true + } + + foreach ($pattern in @( + "*.user", + "*.suo", + "*.vc.db", + "*.vc.opendb", + "*.autosave", + "*.broken.json", + "*.exe", + "*.dll", + "*.pdb", + "*.ilk", + "*.obj", + "*.o", + "*.a", + "*.lib" + )) { + if ($fileName -like $pattern) { + return $true + } + } + + return $false +} + +function Copy-PublicTree([string]$SourceDir, [string]$DestinationDir, [string]$RelativeDir) { + foreach ($item in Get-ChildItem -LiteralPath $SourceDir -Force) { + $relativePath = if ($RelativeDir) { + Join-Path $RelativeDir $item.Name + } else { + $item.Name + } + + if ($item.PSIsContainer) { + if (Should-ExcludeDirectory $relativePath) { + continue + } + + $targetDir = Join-Path $DestinationDir $item.Name + New-Item -ItemType Directory -Path $targetDir -Force | Out-Null + Copy-PublicTree -SourceDir $item.FullName -DestinationDir $targetDir -RelativeDir $relativePath + continue + } + + if (Should-ExcludeFile $relativePath) { + continue + } + + Copy-Item -LiteralPath $item.FullName -Destination (Join-Path $DestinationDir $item.Name) -Force + } +} + +$repoRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot "..")) +$outputPath = Get-FullPath $OutputDir + +if ([string]::IsNullOrWhiteSpace($OutputDir)) { + throw "OutputDir is required." +} + +if (Is-SubPath -Candidate $outputPath -Parent $repoRoot) { + throw "OutputDir must be outside the repository to avoid recursive export: $outputPath" +} + +if (Test-Path -LiteralPath $outputPath) { + $hasContent = (Get-ChildItem -LiteralPath $outputPath -Force | Select-Object -First 1) -ne $null + if ($hasContent -and -not $Force) { + throw "OutputDir already exists and is not empty. Pass -Force to replace it: $outputPath" + } + + if ($Force) { + Remove-Item -LiteralPath $outputPath -Recurse -Force + } +} + +New-Item -ItemType Directory -Path $outputPath -Force | Out-Null +Copy-PublicTree -SourceDir $repoRoot -DestinationDir $outputPath -RelativeDir "" + +Write-Host "GitHub export prepared:" +Write-Host " $outputPath" +Write-Host "" +Write-Host "Next steps:" +Write-Host " cd $outputPath" +Write-Host " git init" +Write-Host " git remote add origin https://github.com/Ysm-04/DesktopPet.git" +Write-Host " git add ." +Write-Host " git commit -m `"Publish GitHub version`"" +Write-Host " git push -u origin main"