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/
dist/
cmake-build-*/
.vs/
.vscode/
+4
View File
@@ -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
+99 -5
View File
@@ -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/<characterId>/` 组织,后续新增角色包时放在同级子目录。基本结构
内置角色包按 `resources/characters/<characterId>/` 组织。用户导入的角色包不会写入安装目录,而是复制到用户数据目录
```text
QStandardPaths::AppDataLocation/characters/<characterId>/
```
角色包基本结构:
```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-<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
```
发布包应排除 `tools/``docs/``reports/``build/``.git/`,只保留运行必需文件、`resources/characters/``resources/icons/``LICENSE` 和必要说明。
发布包应排除 `tools/``docs/``reports/``build/``dist/``.git/`,只保留运行必需文件、`resources/characters/``resources/icons/``LICENSE` 和必要说明。
## AI 配置和聊天
+17 -4
View File
@@ -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-<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
1. 角色包导入和角色切换
1. 角色包导出和更完整管理界面
2. 对话历史导出、搜索或更完整管理界面
3. 发布前素材授权确认与打包验证
4. 长期性能压测记录
+6 -3
View File
@@ -84,7 +84,7 @@ cmake -S . -B build/mingw-debug -G Ninja `
## 3. 角色包约定
`resources/characters/shiroko` 目录作为当前默认角色包。角色`resources/characters/<characterId>/` 组织,后续新增角色包时放在同级子目录
`resources/characters/shiroko` 目录作为当前默认内置角色包。内置角色按 `resources/characters/<characterId>/` 组织,用户导入角色复制到 `QStandardPaths::AppDataLocation/characters/<characterId>/`
已检查到的结构:
@@ -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 分组管理
```
+12 -1
View File
@@ -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
+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"));
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"));
+602 -27
View File
@@ -1,28 +1,423 @@
#include "CharacterPackageRepository.h"
#include "CharacterPackageLoader.h"
#include "../util/ResourcePaths.h"
#include <QDateTime>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QImageReader>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonParseError>
#include <QJsonValue>
#include <QSet>
#include <QStandardPaths>
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();
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<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()))
{
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<CharacterPackageInfo> 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;
}
@@ -2,15 +2,55 @@
#include <QString>
#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
{
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<CharacterPackageInfo> 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);
};
+1
View File
@@ -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;
+14
View File
@@ -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))
+35 -5
View File
@@ -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()
+1
View File
@@ -54,6 +54,7 @@ protected:
private:
void loadInitialImage();
bool loadCharacterPackage(const QString &characterId, bool centerWindow);
void buildAnimationClips();
void addStateTestActions(QMenu *menu);
void startChat();
+195 -20
View File
@@ -9,6 +9,7 @@
#include <QComboBox>
#include <QDialogButtonBox>
#include <QDoubleSpinBox>
#include <QFileDialog>
#include <QFormLayout>
#include <QFrame>
#include <QHBoxLayout>
@@ -20,6 +21,7 @@
#include <QPair>
#include <QPointer>
#include <QPushButton>
#include <QDir>
#include <QSpinBox>
#include <QStackedWidget>
#include <QStyle>
@@ -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<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 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;
+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"