Add character management and release packaging

This commit is contained in:
2026-06-01 12:46:56 +08:00
parent 4388a168f1
commit 7ffc009307
17 changed files with 1397 additions and 65 deletions
+1
View File
@@ -1,4 +1,5 @@
build/ build/
dist/
cmake-build-*/ cmake-build-*/
.vs/ .vs/
.vscode/ .vscode/
+4
View File
@@ -78,6 +78,10 @@ target_link_libraries(QtDesktopPet
Qt6::Widgets Qt6::Widgets
) )
set_target_properties(QtDesktopPet PROPERTIES
WIN32_EXECUTABLE TRUE
)
if (WIN32) if (WIN32)
set_source_files_properties(resources/icons/app_icon.rc set_source_files_properties(resources/icons/app_icon.rc
PROPERTIES PROPERTIES
+99 -5
View File
@@ -32,13 +32,16 @@ QtDesktopPet 是一个基于 Qt Widgets / C++17 的 Windows 桌面宠物原型
- 内存历史上限和可选本地历史保存 - 内存历史上限和可选本地历史保存
- AI 请求取消和对话清空 - AI 请求取消和对话清空
- Google Gemini 原生聊天请求 - Google Gemini 原生聊天请求
- 角色文件夹导入和角色切换
- 删除用户导入角色
- Windows 发布打包脚本和 Inno Setup 安装器脚本
尚未实现: 尚未实现:
- 角色导入/切换界面 - 角色导出和更完整的管理界面
- 对话历史导出/管理界面 - 对话历史导出/管理界面
- 长期性能压测记录 - 长期性能压测记录
- 打包发布脚本 - 发布包实机安装/卸载验证
## 技术栈 ## 技术栈
@@ -92,7 +95,13 @@ resources/icons/app_icon_1024.png
resources/characters/shiroko/ resources/characters/shiroko/
``` ```
角色包按 `resources/characters/<characterId>/` 组织,后续新增角色包时放在同级子目录。基本结构 内置角色包按 `resources/characters/<characterId>/` 组织。用户导入的角色包不会写入安装目录,而是复制到用户数据目录
```text
QStandardPaths::AppDataLocation/characters/<characterId>/
```
角色包基本结构:
```text ```text
resources/characters/shiroko/ resources/characters/shiroko/
@@ -108,7 +117,20 @@ resources/characters/shiroko/
``` ```
当前素材版本为 `2.1.0-stable`,所有帧使用 512x512 透明画布。当前实现会读取当前角色包的各状态配置,并按 `character.json` 中的 FPS 播放。 当前素材版本为 `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 个旧日志文件 - 最多保留 3 个旧日志文件
- 文件名为 `QtDesktopPet.log.1``QtDesktopPet.log.2``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-<version>-windows-x64/
dist/QtDesktopPet-<version>-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-<version>-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 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 配置和聊天 ## AI 配置和聊天
+17 -4
View File
@@ -1394,16 +1394,29 @@ Windows 下不能只拷贝 exe。
8. 便携模式是否可用 8. 便携模式是否可用
``` ```
建议后续提供: 当前提供:
```text ```text
1. Windows x64 Release 构建 1. Windows x64 Release 构建
2. 打包脚本 2. 打包脚本
3. README 部署说明 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-<version>-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 ```text
1. 角色包导入和角色切换 1. 角色包导出和更完整管理界面
2. 对话历史导出、搜索或更完整管理界面 2. 对话历史导出、搜索或更完整管理界面
3. 发布前素材授权确认与打包验证 3. 发布前素材授权确认与打包验证
4. 长期性能压测记录 4. 长期性能压测记录
+6 -3
View File
@@ -84,7 +84,7 @@ cmake -S . -B build/mingw-debug -G Ninja `
## 3. 角色包约定 ## 3. 角色包约定
`resources/characters/shiroko` 目录作为当前默认角色包。角色`resources/characters/<characterId>/` 组织,后续新增角色包时放在同级子目录 `resources/characters/shiroko` 目录作为当前默认内置角色包。内置角色按 `resources/characters/<characterId>/` 组织,用户导入角色复制到 `QStandardPaths::AppDataLocation/characters/<characterId>/`
已检查到的结构: 已检查到的结构:
@@ -504,6 +504,7 @@ tools/
docs/ docs/
reports/ reports/
build/ build/
dist/
.git/ .git/
``` ```
@@ -586,12 +587,14 @@ build/
懒加载当前为状态级首次播放加载,并已接入主线程分批预热和状态级 LRU 卸载;单轮预热不会反复重新加载刚被 LRU 卸载的状态 懒加载当前为状态级首次播放加载,并已接入主线程分批预热和状态级 LRU 卸载;单轮预热不会反复重新加载刚被 LRU 卸载的状态
Windows 下 API Key 使用 DPAPI 加密保存,非 Windows 需用户确认后才允许明文保存 Windows 下 API Key 使用 DPAPI 加密保存,非 Windows 需用户确认后才允许明文保存
运行时资源优先读取可执行文件同级 resources/,找不到时回退到源码目录 resources/ 运行时资源优先读取可执行文件同级 resources/,找不到时回退到源码目录 resources/
已支持角色文件夹导入、角色切换和删除用户导入角色:验证通过后复制到用户数据目录,验证失败不做文件操作;内置角色不可删除
已新增 Windows 发布打包脚本和 Inno Setup 安装器脚本;脚本不负责 CMake 构建,安装器卸载时可由用户确认后清理当前用户数据目录
``` ```
当前实现与计划仍存在差异: 当前实现与计划仍存在差异:
```text ```text
1. SettingsDialog 仍是最小设置界面,尚未包含完整角色切换流程和更完整的分区布局 1. SettingsDialog 仍是最小设置界面,角色页已有导入、切换和删除用户角色,但尚未包含导出和更完整角色管理流程
2. 对话历史已有内存上限和可选本地保存,但尚未提供导出、搜索或完整管理界面 2. 对话历史已有内存上限和可选本地保存,但尚未提供导出、搜索或完整管理界面
3. 状态级懒加载尚未包含后台线程预热、单帧级缓存和长期压测记录 3. 状态级懒加载尚未包含后台线程预热、单帧级缓存和长期压测记录
4. README 和开发文档已开始同步当前进度,但仍需随功能继续维护 4. README 和开发文档已开始同步当前进度,但仍需随功能继续维护
@@ -639,6 +642,6 @@ build/
```text ```text
1. shiroko 素材是否允许作为正式开源发布素材继续保留在仓库中 1. shiroko 素材是否允许作为正式开源发布素材继续保留在仓库中
2. 设置页下一步先完善角色包配置,还是先补发布打包配置 2. 角色管理下一步是否需要导出、打开用户角色目录
3. 对话历史后续是否需要导出、搜索或按角色/Provider 分组管理 3. 对话历史后续是否需要导出、搜索或按角色/Provider 分组管理
``` ```
+12 -1
View File
@@ -15,7 +15,7 @@
| 显示器 / DPI | TODO | | 显示器 / DPI | TODO |
| Qt 版本 | TODO | | Qt 版本 | TODO |
| 角色包 | `resources/characters/shiroko` | | 角色包 | `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/ docs/
reports/ reports/
build/ build/
dist/
.git/ .git/
``` ```
@@ -99,6 +100,16 @@ LICENSE
README.md README.md
``` ```
安装器卸载验证:
```text
1. 正常卸载时程序文件应删除
2. 选择不删除用户数据时,配置、导入角色、聊天记录和日志应保留
3. 选择删除用户数据时,卸载完成阶段应删除当前用户下的 QtDesktopPet 数据目录
```
安装器最终文件默认放在项目根目录。Inno Setup 编译阶段使用当前盘符下的纯 ASCII 临时目录,例如 `D:\QtDesktopPetInstallerOutput`,再复制最终安装包回项目根目录,用于规避中文项目路径下可能出现的 `EndUpdateResource failed (5)`
运行时资源查找顺序: 运行时资源查找顺序:
```text ```text
+82
View File
@@ -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;
+4
View File
@@ -122,6 +122,10 @@ CharacterPackage CharacterPackageLoader::load(const QString &packagePath, QStrin
} }
package.id = requiredString(root, QStringLiteral("id")); package.id = requiredString(root, QStringLiteral("id"));
if (package.id.isEmpty())
{
package.id = QFileInfo(package.packagePath).fileName().trimmed();
}
package.displayName = requiredString(root, QStringLiteral("displayName")); package.displayName = requiredString(root, QStringLiteral("displayName"));
package.author = requiredString(root, QStringLiteral("author")); package.author = requiredString(root, QStringLiteral("author"));
package.version = requiredString(root, QStringLiteral("version")); package.version = requiredString(root, QStringLiteral("version"));
+604 -29
View File
@@ -1,28 +1,423 @@
#include "CharacterPackageRepository.h" #include "CharacterPackageRepository.h"
#include "CharacterPackageLoader.h"
#include "../util/ResourcePaths.h" #include "../util/ResourcePaths.h"
#include <QDateTime>
#include <QDir> #include <QDir>
#include <QFile>
#include <QFileInfo> #include <QFileInfo>
#include <QImageReader>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonParseError>
#include <QJsonValue>
#include <QSet>
#include <QStandardPaths>
namespace 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<QString> 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<double>(package.base.height) * package.base.anchorY + package.bubble.offsetY;
if (package.base.height > 0 && anchorY > static_cast<double>(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(); const QString trimmed = characterId.trimmed();
return !trimmed.isEmpty() if (trimmed.isEmpty()
&& trimmed != QStringLiteral(".") || trimmed == QStringLiteral(".")
&& trimmed != QStringLiteral("..") || trimmed == QStringLiteral("..")
&& !trimmed.contains(QLatin1Char('/')) || trimmed.startsWith(QLatin1Char('.'))
&& !trimmed.contains(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() QString CharacterPackageRepository::charactersRootPath()
{
return builtInCharactersRootPath();
}
QString CharacterPackageRepository::builtInCharactersRootPath()
{ {
return ResourcePaths::charactersRootPath(); return ResourcePaths::charactersRootPath();
} }
QString CharacterPackageRepository::userCharactersRootPath()
{
return QDir(fallbackUserDataPath()).filePath(QStringLiteral("characters"));
}
QString CharacterPackageRepository::defaultCharacterId() QString CharacterPackageRepository::defaultCharacterId()
{ {
return QStringLiteral("shiroko"); return QStringLiteral("shiroko");
@@ -39,6 +434,17 @@ QString CharacterPackageRepository::defaultPreviewPath()
} }
QString CharacterPackageRepository::packagePath(const QString &characterId) 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(); const QString trimmed = characterId.trimmed();
if (!isValidCharacterId(trimmed)) if (!isValidCharacterId(trimmed))
@@ -46,7 +452,18 @@ QString CharacterPackageRepository::packagePath(const QString &characterId)
return {}; 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) QString CharacterPackageRepository::previewPath(const QString &characterId)
@@ -60,32 +477,190 @@ QString CharacterPackageRepository::previewPath(const QString &characterId)
return QDir(path).filePath(QStringLiteral("preview.png")); return QDir(path).filePath(QStringLiteral("preview.png"));
} }
bool CharacterPackageRepository::hasPackage(const QString &characterId)
{
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<CharacterPackageInfo> CharacterPackageRepository::availablePackages()
{
QVector<CharacterPackageInfo> packages;
QSet<QString> 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()))
{
appendPackage(id, builtInPackagePath(id), false);
}
for (const QString &id : collectPackageIds(userCharactersRootPath()))
{
appendPackage(id, userPackagePath(id), true);
}
return packages;
}
QStringList CharacterPackageRepository::availablePackageIds() QStringList CharacterPackageRepository::availablePackageIds()
{ {
const QDir rootDirectory(charactersRootPath());
if (!rootDirectory.exists())
{
return {};
}
const QFileInfoList entries = rootDirectory.entryInfoList(
QDir::Dirs | QDir::NoDotAndDotDot,
QDir::Name);
QStringList packageIds; QStringList packageIds;
for (const QFileInfo &entry : entries) const QVector<CharacterPackageInfo> packages = availablePackages();
for (const CharacterPackageInfo &package : packages)
{ {
if (!isValidCharacterId(entry.fileName())) packageIds.append(package.id);
{
continue;
}
const QFileInfo manifest(QDir(entry.absoluteFilePath()).filePath(QStringLiteral("character.json")));
if (manifest.isFile())
{
packageIds.append(entry.fileName());
}
} }
return packageIds; 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;
}
@@ -2,15 +2,55 @@
#include <QString> #include <QString>
#include <QStringList> #include <QStringList>
#include <QVector>
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 class CharacterPackageRepository
{ {
public: public:
static QString charactersRootPath(); static QString charactersRootPath();
static QString builtInCharactersRootPath();
static QString userCharactersRootPath();
static QString defaultCharacterId(); static QString defaultCharacterId();
static QString defaultPackagePath(); static QString defaultPackagePath();
static QString defaultPreviewPath(); static QString defaultPreviewPath();
static QString packagePath(const QString &characterId); 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 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<CharacterPackageInfo> availablePackages();
static QStringList availablePackageIds(); 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);
}; };
+1
View File
@@ -15,6 +15,7 @@ struct AppConfig
bool enableAnimationPrewarm = true; bool enableAnimationPrewarm = true;
int animationCacheLimitMb = 180; int animationCacheLimitMb = 180;
bool unloadAnimationsWhenHidden = true; bool unloadAnimationsWhenHidden = true;
QString characterId = QStringLiteral("shiroko");
int requestContextMessageLimit = 12; int requestContextMessageLimit = 12;
int memoryHistoryMessageLimit = 200; int memoryHistoryMessageLimit = 200;
bool saveConversationHistory = false; bool saveConversationHistory = false;
+14
View File
@@ -50,6 +50,13 @@ QJsonObject chatObjectFromConfig(const AppConfig &config)
return chat; return chat;
} }
QJsonObject characterObjectFromConfig(const AppConfig &config)
{
QJsonObject character;
character.insert(QStringLiteral("id"), config.characterId);
return character;
}
QString normalizedProviderName(const QString &provider) QString normalizedProviderName(const QString &provider)
{ {
const QString normalized = provider.trimmed().toLower(); const QString normalized = provider.trimmed().toLower();
@@ -245,6 +252,12 @@ AppConfig ConfigManager::loadAppConfig() const
config.savedHistoryMessageLimit = chat.value(QStringLiteral("savedHistoryMessageLimit")).toInt(config.savedHistoryMessageLimit); 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; return config;
} }
@@ -332,6 +345,7 @@ bool ConfigManager::saveAppConfig(const AppConfig &config) const
root.insert(QStringLiteral("window"), windowObjectFromConfig(config)); root.insert(QStringLiteral("window"), windowObjectFromConfig(config));
root.insert(QStringLiteral("performance"), performanceObjectFromConfig(config)); root.insert(QStringLiteral("performance"), performanceObjectFromConfig(config));
root.insert(QStringLiteral("chat"), chatObjectFromConfig(config)); root.insert(QStringLiteral("chat"), chatObjectFromConfig(config));
root.insert(QStringLiteral("character"), characterObjectFromConfig(config));
QFile file(appConfigPath()); QFile file(appConfigPath());
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate))
+35 -5
View File
@@ -81,6 +81,11 @@ AppConfig normalizedAppConfig(AppConfig config)
config.requestContextMessageLimit = qBound(0, config.requestContextMessageLimit, 200); config.requestContextMessageLimit = qBound(0, config.requestContextMessageLimit, 200);
config.memoryHistoryMessageLimit = evenBoundedHistoryLimit(config.memoryHistoryMessageLimit, 2, 5000); config.memoryHistoryMessageLimit = evenBoundedHistoryLimit(config.memoryHistoryMessageLimit, 2, 5000);
config.savedHistoryMessageLimit = evenBoundedHistoryLimit(config.savedHistoryMessageLimit, 2, 10000); config.savedHistoryMessageLimit = evenBoundedHistoryLimit(config.savedHistoryMessageLimit, 2, 10000);
config.characterId = config.characterId.trimmed();
if (!CharacterPackageRepository::hasPackage(config.characterId))
{
config.characterId = CharacterPackageRepository::defaultCharacterId();
}
return config; return config;
} }
@@ -167,6 +172,7 @@ PetWindow::~PetWindow()
void PetWindow::applyAppConfig(const AppConfig &config) void PetWindow::applyAppConfig(const AppConfig &config)
{ {
const AppConfig normalizedConfig = normalizedAppConfig(config); const AppConfig normalizedConfig = normalizedAppConfig(config);
const bool characterChanged = m_appConfig.characterId != normalizedConfig.characterId;
const bool rebuildClips = !qFuzzyCompare(m_appConfig.scale, normalizedConfig.scale) const bool rebuildClips = !qFuzzyCompare(m_appConfig.scale, normalizedConfig.scale)
|| m_appConfig.performanceMode != normalizedConfig.performanceMode || m_appConfig.performanceMode != normalizedConfig.performanceMode
|| m_appConfig.enableLazyLoad != normalizedConfig.enableLazyLoad; || m_appConfig.enableLazyLoad != normalizedConfig.enableLazyLoad;
@@ -186,7 +192,12 @@ void PetWindow::applyAppConfig(const AppConfig &config)
move(m_appConfig.windowPosition); 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() const QString previousState = m_stateMachine.currentState().isEmpty()
? QStringLiteral("idle") ? QStringLiteral("idle")
@@ -782,22 +793,41 @@ void PetWindow::mouseReleaseEvent(QMouseEvent *event)
void PetWindow::loadInitialImage() 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; 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()) if (!loadError.isEmpty())
{ {
Logger::warning(QStringLiteral("Character package load failed: ") + loadError); Logger::warning(QStringLiteral("Character package load failed: ") + loadError);
} }
m_characterPackage = package;
buildAnimationClips(); buildAnimationClips();
if (m_clips.contains(QStringLiteral("idle"))) if (m_clips.contains(QStringLiteral("idle")))
{ {
playResolvedState(m_stateMachine.start(), true); playResolvedState(m_stateMachine.start(), centerWindow);
return; return true;
} }
setDisplayImage(CharacterPackageRepository::defaultPreviewPath(), true); setDisplayImage(CharacterPackageRepository::previewPath(m_appConfig.characterId), centerWindow);
return !m_characterPackage.states.isEmpty();
} }
void PetWindow::buildAnimationClips() void PetWindow::buildAnimationClips()
+1
View File
@@ -54,6 +54,7 @@ protected:
private: private:
void loadInitialImage(); void loadInitialImage();
bool loadCharacterPackage(const QString &characterId, bool centerWindow);
void buildAnimationClips(); void buildAnimationClips();
void addStateTestActions(QMenu *menu); void addStateTestActions(QMenu *menu);
void startChat(); void startChat();
+195 -20
View File
@@ -9,6 +9,7 @@
#include <QComboBox> #include <QComboBox>
#include <QDialogButtonBox> #include <QDialogButtonBox>
#include <QDoubleSpinBox> #include <QDoubleSpinBox>
#include <QFileDialog>
#include <QFormLayout> #include <QFormLayout>
#include <QFrame> #include <QFrame>
#include <QHBoxLayout> #include <QHBoxLayout>
@@ -20,6 +21,7 @@
#include <QPair> #include <QPair>
#include <QPointer> #include <QPointer>
#include <QPushButton> #include <QPushButton>
#include <QDir>
#include <QSpinBox> #include <QSpinBox>
#include <QStackedWidget> #include <QStackedWidget>
#include <QStyle> #include <QStyle>
@@ -90,6 +92,9 @@ SettingsDialog::SettingsDialog(
, m_savedHistoryMessageLimitSpinBox(new QSpinBox(this)) , m_savedHistoryMessageLimitSpinBox(new QSpinBox(this))
, m_clearConversationHistoryButton(new QPushButton(QStringLiteral("清空聊天记录"), this)) , m_clearConversationHistoryButton(new QPushButton(QStringLiteral("清空聊天记录"), this))
, m_characterComboBox(new QComboBox(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_configStore(configStore)
, m_appConfig(appConfig) , m_appConfig(appConfig)
, m_aiTestBlocked(std::move(aiTestBlocked)) , m_aiTestBlocked(std::move(aiTestBlocked))
@@ -273,29 +278,13 @@ SettingsDialog::SettingsDialog(
auto *chatPage = new QWidget(this); auto *chatPage = new QWidget(this);
chatPage->setLayout(chatPageLayout); chatPage->setLayout(chatPageLayout);
QStringList characterIds = CharacterPackageRepository::availablePackageIds(); m_characterStatusLabel->setObjectName(QStringLiteral("HintLabel"));
if (characterIds.isEmpty()) m_characterStatusLabel->setWordWrap(true);
{ reloadCharacterList(m_appConfig.characterId);
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("角色选择将在多角色资源配置接入后启用。"));
auto *characterTitleLabel = new QLabel(QStringLiteral("角色"), this); auto *characterTitleLabel = new QLabel(QStringLiteral("角色"), this);
characterTitleLabel->setObjectName(QStringLiteral("PageTitle")); characterTitleLabel->setObjectName(QStringLiteral("PageTitle"));
auto *characterHintLabel = new QLabel(QStringLiteral("当前版本使用内置角色资源"), this); auto *characterHintLabel = new QLabel(QStringLiteral("支持导入本地角色文件夹。导入前会先验证角色包,验证失败不会复制或覆盖任何文件"), this);
characterHintLabel->setObjectName(QStringLiteral("HintLabel")); characterHintLabel->setObjectName(QStringLiteral("HintLabel"));
characterHintLabel->setWordWrap(true); characterHintLabel->setWordWrap(true);
@@ -306,11 +295,17 @@ SettingsDialog::SettingsDialog(
characterFormLayout->setVerticalSpacing(12); characterFormLayout->setVerticalSpacing(12);
characterFormLayout->addRow(QStringLiteral("当前角色"), m_characterComboBox); 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(); auto *characterPageLayout = new QVBoxLayout();
characterPageLayout->setContentsMargins(24, 24, 24, 24); characterPageLayout->setContentsMargins(24, 24, 24, 24);
characterPageLayout->setSpacing(16); characterPageLayout->setSpacing(16);
characterPageLayout->addWidget(characterTitleLabel); characterPageLayout->addWidget(characterTitleLabel);
characterPageLayout->addLayout(characterFormLayout); characterPageLayout->addLayout(characterFormLayout);
characterPageLayout->addLayout(characterActionLayout);
characterPageLayout->addWidget(characterHintLabel); characterPageLayout->addWidget(characterHintLabel);
characterPageLayout->addStretch(); characterPageLayout->addStretch();
@@ -404,6 +399,12 @@ SettingsDialog::SettingsDialog(
connect(m_clearConversationHistoryButton, &QPushButton::clicked, this, [this]() { connect(m_clearConversationHistoryButton, &QPushButton::clicked, this, [this]() {
this->clearConversationHistory(); this->clearConversationHistory();
}); });
connect(m_importCharacterButton, &QPushButton::clicked, this, [this]() {
importCharacterFolder();
});
connect(m_deleteCharacterButton, &QPushButton::clicked, this, [this]() {
deleteSelectedCharacter();
});
} }
SettingsDialog::~SettingsDialog() SettingsDialog::~SettingsDialog()
@@ -449,6 +450,11 @@ AppConfig SettingsDialog::appConfig() const
config.memoryHistoryMessageLimit = m_memoryHistoryMessageLimitSpinBox->value(); config.memoryHistoryMessageLimit = m_memoryHistoryMessageLimitSpinBox->value();
config.saveConversationHistory = m_saveConversationHistoryCheckBox->isChecked(); config.saveConversationHistory = m_saveConversationHistoryCheckBox->isChecked();
config.savedHistoryMessageLimit = m_savedHistoryMessageLimitSpinBox->value(); config.savedHistoryMessageLimit = m_savedHistoryMessageLimitSpinBox->value();
config.characterId = m_characterComboBox->currentData().toString().trimmed();
if (config.characterId.isEmpty())
{
config.characterId = CharacterPackageRepository::defaultCharacterId();
}
return config; return config;
} }
@@ -755,3 +761,172 @@ void SettingsDialog::clearConversationHistory()
m_clearConversationStatusLabel->setText(QStringLiteral("聊天记录已清空。")); 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<CharacterPackageInfo> 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));
}
+6
View File
@@ -45,6 +45,9 @@ private:
void testConnection(); void testConnection();
void setTestStatus(const QString &message, const QString &state); void setTestStatus(const QString &message, const QString &state);
void clearConversationHistory(); void clearConversationHistory();
void reloadCharacterList(const QString &selectedCharacterId = {});
void importCharacterFolder();
void deleteSelectedCharacter();
QComboBox *m_providerComboBox = nullptr; QComboBox *m_providerComboBox = nullptr;
QLineEdit *m_baseUrlEdit = nullptr; QLineEdit *m_baseUrlEdit = nullptr;
@@ -71,6 +74,9 @@ private:
QPushButton *m_clearConversationHistoryButton = nullptr; QPushButton *m_clearConversationHistoryButton = nullptr;
QLabel *m_clearConversationStatusLabel = nullptr; QLabel *m_clearConversationStatusLabel = nullptr;
QComboBox *m_characterComboBox = nullptr; QComboBox *m_characterComboBox = nullptr;
QPushButton *m_importCharacterButton = nullptr;
QPushButton *m_deleteCharacterButton = nullptr;
QLabel *m_characterStatusLabel = nullptr;
AIConfigStore m_configStore; AIConfigStore m_configStore;
AIConfigStore m_acceptedConfigStore; AIConfigStore m_acceptedConfigStore;
AppConfig m_appConfig; AppConfig m_appConfig;
+278
View File
@@ -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"