diff --git a/CMakeLists.txt b/CMakeLists.txt index 485f8cd..1738833 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -28,6 +28,11 @@ qt_add_executable(QtDesktopPet src/ai/GoogleGeminiProvider.cpp src/ai/OpenAICompatibleProvider.h src/ai/OpenAICompatibleProvider.cpp + src/assistant/CommandDispatcher.h + src/assistant/CommandDispatcher.cpp + src/assistant/IntentRouter.h + src/assistant/IntentRouter.cpp + src/assistant/UserIntent.h src/character/AnimationClip.h src/character/AnimationClip.cpp src/character/CharacterPackage.h diff --git a/docs/QtDesktopPet_后续功能规划与结构审查.md b/docs/QtDesktopPet_后续功能规划与结构审查.md new file mode 100644 index 0000000..2c96e9f --- /dev/null +++ b/docs/QtDesktopPet_后续功能规划与结构审查.md @@ -0,0 +1,1073 @@ +# QtDesktopPet 后续功能开发与当前结构审查文档 + +> 适用仓库:`https://git.emoera.com/Make/Qt_DesktopPet` +> 目的:在继续新增功能前,先收口当前结构风险,并为后续功能扩展确定统一入口和实现边界。 +> 注意:本文档不是让 Codex 一次性实现所有功能,而是作为后续开发路线和代码审查依据。 + +--- + +## 1. 当前项目状态概览 + +根据当前 README 和仓库结构,项目已经具备较完整的桌宠应用基础: + +- 透明无边框桌宠窗口 +- 鼠标拖动、置顶、右键菜单 +- 托盘显示、隐藏、退出 +- 单实例限制 +- PNG 序列帧动画播放 +- `idle / talk / think / sleep / happy / drag / error` 多状态动画 +- 隐藏时暂停动画,显示时恢复 +- 窗口位置、置顶、缩放、性能设置保存 +- 文件日志和日志轮转 +- AI Provider 分组配置 +- OpenAI Compatible 聊天请求 +- Google Gemini 原生聊天请求 +- SSE 流式输出 +- AI 回复气泡 +- 聊天输入框 +- 对话历史面板 +- 本地历史保存和历史上限 +- AI 请求取消和对话清空 +- 角色文件夹导入和角色切换 +- 删除用户导入角色 +- Windows 打包脚本和 Inno Setup 安装器脚本 +- Release exe 双击不弹控制台窗口 + +项目已经从早期 MVP 进入到“可扩展桌面应用原型”阶段,可以开始规划工具能力扩展。 + +但是,在正式加入定时提醒、天气、本地文件操作、联网搜索之前,建议先做一轮结构收口。 + +--- + +## 2. 当前需要优先审查或优化的地方 + +### 2.1 审查角色切换是否保存后立即生效 + +当前已经支持角色导入和角色切换。经当前测试确认: + +```text +设置页选择新角色 +保存设置 +当前桌宠窗口是否立即重新加载新角色包 +``` + +结论:保存后会立即切换,不依赖重启。 + +当前代码路径: + +```text +PetWindow::openSettingsDialog() +↓ +applyAppConfig(dialog.appConfig()) +↓ +PetWindow::applyAppConfig() 比较旧 characterId 与新 characterId +↓ +characterId 变化时停止当前动画并调用 loadCharacterPackage() +↓ +loadCharacterPackage() 重新构建动画状态并从 idle 状态开始播放 +``` + +后续不需要围绕 2.1 做修复,只需要保留为回归检查项: + +```text +设置页切换角色后点击保存,桌宠应立即显示新角色 +不需要关闭或重启程序 +切换后动画状态应可继续正常播放 +``` + +--- + +### 2.2 不要继续向 PetWindow 堆功能 + +目前 `PetWindow` 已经承担了过多职责,后续不能继续把所有新功能塞进去。 + +`PetWindow` 应该主要负责: + +```text +窗口显示 +拖动 +菜单 +子组件组织 +基础 UI 信号转发 +``` + +不建议继续负责: + +```text +提醒解析 +天气查询 +联网搜索 +本地文件读写 +AI 工具调度 +复杂业务状态管理 +``` + +后续应逐步拆出: + +```text +ChatController +ReminderController +WeatherController +FileOperationController +ToolCommandDispatcher +``` + +即使暂时不大规模重构,也至少要为后续功能增加统一调度层,不要直接把新逻辑写进 `PetWindow::submitChatMessage()`。 + +--- + +### 2.3 新增 IntentRouter / CommandDispatcher + +后续功能包括: + +```text +定时提醒 +天气查询 +本地文件操作 +联网搜索 +普通 AI 对话 +``` + +这些都来自用户输入框。如果没有统一入口,逻辑会混乱。 + +可拆成两个模块: + +```text +src/intent/ + ├── IntentTypes.h + ├── IntentRouter.h + └── IntentRouter.cpp + +src/command/ + ├── CommandDispatcher.h + └── CommandDispatcher.cpp +``` + +当前阶段已选择合并为一个轻量模块: + +```text +src/assistant/ + ├── UserIntent.h + ├── IntentRouter.h + ├── IntentRouter.cpp + ├── CommandDispatcher.h + └── CommandDispatcher.cpp +``` + +当前意图枚举: + +```cpp +enum class UserIntentType +{ + Chat, + Reminder, + Weather, + FileOperation, + Search +}; +``` + +建议入口流程: + +```text +用户输入 +↓ +IntentRouter 判断意图 +↓ +CommandDispatcher 分发 +↓ +ReminderManager / WeatherManager / FileOperationManager / WebSearchManager / ConversationManager +``` + +第一版意图识别不需要复杂,规则优先即可。 + +--- + +### 2.4 意图优先级建议 + +多个意图可能同时出现,需要固定优先级。 + +推荐: + +```text +Reminder > FileOperation > Weather > Search > Chat +``` + +原因: + +```text +“明天早上提醒我看天气” +虽然包含“天气”,但本质是提醒。 + +“把天气截图保存到桌面” +可能是文件操作,而不是单纯天气查询。 + +“搜索一下明天天气” +可能是搜索请求,但如果已有 WeatherTool,应优先走天气工具。 +``` + +--- + +### 2.5 检查 CMAKE_AUTOMOC 设置 + +如果后续新增的 Manager 使用 `QObject`、`signals`、`slots`,建议开启: + +```cmake +set(CMAKE_AUTOMOC ON) +``` + +如果继续保持 `CMAKE_AUTOMOC OFF`,则后续模块应避免 `Q_OBJECT`,全部使用普通 C++ 回调或现有信号体系。 + +建议明确选择一种: + +```text +方案 A:开启 AUTOMOC,后续工具模块正常使用 QObject 信号槽 +方案 B:保持 OFF,后续模块不得引入 Q_OBJECT +``` + +当前阶段选择方案 B:继续保持 `CMAKE_AUTOMOC OFF`,新增意图分发模块使用普通 C++ 类和同步返回值,不引入 `Q_OBJECT`。 + +后续如果 Reminder / Weather / Search 等模块需要大量跨对象异步信号,再单独评估是否切换到方案 A。 + +--- + +### 2.6 README 与实际功能保持同步 + +当前 README 已经比较完整,但后续每实现一个功能,应同步更新: + +```text +当前状态 +配置说明 +隐私说明 +日志说明 +用户数据目录 +发布包包含内容 +``` + +尤其是以下功能需要额外说明: + +```text +提醒数据保存位置 +天气 API 请求说明 +IP 定位隐私说明 +本地文件操作权限说明 +联网搜索来源和隐私说明 +``` + +--- + +### 2.7 继续保持角色导入安全策略 + +当前角色导入已经有较多安全边界,比如: + +```text +只导入本地文件夹 +验证失败不复制 +角色 id 安全字符限制 +内置角色不能被覆盖 +只允许删除用户导入角色 +状态路径安全检查 +``` + +后续本地文件操作模块也应该沿用这种风格: + +```text +路径必须安全 +不允许任意系统目录 +危险操作必须确认 +修改前备份 +删除必须二次确认 +``` + +--- + +## 3. 后续功能总路线 + +建议不要一次做完所有功能,按以下顺序推进: + +```text +阶段 0:结构收口 +阶段 1:定时提醒 +阶段 2:天气查询 +阶段 3:本地文件操作 +阶段 4:联网搜索 +阶段 5:语音对话 / 更复杂 Agent 能力 +``` + +当前最推荐先做: + +```text +1. IntentRouter / CommandDispatcher +2. 定时提醒 +3. 天气查询 +``` + +--- + +# 4. 阶段 0:结构收口任务 + +## 4.1 目标 + +在正式加新功能之前,先让用户输入有统一分发入口,避免所有功能混进聊天逻辑。 + +## 4.2 当前新增文件 + +```text +src/assistant/UserIntent.h +src/assistant/IntentRouter.h +src/assistant/IntentRouter.cpp +src/assistant/CommandDispatcher.h +src/assistant/CommandDispatcher.cpp +``` + +## 4.3 IntentRouter 职责 + +```text +判断用户输入属于什么类型: +- 普通聊天 +- 定时提醒 +- 天气查询 +- 本地文件操作 +- 联网搜索 +``` + +第一版可以使用规则判断,不依赖 AI。 + +## 4.4 CommandDispatcher 职责 + +```text +接收用户输入 +调用 IntentRouter +根据意图分发到不同 Manager +普通聊天才交给现有 ConversationManager / LLMProvider +``` + +## 4.5 PetWindow 改造要求 + +`PetWindow` 只负责把用户输入交给 `CommandDispatcher`,不直接判断业务逻辑。 + +示例流程: + +```text +PetWindow::submitChatMessage() +↓ +CommandDispatcher::dispatch(userText) +↓ +根据返回信号更新气泡、状态机、历史 +``` + +不要继续在 `PetWindow` 中直接写提醒、天气、搜索或文件逻辑。 + +--- + +# 5. 阶段 1:定时提醒功能 + +## 5.1 功能定位 + +实现本地一次性提醒功能。 + +用户可以输入: + +```text +晚上8点提醒我提交作业 +明天9点提醒我开会 +10分钟后提醒我喝水 +半小时后提醒我休息 +2小时后提醒我看消息 +``` + +桌宠创建本地提醒,到点后气泡提示或托盘提示。 + +## 5.2 第一版范围 + +要做: + +```text +一次性提醒 +本地保存 +程序重启后提醒不丢 +到点后触发气泡和托盘通知 +提醒触发后标记 triggered +支持查询当前提醒列表 +支持取消提醒 +``` + +暂不做: + +```text +重复提醒 +农历提醒 +复杂日历 +跨设备同步 +联网日程 +语音提醒 +``` + +## 5.3 建议目录 + +```text +src/reminder/ + ├── ReminderTypes.h + ├── ReminderParser.h + ├── ReminderParser.cpp + ├── ReminderManager.h + ├── ReminderManager.cpp + ├── ReminderStore.h + └── ReminderStore.cpp +``` + +## 5.4 数据结构建议 + +```cpp +struct ReminderItem +{ + QString id; + QString title; + QString originalText; + QDateTime remindAt; + bool triggered = false; + QDateTime createdAt; +}; +``` + +## 5.5 存储文件 + +保存到: + +```text +QStandardPaths::AppConfigLocation/reminders.json +``` + +示例: + +```json +{ + "reminders": [ + { + "id": "reminder_001", + "title": "提交作业", + "originalText": "晚上8点提醒我提交作业", + "remindAt": "2026-06-01T20:00:00", + "triggered": false, + "createdAt": "2026-06-01T15:20:00" + } + ] +} +``` + +配置损坏时备份: + +```text +reminders.broken.yyyyMMdd-HHmmss.json +``` + +## 5.6 时间解析策略 + +第一版规则解析优先,不依赖 AI。 + +支持: + +```text +8点 +8点30 +20:30 +晚上8点 +下午3点 +明天9点 +明天上午10点 +10分钟后 +半小时后 +1小时后 +2小时后 +``` + +如果规则解析失败,后续可以再接 AI 解析。 + +## 5.7 AI 辅助解析的设计边界 + +可以后续做 AI 辅助解析,但 AI 不允许直接写文件。 + +正确流程: + +```text +AI 解析用户意图 +↓ +返回结构化 JSON +↓ +程序校验 +↓ +ReminderManager 创建提醒 +↓ +ReminderStore 保存 +``` + +禁止: + +```text +AI 直接读写 reminders.json +AI 直接修改本地文件 +``` + +AI 解析时必须由程序提供当前本地时间: + +```text +currentLocalTime +timeZone +weekday +userText +``` + +因为 API 模型无法可靠知道用户本机时间。 + +## 5.8 到点触发 + +建议行为: + +```text +桌宠可见:显示 ChatBubble + 切 talk 或 happy +桌宠隐藏:系统托盘通知 +用户拖动中:不打断 drag,拖动结束后显示 +``` + +提醒文案: + +```text +到时间啦:提交作业 +``` + +## 5.9 给 Codex 的阶段任务 + +```text +1. 新增 reminder 模块 +2. 实现一次性 ReminderItem +3. 实现 ReminderStore JSON 读写 +4. 实现 ReminderParser 规则解析 +5. 实现 ReminderManager 定时检查 +6. 到点后发信号给 UI 层 +7. UI 层显示气泡和托盘通知 +8. 不要把提醒逻辑写进 PetWindow +9. 不要让 AI 直接写本地文件 +``` + +--- + +# 6. 阶段 2:天气查询功能 + +## 6.1 功能定位 + +天气是独立工具能力,不放进联网搜索。 + +正确流程: + +```text +用户问天气 +↓ +程序识别 Weather 意图 +↓ +WeatherManager 调用天气 API +↓ +拿到结构化天气数据 +↓ +AI 根据结构化数据自然语言回复 +``` + +不要让 AI 自己搜索天气,也不要让 AI 自己拼 URL。 + +## 6.2 为什么不用联网搜索做天气 + +搜索天气有几个问题: + +```text +结果格式不稳定 +摘要可能过期 +地区解析不稳定 +无法稳定提取温度、降雨、风力、湿度等字段 +AI 总结容易出错 +``` + +天气应该走天气 API。 + +## 6.3 第一版范围 + +要做: + +```text +识别天气请求 +支持默认城市 +支持用户指定城市 +支持当前天气 +支持简单今天/明天查询 +AI 不可用时模板兜底回复 +查询中切 think +回复时切 talk +失败时切 error +``` + +暂不做: + +```text +天气预警 +空气质量详情 +分钟级降水 +生活指数 +地图定位 +GPS 定位 +自动后台推送 +天气提醒 +多天气源自动切换 +``` + +## 6.4 建议目录 + +```text +src/weather/ + ├── WeatherTypes.h + ├── WeatherConfig.h + ├── WeatherStore.h + ├── WeatherStore.cpp + ├── WeatherProvider.h + ├── WeatherProvider.cpp + ├── OpenMeteoWeatherProvider.h + ├── OpenMeteoWeatherProvider.cpp + ├── WeatherManager.h + └── WeatherManager.cpp +``` + +## 6.5 天气源建议 + +第一版推荐: + +```text +Open-MeteoProvider +``` + +优点: + +```text +不需要 API Key +全球可用 +适合开源项目默认方案 +``` + +后续再加: + +```text +AmapWeatherProvider +OpenWeatherProvider +``` + +如果主要面向国内用户,高德天气也可以作为第二个 Provider,但需要用户配置 Web 服务 Key。 + +## 6.6 WeatherConfig + +```cpp +struct WeatherConfig +{ + bool enabled = true; + + QString provider = "open-meteo"; + + QString defaultLocationName; + double defaultLatitude = 0.0; + double defaultLongitude = 0.0; + bool hasDefaultCoordinate = false; + + bool allowIpLocation = false; + bool askBeforeIpLocation = true; + + QString apiKey; + int timeoutMs = 10000; + + bool useAIResponse = true; + bool showRawDataWhenAIUnavailable = true; +}; +``` + +保存到: + +```text +QStandardPaths::AppConfigLocation/weather_config.json +``` + +## 6.7 地点策略 + +优先级: + +```text +用户明确输入城市 +↓ +用户设置的默认城市 +↓ +用户主动选择 IP 定位 +↓ +追问用户城市 +``` + +不要默认静默使用 IP 定位。 + +## 6.8 IP 定位原则 + +IP 定位可以做,但只能作为可选辅助。 + +原因: + +```text +VPN / 代理会导致定位错误 +运营商出口可能定位不准 +校园网 / 公司网可能不准 +用户可能不希望程序自动请求定位服务 +``` + +建议交互: + +```text +你还没有设置默认城市。 +可以手动输入城市,也可以使用 IP 自动定位。 +是否使用 IP 自动定位? +``` + +识别到城市后仍要确认: + +```text +我识别到你可能在西安,要设为默认城市吗? +``` + +## 6.9 天气意图关键词 + +```text +天气 +气温 +温度 +冷不冷 +热不热 +下雨 +降雨 +带伞 +刮风 +风力 +湿度 +空气质量 +雾霾 +适合出门 +穿什么 +外面怎么样 +``` + +## 6.10 AI 回复上下文 + +程序应把结构化天气数据交给 AI: + +```text +用户问题: +今天西安天气怎么样? + +天气数据: +城市:西安 +当前天气:多云 +温度:26℃ +体感温度:27℃ +湿度:60% +风向:东北风 +风速:3级 +降雨概率:20% +更新时间:2026-06-01 15:00 + +请根据以上数据,用简洁自然的语气回答。不要编造没有提供的数据。 +``` + +## 6.11 AI 不可用时兜底 + +如果天气 API 成功但 AI 失败,则模板回复: + +```text +西安当前天气:多云,26℃,湿度 60%,风力 3 级。更新时间:15:00。 +``` + +--- + +# 7. 阶段 3:本地文件操作功能 + +## 7.1 功能定位 + +本地文件操作是高风险功能,必须后置,不能早于提醒和天气。 + +目标是让桌宠能辅助用户处理本地文件,例如: + +```text +帮我整理这个文件夹里的图片 +帮我把这些日志文件打包 +帮我读取这个 txt 总结一下 +帮我把这个 md 转成备份副本 +``` + +## 7.2 基本原则 + +AI 不能直接操作本地文件。 + +正确流程: + +```text +AI 理解用户意图 +↓ +生成操作计划 +↓ +程序校验路径和权限 +↓ +展示计划给用户确认 +↓ +程序执行 +↓ +执行结果反馈给 AI / 用户 +``` + +禁止: + +```text +AI 直接读写任意文件 +AI 直接删除文件 +AI 直接覆盖文件 +AI 自己决定访问系统目录 +``` + +## 7.3 安全边界 + +必须实现: + +```text +工作目录白名单 +路径标准化 +禁止符号链接逃逸 +禁止访问系统目录 +禁止访问用户隐私目录,除非用户显式选择 +修改前备份 +删除前二次确认 +批量操作前显示操作计划 +``` + +## 7.4 建议目录 + +```text +src/fileops/ + ├── FileOperationTypes.h + ├── FileOperationPlanner.h + ├── FileOperationPlanner.cpp + ├── FileOperationManager.h + ├── FileOperationManager.cpp + ├── FileSandbox.h + ├── FileSandbox.cpp + ├── FileBackupManager.h + └── FileBackupManager.cpp +``` + +## 7.5 第一版建议只做低风险操作 + +第一版可做: + +```text +读取用户主动选择的文本文件 +列出用户主动选择的文件夹 +复制文件 +创建备份 +打包 zip +重命名文件,需确认 +``` + +暂不做: + +```text +删除文件 +覆盖文件 +移动大量文件 +修改源码 +执行脚本 +运行命令 +访问系统目录 +``` + +## 7.6 修改/删除必须二次确认 + +即使用户说“删掉它”,也必须确认: + +```text +即将删除: +D:/xxx/a.txt +D:/xxx/b.txt + +此操作会移动到回收站/备份后删除。 +是否继续? +``` + +--- + +# 8. 阶段 4:联网搜索功能 + +## 8.1 功能定位 + +联网搜索后置,不要现在优先做。 + +联网搜索是通用增强能力: + +```text +查询最新信息 +查官网 +查报错 +查新闻 +查版本 +查文档 +``` + +## 8.2 不要给每家模型各自配置搜索 + +不建议: + +```text +OpenAIProvider 自己一套搜索 +GeminiProvider 自己一套搜索 +DeepSeekProvider 自己一套搜索 +CustomProvider 自己一套搜索 +``` + +推荐: + +```text +WebSearchManager 独立负责搜索 +LLMProvider 只负责回答 +ConversationManager / CommandDispatcher 负责把搜索结果注入给 AI +``` + +## 8.3 建议目录 + +```text +src/search/ + ├── SearchTypes.h + ├── SearchProvider.h + ├── SearXNGSearchProvider.h + ├── SearXNGSearchProvider.cpp + ├── WebSearchManager.h + └── WebSearchManager.cpp +``` + +第一版只做: + +```text +SearXNG + 自定义 Base URL +``` + +后续再加: + +```text +Tavily +Brave Search +CustomSearchProvider +``` + +## 8.4 安全边界 + +```text +不要让 AI 自己生成任意 URL 给程序访问 +不要做网页全文抓取第一版 +不要默认每次都联网 +不要把用户隐私查询写日志 +搜索结果要显示来源 +``` + +--- + +# 9. 建议最终开发顺序 + +## 9.1 第一步:收口架构 + +```text +1. 角色切换保存后立即生效已确认,后续作为回归项保留 +2. 新增 IntentRouter +3. 新增 CommandDispatcher +4. 调整 PetWindow 输入流程 +5. 当前阶段保持 CMAKE_AUTOMOC OFF,新增模块不使用 Q_OBJECT +``` + +## 9.2 第二步:定时提醒 + +```text +1. ReminderTypes +2. ReminderStore +3. ReminderParser +4. ReminderManager +5. 到点气泡和托盘通知 +6. 设置页增加提醒列表,可后置 +``` + +## 9.3 第三步:天气查询 + +```text +1. WeatherTypes +2. WeatherConfig / WeatherStore +3. OpenMeteoWeatherProvider +4. WeatherManager +5. IntentRouter 天气识别 +6. AI 回复 / 模板兜底 +``` + +## 9.4 第四步:本地文件操作 + +```text +1. FileSandbox +2. FileOperationTypes +3. FileOperationPlanner +4. FileOperationManager +5. 用户确认 UI +6. 备份机制 +``` + +## 9.5 第五步:联网搜索 + +```text +1. SearchTypes +2. SearchProvider +3. SearXNGSearchProvider +4. WebSearchManager +5. 来源展示 +``` + +--- + +# 10. 给 Codex 的总提示 + +下面这段可以直接作为后续任务总说明: + +```text +当前不要急着直接实现提醒、天气、文件操作或联网搜索。 + +请先审查当前 QtDesktopPet 项目结构,并完成以下准备工作: + +1. 角色切换在设置保存后已确认立即生效,后续只需保留回归检查。 +2. 新增 IntentRouter / CommandDispatcher,使用户输入先经过统一意图分发。 +3. 意图类型包括 Chat、Reminder、Weather、FileOperation、Search。 +4. 意图优先级为 Reminder > FileOperation > Weather > Search > Chat。 +5. 普通聊天继续走现有 AI 对话流程。 +6. 新功能不得继续直接塞进 PetWindow。 +7. 后续 Reminder、Weather、FileOperation、Search 均应作为独立模块接入。 +8. 当前阶段保持 CMAKE_AUTOMOC OFF,后续模块不要使用 Q_OBJECT;确需 Qt 信号槽时再单独评估。 +9. 保持现有隐私策略:API Key、Authorization、完整用户消息、完整错误响应不得写入日志。 +10. 保持现有文件安全策略:路径校验、危险操作确认、修改前备份。 +``` + +--- + +# 11. 当前结论 + +项目已经可以进入“工具能力扩展阶段”,但不建议直接开写业务功能。 + +建议先做: + +```text +结构收口 → IntentRouter / CommandDispatcher → 定时提醒 → 天气 → 本地文件操作 → 联网搜索 +``` + +其中: + +```text +定时提醒:最适合第一个落地 +天气:第二个落地 +本地文件操作:风险较高,第三个落地 +联网搜索:通用能力,最后落地 +``` diff --git a/src/assistant/CommandDispatcher.cpp b/src/assistant/CommandDispatcher.cpp new file mode 100644 index 0000000..5fd1713 --- /dev/null +++ b/src/assistant/CommandDispatcher.cpp @@ -0,0 +1,50 @@ +#include "CommandDispatcher.h" + +QString userIntentTypeName(UserIntentType type) +{ + switch (type) + { + case UserIntentType::Chat: + return QStringLiteral("Chat"); + case UserIntentType::Reminder: + return QStringLiteral("Reminder"); + case UserIntentType::Weather: + return QStringLiteral("Weather"); + case UserIntentType::FileOperation: + return QStringLiteral("FileOperation"); + case UserIntentType::Search: + return QStringLiteral("Search"); + } + + return QStringLiteral("Unknown"); +} + +CommandDispatchResult CommandDispatcher::dispatch(const QString &text) const +{ + const UserIntent intent = m_intentRouter.route(text); + if (intent.type == UserIntentType::Chat) + { + return {CommandDispatchAction::Chat, intent, intent.text}; + } + + return {CommandDispatchAction::UnsupportedTool, intent, unsupportedToolMessage(intent.type)}; +} + +QString CommandDispatcher::unsupportedToolMessage(UserIntentType type) const +{ + switch (type) + { + case UserIntentType::Reminder: + return QStringLiteral("提醒功能尚未接入,我现在还不能创建本地提醒。"); + case UserIntentType::Weather: + return QStringLiteral("天气查询功能尚未接入,我现在还不能查询实时天气。"); + case UserIntentType::FileOperation: + return QStringLiteral("本地文件操作功能尚未接入。为避免风险,我现在不会读写或删除文件。"); + case UserIntentType::Search: + return QStringLiteral("联网搜索功能尚未接入,我现在不会发起搜索请求。"); + case UserIntentType::Chat: + return QString(); + } + + return QStringLiteral("该工具功能尚未接入。"); +} diff --git a/src/assistant/CommandDispatcher.h b/src/assistant/CommandDispatcher.h new file mode 100644 index 0000000..51cfea3 --- /dev/null +++ b/src/assistant/CommandDispatcher.h @@ -0,0 +1,29 @@ +#pragma once + +#include "IntentRouter.h" + +#include + +enum class CommandDispatchAction +{ + Chat, + UnsupportedTool, +}; + +struct CommandDispatchResult +{ + CommandDispatchAction action = CommandDispatchAction::Chat; + UserIntent intent; + QString message; +}; + +class CommandDispatcher +{ +public: + CommandDispatchResult dispatch(const QString &text) const; + +private: + QString unsupportedToolMessage(UserIntentType type) const; + + IntentRouter m_intentRouter; +}; diff --git a/src/assistant/IntentRouter.cpp b/src/assistant/IntentRouter.cpp new file mode 100644 index 0000000..3fb7e26 --- /dev/null +++ b/src/assistant/IntentRouter.cpp @@ -0,0 +1,123 @@ +#include "IntentRouter.h" + +#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; +} + +bool isReminderIntent(const QString &text) +{ + static const QStringList keywords = { + QStringLiteral("提醒"), + QStringLiteral("提醒我"), + QStringLiteral("叫我"), + QStringLiteral("闹钟"), + QStringLiteral("到点"), + }; + return containsAny(text, keywords); +} + +bool isFileOperationIntent(const QString &text) +{ + static const QStringList keywords = { + QStringLiteral("文件"), + QStringLiteral("文件夹"), + QStringLiteral("目录"), + QStringLiteral("保存到"), + QStringLiteral("截图"), + QStringLiteral("打包"), + QStringLiteral("压缩"), + QStringLiteral("复制"), + QStringLiteral("备份"), + QStringLiteral("重命名"), + QStringLiteral("删除"), + QStringLiteral("移动"), + QStringLiteral("读取"), + QStringLiteral(".txt"), + QStringLiteral(".md"), + QStringLiteral("日志"), + }; + return containsAny(text, keywords); +} + +bool isWeatherIntent(const QString &text) +{ + static const QStringList keywords = { + QStringLiteral("天气"), + QStringLiteral("气温"), + QStringLiteral("温度"), + QStringLiteral("冷不冷"), + QStringLiteral("热不热"), + QStringLiteral("下雨"), + QStringLiteral("降雨"), + QStringLiteral("带伞"), + QStringLiteral("刮风"), + QStringLiteral("风力"), + QStringLiteral("湿度"), + QStringLiteral("空气质量"), + QStringLiteral("雾霾"), + QStringLiteral("适合出门"), + QStringLiteral("穿什么"), + QStringLiteral("外面怎么样"), + }; + return containsAny(text, keywords); +} + +bool isSearchIntent(const QString &text) +{ + static const QStringList keywords = { + QStringLiteral("搜索"), + QStringLiteral("搜一下"), + QStringLiteral("查一下"), + QStringLiteral("查找"), + QStringLiteral("联网"), + QStringLiteral("最新"), + QStringLiteral("官网"), + QStringLiteral("新闻"), + QStringLiteral("版本"), + QStringLiteral("文档"), + QStringLiteral("报错"), + }; + return containsAny(text, keywords); +} +} + +UserIntent IntentRouter::route(const QString &text) const +{ + const QString trimmedText = text.trimmed(); + + if (isReminderIntent(trimmedText)) + { + return {UserIntentType::Reminder, trimmedText}; + } + + if (isFileOperationIntent(trimmedText)) + { + return {UserIntentType::FileOperation, trimmedText}; + } + + if (isWeatherIntent(trimmedText)) + { + return {UserIntentType::Weather, trimmedText}; + } + + if (isSearchIntent(trimmedText)) + { + return {UserIntentType::Search, trimmedText}; + } + + return {UserIntentType::Chat, trimmedText}; +} diff --git a/src/assistant/IntentRouter.h b/src/assistant/IntentRouter.h new file mode 100644 index 0000000..c494c71 --- /dev/null +++ b/src/assistant/IntentRouter.h @@ -0,0 +1,11 @@ +#pragma once + +#include "UserIntent.h" + +#include + +class IntentRouter +{ +public: + UserIntent route(const QString &text) const; +}; diff --git a/src/assistant/UserIntent.h b/src/assistant/UserIntent.h new file mode 100644 index 0000000..2bc53b5 --- /dev/null +++ b/src/assistant/UserIntent.h @@ -0,0 +1,20 @@ +#pragma once + +#include + +enum class UserIntentType +{ + Chat, + Reminder, + Weather, + FileOperation, + Search, +}; + +struct UserIntent +{ + UserIntentType type = UserIntentType::Chat; + QString text; +}; + +QString userIntentTypeName(UserIntentType type); diff --git a/src/ui/PetWindow.cpp b/src/ui/PetWindow.cpp index b97f476..007555e 100644 --- a/src/ui/PetWindow.cpp +++ b/src/ui/PetWindow.cpp @@ -3,6 +3,7 @@ #include "../ai/AIProviderFactory.h" #include "../ai/ConversationManager.h" #include "../ai/ConversationStore.h" +#include "../assistant/CommandDispatcher.h" #include "../character/CharacterPackageLoader.h" #include "../character/CharacterPackageRepository.h" #include "../config/ConfigManager.h" @@ -460,19 +461,45 @@ bool PetWindow::submitChatMessage(const QString &message) return false; } - const QString trimmedMessage = message.trimmed(); - if (trimmedMessage.isEmpty()) + const QString normalizedMessage = message.trimmed(); + if (normalizedMessage.isEmpty()) { return false; } - if (trimmedMessage.size() > MaxUserMessageLength) + if (normalizedMessage.size() > MaxUserMessageLength) { playState(QStringLiteral("error"), false); showBubbleMessage(QStringLiteral("消息太长,请控制在 4000 字以内。")); return false; } + CommandDispatcher dispatcher; + const CommandDispatchResult result = dispatcher.dispatch(normalizedMessage); + if (result.action == CommandDispatchAction::UnsupportedTool) + { + playState(QStringLiteral("talk"), false); + showBubbleMessage(result.message); + return true; + } + + return submitAiChatMessage(result.message); +} + +bool PetWindow::submitAiChatMessage(const QString &message) +{ + if (!m_conversationManager || m_conversationManager->isBusy()) + { + showBubbleMessage(QStringLiteral("AI 回复正在进行。")); + return false; + } + + const QString chatMessage = message.trimmed(); + if (chatMessage.isEmpty()) + { + return false; + } + ConfigManager configManager; AIConfig config = configManager.loadAIConfig(); QString errorMessage; @@ -507,7 +534,7 @@ bool PetWindow::submitChatMessage(const QString &message) QPointer window(this); m_conversationManager->sendUserMessageStreaming( - trimmedMessage, + chatMessage, [window](const QString &delta) { if (!window.isNull()) { diff --git a/src/ui/PetWindow.h b/src/ui/PetWindow.h index 5bea67f..0730143 100644 --- a/src/ui/PetWindow.h +++ b/src/ui/PetWindow.h @@ -60,6 +60,7 @@ private: void addStateTestActions(QMenu *menu); void startChat(); bool submitChatMessage(const QString &message); + bool submitAiChatMessage(const QString &message); void clearConversation(); void cancelActiveAIRequest(); void showConversationHistory();