From 7ffc0093072eb695f619736cca7527992a122734 Mon Sep 17 00:00:00 2001 From: Ysm-04 Date: Mon, 1 Jun 2026 12:46:56 +0800 Subject: [PATCH] Add character management and release packaging --- .gitignore | 1 + CMakeLists.txt | 4 + README.md | 104 ++- docs/Qt_DesktopPet_开发文档.md | 21 +- docs/implementation_plan.md | 9 +- docs/performance_stability_check.md | 13 +- installer/QtDesktopPet.iss | 82 +++ src/character/CharacterPackageLoader.cpp | 4 + src/character/CharacterPackageRepository.cpp | 629 ++++++++++++++++++- src/character/CharacterPackageRepository.h | 40 ++ src/config/AppConfig.h | 1 + src/config/ConfigManager.cpp | 14 + src/ui/PetWindow.cpp | 40 +- src/ui/PetWindow.h | 1 + src/ui/SettingsDialog.cpp | 215 ++++++- src/ui/SettingsDialog.h | 6 + tools/package_release.ps1 | 278 ++++++++ 17 files changed, 1397 insertions(+), 65 deletions(-) create mode 100644 installer/QtDesktopPet.iss create mode 100644 tools/package_release.ps1 diff --git a/.gitignore b/.gitignore index 06bd4ab..9d0756a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ build/ +dist/ cmake-build-*/ .vs/ .vscode/ diff --git a/CMakeLists.txt b/CMakeLists.txt index f497eb7..485f8cd 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -78,6 +78,10 @@ target_link_libraries(QtDesktopPet Qt6::Widgets ) +set_target_properties(QtDesktopPet PROPERTIES + WIN32_EXECUTABLE TRUE +) + if (WIN32) set_source_files_properties(resources/icons/app_icon.rc PROPERTIES diff --git a/README.md b/README.md index 24cfa0f..702cf97 100644 --- a/README.md +++ b/README.md @@ -32,13 +32,16 @@ QtDesktopPet 是一个基于 Qt Widgets / C++17 的 Windows 桌面宠物原型 - 内存历史上限和可选本地历史保存 - AI 请求取消和对话清空 - Google Gemini 原生聊天请求 +- 角色文件夹导入和角色切换 +- 删除用户导入角色 +- Windows 发布打包脚本和 Inno Setup 安装器脚本 尚未实现: -- 角色导入/切换界面 +- 角色导出和更完整的管理界面 - 对话历史导出/管理界面 - 长期性能压测记录 -- 打包发布脚本 +- 发布包实机安装/卸载验证 ## 技术栈 @@ -92,7 +95,13 @@ resources/icons/app_icon_1024.png resources/characters/shiroko/ ``` -角色包按 `resources/characters//` 组织,后续新增角色包时放在同级子目录。基本结构: +内置角色包按 `resources/characters//` 组织。用户导入的角色包不会写入安装目录,而是复制到用户数据目录: + +```text +QStandardPaths::AppDataLocation/characters// +``` + +角色包基本结构: ```text resources/characters/shiroko/ @@ -108,7 +117,20 @@ resources/characters/shiroko/ ``` 当前素材版本为 `2.1.0-stable`,所有帧使用 512x512 透明画布。当前实现会读取当前角色包的各状态配置,并按 `character.json` 中的 FPS 播放。 -运行时会优先读取可执行文件同级的 `resources/characters/`,找不到时回退到源码目录下的 `resources/characters/`。 +运行时会合并内置角色和用户导入角色;内置资源优先读取可执行文件同级的 `resources/characters/`,找不到时回退到源码目录下的 `resources/characters/`。 + +角色导入: + +- 只支持导入本地文件夹,不支持 zip +- 导入前先验证源文件夹;验证失败只弹窗提示,不复制、不创建、不覆盖文件 +- 验证通过后复制到用户数据目录 +- 角色 id 优先读取 `character.json` 的 `id`;为空时使用文件夹名 +- 角色 id 只允许 ASCII 字母、数字、点、下划线和短横线,且不能以点开头或结尾 +- 用户角色同名时会询问是否覆盖 +- 内置角色 id 不能被导入包覆盖 +- 验证要求:`character.json` 可解析、id 安全、存在 `idle` 和 `defaultState`、状态路径安全、fps 合法、每个声明状态至少有一张可读 PNG +- 如果 `base.anchorY + bubble.offsetY` 计算出的气泡锚点明显偏低,导入时会提示用户检查配置,但不强制阻止导入 +- 只允许删除用户导入角色;选择内置角色删除时只会提示“不能删除内置角色”,不做文件操作 懒加载现状: @@ -150,6 +172,78 @@ QStandardPaths::AppConfigLocation/logs/QtDesktopPet.log - 最多保留 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/ +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`。 + +脚本默认优先查找: + +```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 数据目录。 + ## 开发诊断 仓库提供开发用性能采样脚本,不进入普通用户发布包: @@ -170,7 +264,7 @@ reports/perf/ docs/performance_stability_check.md ``` -发布包应排除 `tools/`、`docs/`、`reports/`、`build/` 和 `.git/`,只保留运行必需文件、`resources/characters/`、`resources/icons/`、`LICENSE` 和必要说明。 +发布包应排除 `tools/`、`docs/`、`reports/`、`build/`、`dist/` 和 `.git/`,只保留运行必需文件、`resources/characters/`、`resources/icons/`、`LICENSE` 和必要说明。 ## AI 配置和聊天 diff --git a/docs/Qt_DesktopPet_开发文档.md b/docs/Qt_DesktopPet_开发文档.md index 4556ca5..f0f3841 100644 --- a/docs/Qt_DesktopPet_开发文档.md +++ b/docs/Qt_DesktopPet_开发文档.md @@ -1394,16 +1394,29 @@ Windows 下不能只拷贝 exe。 8. 便携模式是否可用 ``` -建议后续提供: +当前提供: ```text 1. Windows x64 Release 构建 2. 打包脚本 3. README 部署说明 -4. 示例角色包 +4. Inno Setup 安装器脚本 ``` -第一版可以先不做安装器,但需要保证构建产物可以在普通 Windows 环境运行。 +发布流程: + +```text +1. 用户手动完成 Release 构建 +2. 运行 tools/package_release.ps1,传入 QtDesktopPet.exe 路径 +3. 脚本调用 windeployqt 收集 Qt 运行库 +4. 脚本复制 resources/characters、resources/icons、LICENSE、README.md +5. 脚本生成 dist/QtDesktopPet--windows-x64.zip +6. 需要安装器时,脚本优先查找 D:\Inno Setup 7\ISCC.exe,并调用 ISCC 编译 installer/QtDesktopPet.iss +7. 安装器默认最终输出到项目根目录 +8. Inno 编译阶段使用当前盘符下的纯 ASCII 临时目录,例如 D:\QtDesktopPetInstallerOutput,避免中文项目路径下出现 EndUpdateResource failed (5) +``` + +安装器卸载时需要询问用户是否删除用户数据。用户确认后,在卸载完成阶段删除当前用户的 QtDesktopPet 配置、导入角色、聊天记录和日志。 --- @@ -1683,7 +1696,7 @@ MIT License 开源 当前仍需补齐: ```text -1. 角色包导入和角色切换 +1. 角色包导出和更完整管理界面 2. 对话历史导出、搜索或更完整管理界面 3. 发布前素材授权确认与打包验证 4. 长期性能压测记录 diff --git a/docs/implementation_plan.md b/docs/implementation_plan.md index f7914ab..9706f28 100644 --- a/docs/implementation_plan.md +++ b/docs/implementation_plan.md @@ -84,7 +84,7 @@ cmake -S . -B build/mingw-debug -G Ninja ` ## 3. 角色包约定 -`resources/characters/shiroko` 目录作为当前默认角色包。角色包按 `resources/characters//` 组织,后续新增角色包时放在同级子目录。 +`resources/characters/shiroko` 目录作为当前默认内置角色包。内置角色按 `resources/characters//` 组织,用户导入角色复制到 `QStandardPaths::AppDataLocation/characters//`。 已检查到的结构: @@ -504,6 +504,7 @@ tools/ docs/ reports/ build/ +dist/ .git/ ``` @@ -586,12 +587,14 @@ build/ 懒加载当前为状态级首次播放加载,并已接入主线程分批预热和状态级 LRU 卸载;单轮预热不会反复重新加载刚被 LRU 卸载的状态 Windows 下 API Key 使用 DPAPI 加密保存,非 Windows 需用户确认后才允许明文保存 运行时资源优先读取可执行文件同级 resources/,找不到时回退到源码目录 resources/ + 已支持角色文件夹导入、角色切换和删除用户导入角色:验证通过后复制到用户数据目录,验证失败不做文件操作;内置角色不可删除 + 已新增 Windows 发布打包脚本和 Inno Setup 安装器脚本;脚本不负责 CMake 构建,安装器卸载时可由用户确认后清理当前用户数据目录 ``` 当前实现与计划仍存在差异: ```text -1. SettingsDialog 仍是最小设置界面,尚未包含完整角色切换流程和更完整的分区布局 +1. SettingsDialog 仍是最小设置界面,角色页已有导入、切换和删除用户角色,但尚未包含导出和更完整的角色管理流程 2. 对话历史已有内存上限和可选本地保存,但尚未提供导出、搜索或完整管理界面 3. 状态级懒加载尚未包含后台线程预热、单帧级缓存和长期压测记录 4. README 和开发文档已开始同步当前进度,但仍需随功能继续维护 @@ -639,6 +642,6 @@ build/ ```text 1. shiroko 素材是否允许作为正式开源发布素材继续保留在仓库中 -2. 设置页下一步先完善角色包配置,还是先补发布打包配置 +2. 角色管理下一步是否需要导出、打开用户角色目录 3. 对话历史后续是否需要导出、搜索或按角色/Provider 分组管理 ``` diff --git a/docs/performance_stability_check.md b/docs/performance_stability_check.md index 3c31669..4320743 100644 --- a/docs/performance_stability_check.md +++ b/docs/performance_stability_check.md @@ -15,7 +15,7 @@ | 显示器 / DPI | TODO | | Qt 版本 | TODO | | 角色包 | `resources/characters/shiroko` | -| AppConfig 关键项 | scale=TODO, performanceMode=TODO, pauseWhenHidden=TODO, enableLazyLoad=TODO, enableAnimationPrewarm=TODO, animationCacheLimitMb=TODO, unloadAnimationsWhenHidden=TODO | +| AppConfig 关键项 | characterId=TODO, scale=TODO, performanceMode=TODO, pauseWhenHidden=TODO, enableLazyLoad=TODO, enableAnimationPrewarm=TODO, animationCacheLimitMb=TODO, unloadAnimationsWhenHidden=TODO | ## 采样脚本 @@ -85,6 +85,7 @@ tools/ docs/ reports/ build/ +dist/ .git/ ``` @@ -99,6 +100,16 @@ LICENSE README.md ``` +安装器卸载验证: + +```text +1. 正常卸载时程序文件应删除 +2. 选择不删除用户数据时,配置、导入角色、聊天记录和日志应保留 +3. 选择删除用户数据时,卸载完成阶段应删除当前用户下的 QtDesktopPet 数据目录 +``` + +安装器最终文件默认放在项目根目录。Inno Setup 编译阶段使用当前盘符下的纯 ASCII 临时目录,例如 `D:\QtDesktopPetInstallerOutput`,再复制最终安装包回项目根目录,用于规避中文项目路径下可能出现的 `EndUpdateResource failed (5)`。 + 运行时资源查找顺序: ```text diff --git a/installer/QtDesktopPet.iss b/installer/QtDesktopPet.iss new file mode 100644 index 0000000..382c028 --- /dev/null +++ b/installer/QtDesktopPet.iss @@ -0,0 +1,82 @@ +#define AppName "QtDesktopPet" +#ifndef AppVersion +#define AppVersion "0.1.0" +#endif +#ifndef SourceDir +#define SourceDir "..\dist\QtDesktopPet-0.1.0-windows-x64" +#endif +#ifndef OutputDir +#define OutputDir "..\dist\installer" +#endif + +[Setup] +AppId={{B37D912A-7354-4D67-AF75-5EFA7E80D605} +AppName={#AppName} +AppVersion={#AppVersion} +AppPublisher=Make +DefaultDirName={localappdata}\Programs\{#AppName} +DefaultGroupName={#AppName} +DisableProgramGroupPage=yes +OutputDir={#OutputDir} +OutputBaseFilename=QtDesktopPet-{#AppVersion}-windows-x64-setup +Compression=lzma2 +SolidCompression=yes +WizardStyle=modern +PrivilegesRequired=lowest +ArchitecturesAllowed=x64compatible +ArchitecturesInstallIn64BitMode=x64compatible +SetupIconFile=..\resources\icons\app_icon.ico +UninstallDisplayIcon={app}\QtDesktopPet.exe + +[Tasks] +Name: "desktopicon"; Description: "Create a desktop shortcut"; GroupDescription: "Additional shortcuts:"; Flags: unchecked + +[Files] +Source: "{#SourceDir}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs + +[Icons] +Name: "{group}\QtDesktopPet"; Filename: "{app}\QtDesktopPet.exe"; WorkingDir: "{app}" +Name: "{group}\Uninstall QtDesktopPet"; Filename: "{uninstallexe}" +Name: "{userdesktop}\QtDesktopPet"; Filename: "{app}\QtDesktopPet.exe"; WorkingDir: "{app}"; Tasks: desktopicon + +[Run] +Filename: "{app}\QtDesktopPet.exe"; Description: "Launch QtDesktopPet"; Flags: nowait postinstall skipifsilent + +[Code] +var + DeleteUserDataAfterUninstall: Boolean; + +procedure DeleteDirIfExists(Path: String); +begin + if DirExists(Path) then + begin + DelTree(Path, True, True, True); + end; +end; + +function InitializeUninstall(): Boolean; +var + Answer: Integer; +begin + Result := True; + DeleteUserDataAfterUninstall := False; + Answer := MsgBox( + '是否同时删除 QtDesktopPet 的用户配置、导入角色、聊天记录和日志?' + #13#10 + #13#10 + + '这会删除当前用户的 QtDesktopPet 数据,操作不可恢复。', + mbConfirmation, + MB_YESNO); + + if Answer = IDYES then + begin + DeleteUserDataAfterUninstall := True; + end; +end; + +procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep); +begin + if (CurUninstallStep = usPostUninstall) and DeleteUserDataAfterUninstall then + begin + DeleteDirIfExists(ExpandConstant('{userappdata}\QtDesktopPet\QtDesktopPet')); + DeleteDirIfExists(ExpandConstant('{localappdata}\QtDesktopPet\QtDesktopPet')); + end; +end; diff --git a/src/character/CharacterPackageLoader.cpp b/src/character/CharacterPackageLoader.cpp index fc3df6a..0b01fc0 100644 --- a/src/character/CharacterPackageLoader.cpp +++ b/src/character/CharacterPackageLoader.cpp @@ -122,6 +122,10 @@ CharacterPackage CharacterPackageLoader::load(const QString &packagePath, QStrin } package.id = requiredString(root, QStringLiteral("id")); + if (package.id.isEmpty()) + { + package.id = QFileInfo(package.packagePath).fileName().trimmed(); + } package.displayName = requiredString(root, QStringLiteral("displayName")); package.author = requiredString(root, QStringLiteral("author")); package.version = requiredString(root, QStringLiteral("version")); diff --git a/src/character/CharacterPackageRepository.cpp b/src/character/CharacterPackageRepository.cpp index 5a66416..967717f 100644 --- a/src/character/CharacterPackageRepository.cpp +++ b/src/character/CharacterPackageRepository.cpp @@ -1,28 +1,423 @@ #include "CharacterPackageRepository.h" +#include "CharacterPackageLoader.h" #include "../util/ResourcePaths.h" +#include #include +#include #include +#include +#include +#include +#include +#include +#include +#include namespace { -bool isValidCharacterId(const QString &characterId) +constexpr int SupportedSchemaVersion = 1; + +bool setError(QString *errorMessage, const QString &message) +{ + if (errorMessage != nullptr) + { + *errorMessage = message; + } + + return false; +} + +QString fallbackUserDataPath() +{ + const QString appDataPath = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation); + if (!appDataPath.isEmpty()) + { + return appDataPath; + } + + return QDir::currentPath(); +} + +bool isSafeRelativePath(QString path) +{ + path = path.trimmed(); + path.replace(QLatin1Char('\\'), QLatin1Char('/')); + if (path.isEmpty() || path.startsWith(QLatin1Char('/')) || QDir::isAbsolutePath(path)) + { + return false; + } + + const QString cleanedPath = QDir::cleanPath(path); + if (cleanedPath == QStringLiteral(".") || cleanedPath.startsWith(QStringLiteral("../"))) + { + return false; + } + + return !cleanedPath.split(QLatin1Char('/'), Qt::SkipEmptyParts).contains(QStringLiteral("..")); +} + +bool hasReadablePngFrame(const QStringList &framePaths) +{ + for (const QString &framePath : framePaths) + { + QImageReader reader(framePath); + if (reader.canRead()) + { + return true; + } + } + + return false; +} + +bool isAsciiLetterOrNumber(QChar character) +{ + return (character >= QLatin1Char('a') && character <= QLatin1Char('z')) + || (character >= QLatin1Char('A') && character <= QLatin1Char('Z')) + || (character >= QLatin1Char('0') && character <= QLatin1Char('9')); +} + +QStringList collectPackageIds(const QString &rootPath) +{ + const QDir rootDirectory(rootPath); + if (!rootDirectory.exists()) + { + return {}; + } + + const QFileInfoList entries = rootDirectory.entryInfoList( + QDir::Dirs | QDir::NoDotAndDotDot, + QDir::Name); + + QStringList packageIds; + for (const QFileInfo &entry : entries) + { + if (!CharacterPackageRepository::isValidCharacterId(entry.fileName())) + { + continue; + } + + const QFileInfo manifest(QDir(entry.absoluteFilePath()).filePath(QStringLiteral("character.json"))); + if (manifest.isFile()) + { + packageIds.append(entry.fileName()); + } + } + + return packageIds; +} + +bool copyDirectoryRecursively(const QString &sourcePath, const QString &targetPath, QString *errorMessage) +{ + const QDir sourceDirectory(sourcePath); + if (!sourceDirectory.exists()) + { + return setError(errorMessage, QStringLiteral("源角色文件夹不存在。")); + } + + QDir targetDirectory(targetPath); + if (!targetDirectory.exists() && !QDir().mkpath(targetPath)) + { + return setError(errorMessage, QStringLiteral("无法创建导入目标目录。")); + } + + const QFileInfoList entries = sourceDirectory.entryInfoList( + QDir::NoDotAndDotDot | QDir::AllEntries | QDir::Hidden | QDir::System, + QDir::Name); + for (const QFileInfo &entry : entries) + { + if (entry.isSymLink()) + { + return setError(errorMessage, QStringLiteral("角色包中不允许包含符号链接:") + entry.fileName()); + } + + const QString targetEntryPath = targetDirectory.filePath(entry.fileName()); + if (entry.isDir()) + { + if (!copyDirectoryRecursively(entry.absoluteFilePath(), targetEntryPath, errorMessage)) + { + return false; + } + continue; + } + + if (!QFile::copy(entry.absoluteFilePath(), targetEntryPath)) + { + return setError(errorMessage, QStringLiteral("复制文件失败:") + entry.fileName()); + } + } + + return true; +} + +bool replaceDirectoryWithPreparedImport(const QString &preparedPath, const QString &targetPath, bool overwrite, QString *errorMessage) +{ + QDir rootDirectory(QFileInfo(targetPath).absolutePath()); + if (!rootDirectory.exists() && !rootDirectory.mkpath(QStringLiteral("."))) + { + return setError(errorMessage, QStringLiteral("无法创建用户角色目录。")); + } + + const QFileInfo targetInfo(targetPath); + if (targetInfo.exists()) + { + if (!overwrite) + { + return setError(errorMessage, QStringLiteral("同名角色已存在。")); + } + + const QString backupPath = targetPath + + QStringLiteral(".replacing.") + + QDateTime::currentDateTime().toString(QStringLiteral("yyyyMMdd-HHmmss")); + if (!rootDirectory.rename(targetPath, backupPath)) + { + return setError(errorMessage, QStringLiteral("无法替换已有角色目录。")); + } + + if (!rootDirectory.rename(preparedPath, targetPath)) + { + rootDirectory.rename(backupPath, targetPath); + return setError(errorMessage, QStringLiteral("写入新角色目录失败,已尝试恢复旧目录。")); + } + + QDir(backupPath).removeRecursively(); + return true; + } + + if (!rootDirectory.rename(preparedPath, targetPath)) + { + return setError(errorMessage, QStringLiteral("写入角色目录失败。")); + } + + return true; +} + +bool validateManifest(const QString &directoryPath, QString *characterId, QString *displayName, QString *errorMessage) +{ + const QDir packageDirectory(directoryPath); + if (!packageDirectory.exists()) + { + return setError(errorMessage, QStringLiteral("角色文件夹不存在。")); + } + + QFile manifestFile(packageDirectory.filePath(QStringLiteral("character.json"))); + if (!manifestFile.exists()) + { + return setError(errorMessage, QStringLiteral("缺少 character.json。")); + } + + if (!manifestFile.open(QIODevice::ReadOnly)) + { + return setError(errorMessage, QStringLiteral("无法读取 character.json。")); + } + + QJsonParseError parseError; + const QJsonDocument document = QJsonDocument::fromJson(manifestFile.readAll(), &parseError); + if (parseError.error != QJsonParseError::NoError || !document.isObject()) + { + return setError(errorMessage, QStringLiteral("character.json 不是有效 JSON。")); + } + + const QJsonObject root = document.object(); + if (root.value(QStringLiteral("schemaVersion")).toInt(0) != SupportedSchemaVersion) + { + return setError(errorMessage, QStringLiteral("schemaVersion 不受支持。")); + } + + QString packageId = root.value(QStringLiteral("id")).toString().trimmed(); + if (packageId.isEmpty()) + { + packageId = QFileInfo(directoryPath).fileName().trimmed(); + } + if (!CharacterPackageRepository::isValidCharacterId(packageId)) + { + return setError(errorMessage, QStringLiteral("角色 id 不是安全的目录名。")); + } + + const QJsonValue baseValue = root.value(QStringLiteral("base")); + if (baseValue.isObject()) + { + const QJsonObject base = baseValue.toObject(); + if (base.contains(QStringLiteral("width")) && base.value(QStringLiteral("width")).toInt(0) <= 0) + { + return setError(errorMessage, QStringLiteral("base.width 必须大于 0。")); + } + if (base.contains(QStringLiteral("height")) && base.value(QStringLiteral("height")).toInt(0) <= 0) + { + return setError(errorMessage, QStringLiteral("base.height 必须大于 0。")); + } + if (base.contains(QStringLiteral("scale")) && base.value(QStringLiteral("scale")).toDouble(0.0) <= 0.0) + { + return setError(errorMessage, QStringLiteral("base.scale 必须大于 0。")); + } + const double anchorX = base.value(QStringLiteral("anchorX")).toDouble(0.5); + const double anchorY = base.value(QStringLiteral("anchorY")).toDouble(0.0); + if (anchorX < 0.0 || anchorX > 1.0 || anchorY < 0.0 || anchorY > 1.0) + { + return setError(errorMessage, QStringLiteral("base.anchorX/anchorY 必须在 0 到 1 之间。")); + } + } + + const QString defaultState = root.value(QStringLiteral("defaultState")).toString().trimmed(); + if (defaultState.isEmpty()) + { + return setError(errorMessage, QStringLiteral("defaultState 不能为空。")); + } + + const QJsonValue statesValue = root.value(QStringLiteral("states")); + if (!statesValue.isObject() || statesValue.toObject().isEmpty()) + { + return setError(errorMessage, QStringLiteral("states 不能为空。")); + } + + QSet validStates; + const QJsonObject states = statesValue.toObject(); + for (auto iterator = states.constBegin(); iterator != states.constEnd(); ++iterator) + { + if (!iterator.value().isObject()) + { + return setError(errorMessage, QStringLiteral("状态配置必须是对象:") + iterator.key()); + } + + const QJsonObject state = iterator.value().toObject(); + const QString relativePath = state.value(QStringLiteral("path")).toString().trimmed(); + if (!isSafeRelativePath(relativePath)) + { + return setError(errorMessage, QStringLiteral("状态路径非法:") + iterator.key()); + } + + if (state.value(QStringLiteral("fps")).toInt(0) <= 0) + { + return setError(errorMessage, QStringLiteral("状态 fps 必须大于 0:") + iterator.key()); + } + + const QString stateDirectoryPath = packageDirectory.filePath(relativePath); + if (QFileInfo(stateDirectoryPath).isSymLink()) + { + return setError(errorMessage, QStringLiteral("状态目录不允许是符号链接:") + iterator.key()); + } + + const QDir stateDirectory(stateDirectoryPath); + if (!stateDirectory.exists()) + { + return setError(errorMessage, QStringLiteral("状态目录不存在:") + iterator.key()); + } + + const QFileInfoList frameFiles = stateDirectory.entryInfoList( + {QStringLiteral("*.png"), QStringLiteral("*.PNG")}, + QDir::Files, + QDir::Name); + QStringList framePaths; + for (const QFileInfo &frameFile : frameFiles) + { + if (frameFile.isSymLink()) + { + return setError(errorMessage, QStringLiteral("状态帧不允许是符号链接:") + iterator.key()); + } + framePaths.append(frameFile.absoluteFilePath()); + } + if (framePaths.isEmpty() || !hasReadablePngFrame(framePaths)) + { + return setError(errorMessage, QStringLiteral("状态没有可读 PNG 帧:") + iterator.key()); + } + + validStates.insert(iterator.key()); + } + + if (!validStates.contains(QStringLiteral("idle"))) + { + return setError(errorMessage, QStringLiteral("缺少可用 idle 状态。")); + } + if (!validStates.contains(defaultState)) + { + return setError(errorMessage, QStringLiteral("defaultState 不存在或不可用。")); + } + + QString loadError; + const CharacterPackage package = CharacterPackageLoader::load(directoryPath, &loadError); + if (!loadError.isEmpty() || !package.hasState(QStringLiteral("idle"))) + { + return setError(errorMessage, loadError.isEmpty() ? QStringLiteral("角色包无法被当前加载器读取。") : loadError); + } + + if (characterId != nullptr) + { + *characterId = packageId; + } + if (displayName != nullptr) + { + *displayName = root.value(QStringLiteral("displayName")).toString(packageId).trimmed(); + if (displayName->isEmpty()) + { + *displayName = packageId; + } + } + + return true; +} + +QString bubbleAnchorWarning(const QString &directoryPath) +{ + QString loadError; + const CharacterPackage package = CharacterPackageLoader::load(directoryPath, &loadError); + if (!loadError.isEmpty()) + { + return {}; + } + + const double anchorY = static_cast(package.base.height) * package.base.anchorY + package.bubble.offsetY; + if (package.base.height > 0 && anchorY > static_cast(package.base.height) * 0.6) + { + return QStringLiteral("当前 bubble.offsetY 可能会让输出气泡显示在角色中部或偏下位置。建议检查 character.json 中的 base.anchorY 和 bubble.offsetY。"); + } + + return {}; +} +} + +bool CharacterPackageRepository::isValidCharacterId(const QString &characterId) { const QString trimmed = characterId.trimmed(); - return !trimmed.isEmpty() - && trimmed != QStringLiteral(".") - && trimmed != QStringLiteral("..") - && !trimmed.contains(QLatin1Char('/')) - && !trimmed.contains(QLatin1Char('\\')); -} + if (trimmed.isEmpty() + || trimmed == QStringLiteral(".") + || trimmed == QStringLiteral("..") + || trimmed.startsWith(QLatin1Char('.')) + || trimmed.endsWith(QLatin1Char('.'))) + { + return false; + } + + for (const QChar character : trimmed) + { + if (!isAsciiLetterOrNumber(character) + && character != QLatin1Char('.') + && character != QLatin1Char('_') + && character != QLatin1Char('-')) + { + return false; + } + } + + return true; } QString CharacterPackageRepository::charactersRootPath() +{ + return builtInCharactersRootPath(); +} + +QString CharacterPackageRepository::builtInCharactersRootPath() { return ResourcePaths::charactersRootPath(); } +QString CharacterPackageRepository::userCharactersRootPath() +{ + return QDir(fallbackUserDataPath()).filePath(QStringLiteral("characters")); +} + QString CharacterPackageRepository::defaultCharacterId() { return QStringLiteral("shiroko"); @@ -39,6 +434,17 @@ QString CharacterPackageRepository::defaultPreviewPath() } QString CharacterPackageRepository::packagePath(const QString &characterId) +{ + const QString userPath = userPackagePath(characterId); + if (!userPath.isEmpty() && QFileInfo::exists(QDir(userPath).filePath(QStringLiteral("character.json")))) + { + return userPath; + } + + return builtInPackagePath(characterId); +} + +QString CharacterPackageRepository::builtInPackagePath(const QString &characterId) { const QString trimmed = characterId.trimmed(); if (!isValidCharacterId(trimmed)) @@ -46,7 +452,18 @@ QString CharacterPackageRepository::packagePath(const QString &characterId) return {}; } - return QDir(charactersRootPath()).filePath(trimmed); + return QDir(builtInCharactersRootPath()).filePath(trimmed); +} + +QString CharacterPackageRepository::userPackagePath(const QString &characterId) +{ + const QString trimmed = characterId.trimmed(); + if (!isValidCharacterId(trimmed)) + { + return {}; + } + + return QDir(userCharactersRootPath()).filePath(trimmed); } QString CharacterPackageRepository::previewPath(const QString &characterId) @@ -60,32 +477,190 @@ QString CharacterPackageRepository::previewPath(const QString &characterId) return QDir(path).filePath(QStringLiteral("preview.png")); } -QStringList CharacterPackageRepository::availablePackageIds() +bool CharacterPackageRepository::hasPackage(const QString &characterId) { - const QDir rootDirectory(charactersRootPath()); - if (!rootDirectory.exists()) + return hasUserPackage(characterId) || hasBuiltInPackage(characterId); +} + +bool CharacterPackageRepository::hasBuiltInPackage(const QString &characterId) +{ + const QString path = builtInPackagePath(characterId); + return !path.isEmpty() && QFileInfo::exists(QDir(path).filePath(QStringLiteral("character.json"))); +} + +bool CharacterPackageRepository::hasUserPackage(const QString &characterId) +{ + const QString path = userPackagePath(characterId); + return !path.isEmpty() && QFileInfo::exists(QDir(path).filePath(QStringLiteral("character.json"))); +} + +QVector CharacterPackageRepository::availablePackages() +{ + QVector packages; + QSet seenIds; + + const auto appendPackage = [&packages, &seenIds](const QString &id, const QString &path, bool userPackage) { + if (seenIds.contains(id)) + { + return; + } + + QString loadError; + const CharacterPackage package = CharacterPackageLoader::load(path, &loadError); + if (!loadError.isEmpty()) + { + return; + } + + CharacterPackageInfo info; + info.id = id; + info.displayName = package.displayName.trimmed().isEmpty() ? id : package.displayName.trimmed(); + info.packagePath = path; + info.previewPath = package.previewPath.isEmpty() ? QDir(path).filePath(QStringLiteral("preview.png")) : package.previewPath; + info.userPackage = userPackage; + packages.append(info); + seenIds.insert(id); + }; + + for (const QString &id : collectPackageIds(builtInCharactersRootPath())) { - return {}; + appendPackage(id, builtInPackagePath(id), false); } - const QFileInfoList entries = rootDirectory.entryInfoList( - QDir::Dirs | QDir::NoDotAndDotDot, - QDir::Name); - - QStringList packageIds; - for (const QFileInfo &entry : entries) + for (const QString &id : collectPackageIds(userCharactersRootPath())) { - if (!isValidCharacterId(entry.fileName())) - { - continue; - } + appendPackage(id, userPackagePath(id), true); + } - const QFileInfo manifest(QDir(entry.absoluteFilePath()).filePath(QStringLiteral("character.json"))); - if (manifest.isFile()) - { - packageIds.append(entry.fileName()); - } + return packages; +} + +QStringList CharacterPackageRepository::availablePackageIds() +{ + QStringList packageIds; + const QVector packages = availablePackages(); + for (const CharacterPackageInfo &package : packages) + { + packageIds.append(package.id); } return packageIds; } + +CharacterPackageValidationResult CharacterPackageRepository::validatePackageDirectoryWithDetails(const QString &directoryPath) +{ + CharacterPackageValidationResult result; + result.valid = validateManifest( + QDir::cleanPath(directoryPath), + &result.characterId, + &result.displayName, + &result.errorMessage); + if (result.valid) + { + result.warningMessage = bubbleAnchorWarning(QDir::cleanPath(directoryPath)); + } + return result; +} + +bool CharacterPackageRepository::validatePackageDirectory( + const QString &directoryPath, + QString *characterId, + QString *displayName, + QString *errorMessage) +{ + return validateManifest(QDir::cleanPath(directoryPath), characterId, displayName, errorMessage); +} + +bool CharacterPackageRepository::importPackageDirectory( + const QString &sourceDirectoryPath, + bool overwrite, + QString *importedCharacterId, + QString *errorMessage) +{ + QString characterId; + QString displayName; + if (!validatePackageDirectory(sourceDirectoryPath, &characterId, &displayName, errorMessage)) + { + return false; + } + + if (hasBuiltInPackage(characterId)) + { + return setError(errorMessage, QStringLiteral("角色 id 与内置角色重复,不能覆盖内置角色。")); + } + + const QString targetPath = userPackagePath(characterId); + const QString sourcePath = QDir::cleanPath(sourceDirectoryPath); + if (sourcePath == QDir::cleanPath(targetPath)) + { + if (importedCharacterId != nullptr) + { + *importedCharacterId = characterId; + } + return true; + } + + QDir userRoot(userCharactersRootPath()); + if (!userRoot.exists() && !QDir().mkpath(userRoot.absolutePath())) + { + return setError(errorMessage, QStringLiteral("无法创建用户角色目录。")); + } + + const QString importStamp = QDateTime::currentDateTime().toString(QStringLiteral("yyyyMMdd-HHmmss-zzz")); + const QString preparedPath = userRoot.filePath(QStringLiteral(".importing-") + characterId + QStringLiteral("-") + importStamp); + QDir(preparedPath).removeRecursively(); + + if (!copyDirectoryRecursively(sourcePath, preparedPath, errorMessage)) + { + QDir(preparedPath).removeRecursively(); + return false; + } + + if (!replaceDirectoryWithPreparedImport(preparedPath, targetPath, overwrite, errorMessage)) + { + QDir(preparedPath).removeRecursively(); + return false; + } + + if (importedCharacterId != nullptr) + { + *importedCharacterId = characterId; + } + return true; +} + +bool CharacterPackageRepository::deleteUserPackage(const QString &characterId, QString *errorMessage) +{ + const QString trimmed = characterId.trimmed(); + if (!isValidCharacterId(trimmed)) + { + return setError(errorMessage, QStringLiteral("角色 id 无效。")); + } + + const QString targetPath = userPackagePath(trimmed); + const QString userRootPath = QDir::cleanPath(userCharactersRootPath()); + const QString cleanedTargetPath = QDir::cleanPath(targetPath); + if (cleanedTargetPath == userRootPath || !cleanedTargetPath.startsWith(userRootPath + QLatin1Char('/'))) + { + return setError(errorMessage, QStringLiteral("删除目标不在用户角色目录内。")); + } + + QDir targetDirectory(cleanedTargetPath); + if (!targetDirectory.exists()) + { + return setError(errorMessage, QStringLiteral("用户角色目录不存在。")); + } + + const QFileInfo manifest(targetDirectory.filePath(QStringLiteral("character.json"))); + if (!manifest.isFile()) + { + return setError(errorMessage, QStringLiteral("目标目录不是有效的用户角色包。")); + } + + if (!targetDirectory.removeRecursively()) + { + return setError(errorMessage, QStringLiteral("删除用户角色目录失败。")); + } + + return true; +} diff --git a/src/character/CharacterPackageRepository.h b/src/character/CharacterPackageRepository.h index 87eff7c..58f11b1 100644 --- a/src/character/CharacterPackageRepository.h +++ b/src/character/CharacterPackageRepository.h @@ -2,15 +2,55 @@ #include #include +#include + +struct CharacterPackageInfo +{ + QString id; + QString displayName; + QString packagePath; + QString previewPath; + bool userPackage = false; +}; + +struct CharacterPackageValidationResult +{ + bool valid = false; + QString characterId; + QString displayName; + QString warningMessage; + QString errorMessage; +}; class CharacterPackageRepository { public: static QString charactersRootPath(); + static QString builtInCharactersRootPath(); + static QString userCharactersRootPath(); static QString defaultCharacterId(); static QString defaultPackagePath(); static QString defaultPreviewPath(); static QString packagePath(const QString &characterId); + static QString builtInPackagePath(const QString &characterId); + static QString userPackagePath(const QString &characterId); static QString previewPath(const QString &characterId); + static bool isValidCharacterId(const QString &characterId); + static bool hasPackage(const QString &characterId); + static bool hasBuiltInPackage(const QString &characterId); + static bool hasUserPackage(const QString &characterId); + static QVector availablePackages(); static QStringList availablePackageIds(); + static CharacterPackageValidationResult validatePackageDirectoryWithDetails(const QString &directoryPath); + static bool validatePackageDirectory( + const QString &directoryPath, + QString *characterId = nullptr, + QString *displayName = nullptr, + QString *errorMessage = nullptr); + static bool importPackageDirectory( + const QString &sourceDirectoryPath, + bool overwrite, + QString *importedCharacterId = 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 2963462..33b413b 100644 --- a/src/config/AppConfig.h +++ b/src/config/AppConfig.h @@ -15,6 +15,7 @@ struct AppConfig bool enableAnimationPrewarm = true; int animationCacheLimitMb = 180; bool unloadAnimationsWhenHidden = true; + QString characterId = QStringLiteral("shiroko"); int requestContextMessageLimit = 12; int memoryHistoryMessageLimit = 200; bool saveConversationHistory = false; diff --git a/src/config/ConfigManager.cpp b/src/config/ConfigManager.cpp index 0b6ac06..00c1821 100644 --- a/src/config/ConfigManager.cpp +++ b/src/config/ConfigManager.cpp @@ -50,6 +50,13 @@ QJsonObject chatObjectFromConfig(const AppConfig &config) return chat; } +QJsonObject characterObjectFromConfig(const AppConfig &config) +{ + QJsonObject character; + character.insert(QStringLiteral("id"), config.characterId); + return character; +} + QString normalizedProviderName(const QString &provider) { const QString normalized = provider.trimmed().toLower(); @@ -245,6 +252,12 @@ AppConfig ConfigManager::loadAppConfig() const config.savedHistoryMessageLimit = chat.value(QStringLiteral("savedHistoryMessageLimit")).toInt(config.savedHistoryMessageLimit); } + const QJsonObject character = root.value(QStringLiteral("character")).toObject(); + if (character.contains(QStringLiteral("id"))) + { + config.characterId = character.value(QStringLiteral("id")).toString(config.characterId).trimmed(); + } + return config; } @@ -332,6 +345,7 @@ bool ConfigManager::saveAppConfig(const AppConfig &config) const root.insert(QStringLiteral("window"), windowObjectFromConfig(config)); root.insert(QStringLiteral("performance"), performanceObjectFromConfig(config)); root.insert(QStringLiteral("chat"), chatObjectFromConfig(config)); + root.insert(QStringLiteral("character"), characterObjectFromConfig(config)); QFile file(appConfigPath()); if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) diff --git a/src/ui/PetWindow.cpp b/src/ui/PetWindow.cpp index f612ce5..42e4efe 100644 --- a/src/ui/PetWindow.cpp +++ b/src/ui/PetWindow.cpp @@ -81,6 +81,11 @@ AppConfig normalizedAppConfig(AppConfig config) config.requestContextMessageLimit = qBound(0, config.requestContextMessageLimit, 200); config.memoryHistoryMessageLimit = evenBoundedHistoryLimit(config.memoryHistoryMessageLimit, 2, 5000); config.savedHistoryMessageLimit = evenBoundedHistoryLimit(config.savedHistoryMessageLimit, 2, 10000); + config.characterId = config.characterId.trimmed(); + if (!CharacterPackageRepository::hasPackage(config.characterId)) + { + config.characterId = CharacterPackageRepository::defaultCharacterId(); + } return config; } @@ -167,6 +172,7 @@ PetWindow::~PetWindow() void PetWindow::applyAppConfig(const AppConfig &config) { const AppConfig normalizedConfig = normalizedAppConfig(config); + const bool characterChanged = m_appConfig.characterId != normalizedConfig.characterId; const bool rebuildClips = !qFuzzyCompare(m_appConfig.scale, normalizedConfig.scale) || m_appConfig.performanceMode != normalizedConfig.performanceMode || m_appConfig.enableLazyLoad != normalizedConfig.enableLazyLoad; @@ -186,7 +192,12 @@ void PetWindow::applyAppConfig(const AppConfig &config) move(m_appConfig.windowPosition); } - if (rebuildClips && !m_characterPackage.states.isEmpty()) + if (characterChanged) + { + m_frameAnimator.stop(); + loadCharacterPackage(m_appConfig.characterId, false); + } + else if (rebuildClips && !m_characterPackage.states.isEmpty()) { const QString previousState = m_stateMachine.currentState().isEmpty() ? QStringLiteral("idle") @@ -782,22 +793,41 @@ void PetWindow::mouseReleaseEvent(QMouseEvent *event) void PetWindow::loadInitialImage() { + loadCharacterPackage(m_appConfig.characterId, true); +} + +bool PetWindow::loadCharacterPackage(const QString &characterId, bool centerWindow) +{ + const QString requestedCharacterId = CharacterPackageRepository::hasPackage(characterId) + ? characterId + : CharacterPackageRepository::defaultCharacterId(); QString loadError; - m_characterPackage = CharacterPackageLoader::load(CharacterPackageRepository::defaultPackagePath(), &loadError); + CharacterPackage package = CharacterPackageLoader::load(CharacterPackageRepository::packagePath(requestedCharacterId), &loadError); + if (!loadError.isEmpty() && requestedCharacterId != CharacterPackageRepository::defaultCharacterId()) + { + Logger::warning(QStringLiteral("Character package load failed: id=%1 error=%2") + .arg(requestedCharacterId, loadError)); + loadError.clear(); + package = CharacterPackageLoader::load(CharacterPackageRepository::defaultPackagePath(), &loadError); + m_appConfig.characterId = CharacterPackageRepository::defaultCharacterId(); + } + if (!loadError.isEmpty()) { Logger::warning(QStringLiteral("Character package load failed: ") + loadError); } + m_characterPackage = package; buildAnimationClips(); if (m_clips.contains(QStringLiteral("idle"))) { - playResolvedState(m_stateMachine.start(), true); - return; + playResolvedState(m_stateMachine.start(), centerWindow); + return true; } - setDisplayImage(CharacterPackageRepository::defaultPreviewPath(), true); + setDisplayImage(CharacterPackageRepository::previewPath(m_appConfig.characterId), centerWindow); + return !m_characterPackage.states.isEmpty(); } void PetWindow::buildAnimationClips() diff --git a/src/ui/PetWindow.h b/src/ui/PetWindow.h index e581459..603f9d2 100644 --- a/src/ui/PetWindow.h +++ b/src/ui/PetWindow.h @@ -54,6 +54,7 @@ protected: private: void loadInitialImage(); + bool loadCharacterPackage(const QString &characterId, bool centerWindow); void buildAnimationClips(); void addStateTestActions(QMenu *menu); void startChat(); diff --git a/src/ui/SettingsDialog.cpp b/src/ui/SettingsDialog.cpp index b629b3b..55dd0b8 100644 --- a/src/ui/SettingsDialog.cpp +++ b/src/ui/SettingsDialog.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -20,6 +21,7 @@ #include #include #include +#include #include #include #include @@ -90,6 +92,9 @@ SettingsDialog::SettingsDialog( , m_savedHistoryMessageLimitSpinBox(new QSpinBox(this)) , m_clearConversationHistoryButton(new QPushButton(QStringLiteral("清空聊天记录"), this)) , m_characterComboBox(new QComboBox(this)) + , m_importCharacterButton(new QPushButton(QStringLiteral("导入角色文件夹"), this)) + , m_deleteCharacterButton(new QPushButton(QStringLiteral("删除角色"), this)) + , m_characterStatusLabel(new QLabel(this)) , m_configStore(configStore) , m_appConfig(appConfig) , m_aiTestBlocked(std::move(aiTestBlocked)) @@ -273,29 +278,13 @@ SettingsDialog::SettingsDialog( auto *chatPage = new QWidget(this); chatPage->setLayout(chatPageLayout); - QStringList characterIds = CharacterPackageRepository::availablePackageIds(); - if (characterIds.isEmpty()) - { - characterIds.append(CharacterPackageRepository::defaultCharacterId()); - } - - for (const QString &characterId : characterIds) - { - m_characterComboBox->addItem(characterId, characterId); - } - - const int defaultCharacterIndex = m_characterComboBox->findData(CharacterPackageRepository::defaultCharacterId()); - if (defaultCharacterIndex >= 0) - { - m_characterComboBox->setCurrentIndex(defaultCharacterIndex); - } - - m_characterComboBox->setEnabled(false); - m_characterComboBox->setToolTip(QStringLiteral("角色选择将在多角色资源配置接入后启用。")); + m_characterStatusLabel->setObjectName(QStringLiteral("HintLabel")); + m_characterStatusLabel->setWordWrap(true); + reloadCharacterList(m_appConfig.characterId); auto *characterTitleLabel = new QLabel(QStringLiteral("角色"), this); characterTitleLabel->setObjectName(QStringLiteral("PageTitle")); - auto *characterHintLabel = new QLabel(QStringLiteral("当前版本使用内置角色资源。"), this); + auto *characterHintLabel = new QLabel(QStringLiteral("支持导入本地角色文件夹。导入前会先验证角色包,验证失败不会复制或覆盖任何文件。"), this); characterHintLabel->setObjectName(QStringLiteral("HintLabel")); characterHintLabel->setWordWrap(true); @@ -306,11 +295,17 @@ SettingsDialog::SettingsDialog( characterFormLayout->setVerticalSpacing(12); characterFormLayout->addRow(QStringLiteral("当前角色"), m_characterComboBox); + auto *characterActionLayout = new QHBoxLayout(); + characterActionLayout->addWidget(m_importCharacterButton); + characterActionLayout->addWidget(m_deleteCharacterButton); + characterActionLayout->addWidget(m_characterStatusLabel, 1); + auto *characterPageLayout = new QVBoxLayout(); characterPageLayout->setContentsMargins(24, 24, 24, 24); characterPageLayout->setSpacing(16); characterPageLayout->addWidget(characterTitleLabel); characterPageLayout->addLayout(characterFormLayout); + characterPageLayout->addLayout(characterActionLayout); characterPageLayout->addWidget(characterHintLabel); characterPageLayout->addStretch(); @@ -404,6 +399,12 @@ SettingsDialog::SettingsDialog( connect(m_clearConversationHistoryButton, &QPushButton::clicked, this, [this]() { this->clearConversationHistory(); }); + connect(m_importCharacterButton, &QPushButton::clicked, this, [this]() { + importCharacterFolder(); + }); + connect(m_deleteCharacterButton, &QPushButton::clicked, this, [this]() { + deleteSelectedCharacter(); + }); } SettingsDialog::~SettingsDialog() @@ -449,6 +450,11 @@ AppConfig SettingsDialog::appConfig() const config.memoryHistoryMessageLimit = m_memoryHistoryMessageLimitSpinBox->value(); config.saveConversationHistory = m_saveConversationHistoryCheckBox->isChecked(); config.savedHistoryMessageLimit = m_savedHistoryMessageLimitSpinBox->value(); + config.characterId = m_characterComboBox->currentData().toString().trimmed(); + if (config.characterId.isEmpty()) + { + config.characterId = CharacterPackageRepository::defaultCharacterId(); + } return config; } @@ -755,3 +761,172 @@ void SettingsDialog::clearConversationHistory() m_clearConversationStatusLabel->setText(QStringLiteral("聊天记录已清空。")); } + +void SettingsDialog::reloadCharacterList(const QString &selectedCharacterId) +{ + const QString currentSelection = selectedCharacterId.trimmed().isEmpty() + ? m_characterComboBox->currentData().toString() + : selectedCharacterId.trimmed(); + + m_characterComboBox->clear(); + const QVector packages = CharacterPackageRepository::availablePackages(); + for (const CharacterPackageInfo &package : packages) + { + QString label = package.displayName.trimmed().isEmpty() ? package.id : package.displayName.trimmed(); + if (label != package.id) + { + label += QStringLiteral(" (") + package.id + QStringLiteral(")"); + } + label += package.userPackage ? QStringLiteral(" - 用户") : QStringLiteral(" - 内置"); + m_characterComboBox->addItem(label, package.id); + } + + if (m_characterComboBox->count() == 0) + { + m_characterComboBox->addItem(CharacterPackageRepository::defaultCharacterId(), CharacterPackageRepository::defaultCharacterId()); + } + + int selectedIndex = m_characterComboBox->findData(currentSelection); + if (selectedIndex < 0) + { + selectedIndex = m_characterComboBox->findData(CharacterPackageRepository::defaultCharacterId()); + } + m_characterComboBox->setCurrentIndex(selectedIndex >= 0 ? selectedIndex : 0); +} + +void SettingsDialog::deleteSelectedCharacter() +{ + const QString characterId = m_characterComboBox->currentData().toString().trimmed(); + if (characterId.isEmpty()) + { + return; + } + + if (CharacterPackageRepository::hasBuiltInPackage(characterId)) + { + QMessageBox::information( + this, + QStringLiteral("不能删除内置角色"), + QStringLiteral("当前选择的是内置角色,不能删除。")); + return; + } + + if (!CharacterPackageRepository::hasUserPackage(characterId)) + { + QMessageBox::warning( + this, + QStringLiteral("删除角色失败"), + QStringLiteral("没有找到可删除的用户角色目录。")); + return; + } + + const QMessageBox::StandardButton result = QMessageBox::warning( + this, + QStringLiteral("删除角色"), + QStringLiteral("确定要删除用户角色 \"%1\" 吗?\n\n这会删除该角色的用户角色目录,操作不可恢复。").arg(characterId), + QMessageBox::Yes | QMessageBox::Cancel, + QMessageBox::Cancel); + if (result != QMessageBox::Yes) + { + return; + } + + QString errorMessage; + if (!CharacterPackageRepository::deleteUserPackage(characterId, &errorMessage)) + { + QMessageBox::warning( + this, + QStringLiteral("删除角色失败"), + errorMessage.isEmpty() ? QStringLiteral("删除用户角色目录失败。") : errorMessage); + return; + } + + if (m_appConfig.characterId == characterId) + { + m_appConfig.characterId = CharacterPackageRepository::defaultCharacterId(); + } + + reloadCharacterList(CharacterPackageRepository::defaultCharacterId()); + m_characterStatusLabel->setText(QStringLiteral("已删除角色:%1").arg(characterId)); +} + +void SettingsDialog::importCharacterFolder() +{ + const QString directoryPath = QFileDialog::getExistingDirectory( + this, + QStringLiteral("选择角色文件夹"), + QString(), + QFileDialog::ShowDirsOnly | QFileDialog::DontResolveSymlinks); + if (directoryPath.isEmpty()) + { + return; + } + + CharacterPackageValidationResult validation = CharacterPackageRepository::validatePackageDirectoryWithDetails(directoryPath); + if (!validation.valid) + { + QMessageBox::warning( + this, + QStringLiteral("角色导入失败"), + QStringLiteral("角色包验证未通过:\n") + validation.errorMessage); + return; + } + + const QString characterId = validation.characterId; + const QString displayName = validation.displayName; + if (CharacterPackageRepository::hasBuiltInPackage(characterId)) + { + QMessageBox::warning( + this, + QStringLiteral("角色导入失败"), + QStringLiteral("角色 id 与内置角色重复,不能覆盖内置角色。\n请修改角色包 id 或文件夹名后再导入。")); + return; + } + + bool overwrite = false; + const bool sourceIsExistingUserPackage = + QDir::cleanPath(directoryPath) == QDir::cleanPath(CharacterPackageRepository::userPackagePath(characterId)); + if (CharacterPackageRepository::hasUserPackage(characterId) && !sourceIsExistingUserPackage) + { + const QMessageBox::StandardButton result = QMessageBox::question( + this, + QStringLiteral("覆盖已有角色"), + QStringLiteral("已存在同名用户角色:%1\n是否覆盖?").arg(characterId), + QMessageBox::Yes | QMessageBox::Cancel, + QMessageBox::Cancel); + if (result != QMessageBox::Yes) + { + return; + } + + overwrite = true; + } + + if (!validation.warningMessage.isEmpty()) + { + const QMessageBox::StandardButton result = QMessageBox::warning( + this, + QStringLiteral("角色导入提示"), + validation.warningMessage + QStringLiteral("\n\n仍然继续导入吗?"), + QMessageBox::Yes | QMessageBox::Cancel, + QMessageBox::Cancel); + if (result != QMessageBox::Yes) + { + return; + } + } + + QString errorMessage; + QString importedCharacterId; + if (!CharacterPackageRepository::importPackageDirectory(directoryPath, overwrite, &importedCharacterId, &errorMessage)) + { + QMessageBox::warning( + this, + QStringLiteral("角色导入失败"), + errorMessage.isEmpty() ? QStringLiteral("复制角色文件夹失败。") : errorMessage); + return; + } + + reloadCharacterList(importedCharacterId); + m_characterStatusLabel->setText(QStringLiteral("已导入角色:%1").arg(displayName.isEmpty() ? importedCharacterId : displayName)); +} diff --git a/src/ui/SettingsDialog.h b/src/ui/SettingsDialog.h index 32f0361..5d3171a 100644 --- a/src/ui/SettingsDialog.h +++ b/src/ui/SettingsDialog.h @@ -45,6 +45,9 @@ private: void testConnection(); void setTestStatus(const QString &message, const QString &state); void clearConversationHistory(); + void reloadCharacterList(const QString &selectedCharacterId = {}); + void importCharacterFolder(); + void deleteSelectedCharacter(); QComboBox *m_providerComboBox = nullptr; QLineEdit *m_baseUrlEdit = nullptr; @@ -71,6 +74,9 @@ private: QPushButton *m_clearConversationHistoryButton = nullptr; QLabel *m_clearConversationStatusLabel = nullptr; QComboBox *m_characterComboBox = nullptr; + QPushButton *m_importCharacterButton = nullptr; + QPushButton *m_deleteCharacterButton = nullptr; + QLabel *m_characterStatusLabel = nullptr; AIConfigStore m_configStore; AIConfigStore m_acceptedConfigStore; AppConfig m_appConfig; diff --git a/tools/package_release.ps1 b/tools/package_release.ps1 new file mode 100644 index 0000000..b0dcf70 --- /dev/null +++ b/tools/package_release.ps1 @@ -0,0 +1,278 @@ +param( + [Parameter(Mandatory = $true)] + [string]$ExePath, + [string]$Version = "", + [string]$OutputRoot = "dist", + [string]$WindeployQtPath = "", + [switch]$NoZip, + [switch]$BuildInstaller, + [string]$InnoCompilerPath = "", + [string]$InstallerOutputDir = "", + [string]$InstallerWorkOutputDir = "" +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +function Resolve-RepoRoot { + $scriptDirectory = Split-Path -Parent $PSCommandPath + return Split-Path -Parent $scriptDirectory +} + +function Resolve-FullPath { + param( + [string]$Path, + [string]$BasePath + ) + + if ([System.IO.Path]::IsPathRooted($Path)) { + return [System.IO.Path]::GetFullPath($Path) + } + + return [System.IO.Path]::GetFullPath((Join-Path $BasePath $Path)) +} + +function Read-ProjectVersion { + param( + [string]$RepoRoot + ) + + $cmakeFile = Join-Path $RepoRoot "CMakeLists.txt" + $content = Get-Content -LiteralPath $cmakeFile -Raw + $match = [regex]::Match($content, 'project\s*\(\s*QtDesktopPet\s+VERSION\s+([0-9]+(?:\.[0-9]+){1,3})') + if ($match.Success) { + return $match.Groups[1].Value + } + + return "0.1.0" +} + +function Resolve-WindeployQt { + param( + [string]$ExplicitPath + ) + + if (-not [string]::IsNullOrWhiteSpace($ExplicitPath)) { + $resolved = Resolve-FullPath -Path $ExplicitPath -BasePath (Get-Location) + if (Test-Path -LiteralPath $resolved) { + return $resolved + } + + throw "windeployqt was not found at '$ExplicitPath'." + } + + $command = Get-Command "windeployqt.exe" -ErrorAction SilentlyContinue + if ($null -ne $command) { + return $command.Source + } + + throw "windeployqt.exe was not found. Add the Qt bin directory to PATH or pass -WindeployQtPath." +} + +function Resolve-InnoCompiler { + param( + [string]$ExplicitPath + ) + + $candidatePaths = @() + if (-not [string]::IsNullOrWhiteSpace($ExplicitPath)) { + $candidatePaths += $ExplicitPath + } else { + $candidatePaths += @( + "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" + ) + } + + foreach ($candidatePath in $candidatePaths) { + $resolved = Resolve-FullPath -Path $candidatePath -BasePath (Get-Location) + if (Test-Path -LiteralPath $resolved) { + return $resolved + } + } + + if (-not [string]::IsNullOrWhiteSpace($ExplicitPath)) { + throw "Inno Setup compiler was not found at '$ExplicitPath'." + } + + throw "ISCC.exe was not found. Install Inno Setup or pass -InnoCompilerPath." +} + +function Resolve-DefaultInstallerWorkOutputDir { + param( + [string]$RepoRoot + ) + + $driveRoot = [System.IO.Path]::GetPathRoot([System.IO.Path]::GetFullPath($RepoRoot)) + if ([string]::IsNullOrWhiteSpace($driveRoot)) { + return [System.IO.Path]::GetFullPath("QtDesktopPetInstallerOutput") + } + + return [System.IO.Path]::GetFullPath((Join-Path $driveRoot "QtDesktopPetInstallerOutput")) +} + +function Assert-RequiredPath { + param( + [string]$Path, + [string]$Description + ) + + if (-not (Test-Path -LiteralPath $Path)) { + throw "$Description was not found: $Path" + } +} + +function Assert-VersionSegment { + param( + [string]$Value + ) + + if ($Value -notmatch '^[0-9A-Za-z._-]+$') { + throw "Version contains unsupported path characters: $Value" + } +} + +function Assert-ChildPath { + param( + [string]$Path, + [string]$ParentPath, + [string]$Description + ) + + $fullPath = [System.IO.Path]::GetFullPath($Path).TrimEnd([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar) + $fullParentPath = [System.IO.Path]::GetFullPath($ParentPath).TrimEnd([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar) + $expectedPrefix = $fullParentPath + [System.IO.Path]::DirectorySeparatorChar + if (-not $fullPath.StartsWith($expectedPrefix, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "$Description must stay inside output root: $fullPath" + } +} + +function Copy-DirectoryFresh { + param( + [string]$Source, + [string]$Destination + ) + + if (Test-Path -LiteralPath $Destination) { + Remove-Item -LiteralPath $Destination -Recurse -Force + } + + New-Item -ItemType Directory -Force -Path (Split-Path -Parent $Destination) | Out-Null + Copy-Item -LiteralPath $Source -Destination $Destination -Recurse -Force +} + +function Invoke-CheckedProcess { + param( + [string]$FilePath, + [string[]]$Arguments + ) + + & $FilePath @Arguments + if ($LASTEXITCODE -ne 0) { + throw "$FilePath exited with code $LASTEXITCODE." + } +} + +$repoRoot = Resolve-RepoRoot +$resolvedExePath = Resolve-FullPath -Path $ExePath -BasePath $repoRoot +$resolvedOutputRoot = Resolve-FullPath -Path $OutputRoot -BasePath $repoRoot +if ([string]::IsNullOrWhiteSpace($Version)) { + $Version = Read-ProjectVersion -RepoRoot $repoRoot +} +Assert-VersionSegment -Value $Version + +$packageName = "QtDesktopPet-$Version-windows-x64" +$packageRoot = Join-Path $resolvedOutputRoot $packageName +$installerFileName = "$packageName-setup.exe" +$targetExePath = Join-Path $packageRoot "QtDesktopPet.exe" +$resourcesRoot = Join-Path $repoRoot "resources" +$charactersRoot = Join-Path $resourcesRoot "characters" +$iconsRoot = Join-Path $resourcesRoot "icons" +$licensePath = Join-Path $repoRoot "LICENSE" +$readmePath = Join-Path $repoRoot "README.md" +$installerScriptPath = Join-Path $repoRoot "installer\QtDesktopPet.iss" + +Assert-RequiredPath -Path $resolvedExePath -Description "QtDesktopPet.exe" +Assert-RequiredPath -Path (Join-Path $charactersRoot "shiroko\character.json") -Description "Default character package" +Assert-RequiredPath -Path (Join-Path $iconsRoot "app_icon.ico") -Description "Application icon" +Assert-RequiredPath -Path $licensePath -Description "LICENSE" +Assert-RequiredPath -Path $readmePath -Description "README.md" + +$windeployQt = Resolve-WindeployQt -ExplicitPath $WindeployQtPath +Assert-ChildPath -Path $packageRoot -ParentPath $resolvedOutputRoot -Description "Release package directory" + +if (Test-Path -LiteralPath $packageRoot) { + Remove-Item -LiteralPath $packageRoot -Recurse -Force +} +New-Item -ItemType Directory -Force -Path $packageRoot | Out-Null + +Copy-Item -LiteralPath $resolvedExePath -Destination $targetExePath -Force +Copy-DirectoryFresh -Source $charactersRoot -Destination (Join-Path $packageRoot "resources\characters") +Copy-DirectoryFresh -Source $iconsRoot -Destination (Join-Path $packageRoot "resources\icons") +Copy-Item -LiteralPath $licensePath -Destination (Join-Path $packageRoot "LICENSE") -Force +Copy-Item -LiteralPath $readmePath -Destination (Join-Path $packageRoot "README.md") -Force + +Write-Host "Running windeployqt: $windeployQt" +Invoke-CheckedProcess -FilePath $windeployQt -Arguments @("--release", "--compiler-runtime", $targetExePath) + +$manifestPath = Join-Path $packageRoot "package_manifest.txt" +@( + "QtDesktopPet release package", + "Version: $Version", + "CreatedUtc: $((Get-Date).ToUniversalTime().ToString("o"))", + "SourceExe: $resolvedExePath", + "Includes: QtDesktopPet.exe, Qt runtime, resources, LICENSE, README.md", + "Excludes: tools, docs, reports, build, dist, .git" +) | Set-Content -LiteralPath $manifestPath -Encoding UTF8 + +if (-not $NoZip) { + $zipPath = Join-Path $resolvedOutputRoot "$packageName.zip" + Assert-ChildPath -Path $zipPath -ParentPath $resolvedOutputRoot -Description "ZIP package" + if (Test-Path -LiteralPath $zipPath) { + Remove-Item -LiteralPath $zipPath -Force + } + + Compress-Archive -LiteralPath $packageRoot -DestinationPath $zipPath -Force + Write-Host "ZIP package: $zipPath" +} + +if ($BuildInstaller) { + Assert-RequiredPath -Path $installerScriptPath -Description "Inno Setup script" + $resolvedInnoCompilerPath = Resolve-InnoCompiler -ExplicitPath $InnoCompilerPath + + if ([string]::IsNullOrWhiteSpace($InstallerOutputDir)) { + $installerFinalOutputDir = $repoRoot + } else { + $installerFinalOutputDir = Resolve-FullPath -Path $InstallerOutputDir -BasePath $repoRoot + } + + if ([string]::IsNullOrWhiteSpace($InstallerWorkOutputDir)) { + $installerWorkOutputDir = Resolve-DefaultInstallerWorkOutputDir -RepoRoot $repoRoot + } else { + $installerWorkOutputDir = Resolve-FullPath -Path $InstallerWorkOutputDir -BasePath $repoRoot + } + New-Item -ItemType Directory -Force -Path $installerWorkOutputDir | Out-Null + New-Item -ItemType Directory -Force -Path $installerFinalOutputDir | Out-Null + + Invoke-CheckedProcess -FilePath $resolvedInnoCompilerPath -Arguments @( + "/DAppVersion=$Version", + "/DSourceDir=$packageRoot", + "/DOutputDir=$installerWorkOutputDir", + $installerScriptPath + ) + + $builtInstallerPath = Join-Path $installerWorkOutputDir $installerFileName + Assert-RequiredPath -Path $builtInstallerPath -Description "Built installer" + + $finalInstallerPath = Join-Path $installerFinalOutputDir $installerFileName + if ([System.IO.Path]::GetFullPath($builtInstallerPath) -ne [System.IO.Path]::GetFullPath($finalInstallerPath)) { + Copy-Item -LiteralPath $builtInstallerPath -Destination $finalInstallerPath -Force + } + + Write-Host "Installer work output: $installerWorkOutputDir" + Write-Host "Installer final output: $finalInstallerPath" +} + +Write-Host "Release package directory: $packageRoot"