完善 AI 会话和配置隔离

This commit is contained in:
2026-05-29 11:28:41 +08:00
parent f4c7e4a08b
commit cc517e149d
10 changed files with 561 additions and 90 deletions
+2
View File
@@ -14,6 +14,8 @@ find_package(Qt6 REQUIRED COMPONENTS Widgets Network)
qt_add_executable(QtDesktopPet qt_add_executable(QtDesktopPet
main.cpp main.cpp
src/ai/ConversationManager.h
src/ai/ConversationManager.cpp
src/ai/LLMProvider.h src/ai/LLMProvider.h
src/ai/LLMTypes.h src/ai/LLMTypes.h
src/ai/OpenAICompatibleProvider.h src/ai/OpenAICompatibleProvider.h
+127
View File
@@ -0,0 +1,127 @@
#include "ConversationManager.h"
#include <utility>
ConversationManager::ConversationManager()
: m_systemPrompt(QStringLiteral("你是一个桌面宠物助手。回复要简短、自然,适合显示在桌宠气泡里。"))
{
}
ConversationManager::~ConversationManager()
{
cancel();
}
bool ConversationManager::isBusy() const
{
return m_provider && m_provider->isBusy();
}
bool ConversationManager::hasHistory() const
{
return !m_history.isEmpty();
}
bool ConversationManager::setProvider(std::unique_ptr<LLMProvider> provider)
{
if (isBusy())
{
return false;
}
m_provider = std::move(provider);
return true;
}
void ConversationManager::sendUserMessage(const QString &message, ResponseCallback callback)
{
const QString content = message.trimmed();
if (content.isEmpty())
{
if (callback)
{
callback({false, {}, QStringLiteral("Message is empty."), 0});
}
return;
}
if (!m_provider)
{
if (callback)
{
callback({false, {}, QStringLiteral("AI provider is not ready."), 0});
}
return;
}
if (isBusy())
{
if (callback)
{
callback({false, {}, QStringLiteral("AI request is already running."), 0});
}
return;
}
const ChatMessage userMessage{QStringLiteral("user"), content};
m_provider->sendChatRequest(buildRequest(userMessage), [this, userMessage, callback = std::move(callback)](const ChatResponse &response) mutable {
if (response.success)
{
appendExchange(userMessage, {QStringLiteral("assistant"), response.content});
}
if (callback)
{
callback(response);
}
});
}
void ConversationManager::cancel()
{
if (m_provider)
{
m_provider->cancel();
}
}
void ConversationManager::clear()
{
if (isBusy())
{
cancel();
}
m_history.clear();
}
ChatRequest ConversationManager::buildRequest(const ChatMessage &userMessage) const
{
ChatRequest request;
if (!m_systemPrompt.trimmed().isEmpty())
{
request.messages.append({QStringLiteral("system"), m_systemPrompt});
}
for (const ChatMessage &message : m_history)
{
request.messages.append(message);
}
request.messages.append(userMessage);
return request;
}
void ConversationManager::appendExchange(const ChatMessage &userMessage, const ChatMessage &assistantMessage)
{
m_history.append(userMessage);
m_history.append(assistantMessage);
trimHistory();
}
void ConversationManager::trimHistory()
{
while (m_history.size() > m_maxHistoryMessages)
{
m_history.removeFirst();
}
}
+34
View File
@@ -0,0 +1,34 @@
#pragma once
#include "LLMProvider.h"
#include <QString>
#include <QVector>
#include <memory>
class ConversationManager
{
public:
using ResponseCallback = LLMProvider::ResponseCallback;
ConversationManager();
~ConversationManager();
bool isBusy() const;
bool hasHistory() const;
bool setProvider(std::unique_ptr<LLMProvider> provider);
void sendUserMessage(const QString &message, ResponseCallback callback);
void cancel();
void clear();
private:
ChatRequest buildRequest(const ChatMessage &userMessage) const;
void appendExchange(const ChatMessage &userMessage, const ChatMessage &assistantMessage);
void trimHistory();
std::unique_ptr<LLMProvider> m_provider;
QVector<ChatMessage> m_history;
QString m_systemPrompt;
int m_maxHistoryMessages = 12;
};
+7
View File
@@ -1,5 +1,6 @@
#pragma once #pragma once
#include <QMap>
#include <QString> #include <QString>
struct AIConfig struct AIConfig
@@ -19,4 +20,10 @@ struct AIConfig
int maxTokens = 1024; int maxTokens = 1024;
}; };
struct AIConfigStore
{
QString activeProvider = QStringLiteral("custom");
QMap<QString, AIConfig> providers;
};
AIConfig defaultAIConfigForProvider(const QString &provider); AIConfig defaultAIConfigForProvider(const QString &provider);
+96 -24
View File
@@ -8,6 +8,7 @@
#include <QJsonDocument> #include <QJsonDocument>
#include <QJsonObject> #include <QJsonObject>
#include <QJsonParseError> #include <QJsonParseError>
#include <QJsonValue>
#include <QStandardPaths> #include <QStandardPaths>
namespace namespace
@@ -34,10 +35,16 @@ QJsonObject performanceObjectFromConfig(const AppConfig &config)
return performance; return performance;
} }
QJsonObject objectFromAIConfig(const AIConfig &config) QString normalizedProviderName(const QString &provider)
{
const QString normalized = provider.trimmed().toLower();
return normalized.isEmpty() ? QStringLiteral("custom") : normalized;
}
QJsonObject objectFromAIProviderConfig(const AIConfig &config)
{ {
QJsonObject root; QJsonObject root;
root.insert(QStringLiteral("provider"), config.provider); root.insert(QStringLiteral("provider"), normalizedProviderName(config.provider));
root.insert(QStringLiteral("protocol"), config.protocol); root.insert(QStringLiteral("protocol"), config.protocol);
root.insert(QStringLiteral("baseUrl"), config.baseUrl); root.insert(QStringLiteral("baseUrl"), config.baseUrl);
root.insert(QStringLiteral("model"), config.model); root.insert(QStringLiteral("model"), config.model);
@@ -55,6 +62,57 @@ QJsonObject objectFromAIConfig(const AIConfig &config)
root.insert(QStringLiteral("maxTokens"), config.maxTokens); root.insert(QStringLiteral("maxTokens"), config.maxTokens);
return root; return root;
} }
QJsonObject objectFromAIConfigStore(const AIConfigStore &store)
{
QJsonObject providers;
for (auto iterator = store.providers.constBegin(); iterator != store.providers.constEnd(); ++iterator)
{
const QString provider = normalizedProviderName(iterator.key());
AIConfig config = iterator.value();
config.provider = provider;
providers.insert(provider, objectFromAIProviderConfig(config));
}
QJsonObject root;
root.insert(QStringLiteral("activeProvider"), normalizedProviderName(store.activeProvider));
root.insert(QStringLiteral("providers"), providers);
return root;
}
AIConfig aiProviderConfigFromObject(const QString &provider, const QJsonObject &root)
{
AIConfig config = defaultAIConfigForProvider(provider);
config.provider = provider;
config.protocol = root.value(QStringLiteral("protocol")).toString(config.protocol);
config.baseUrl = root.value(QStringLiteral("baseUrl")).toString(config.baseUrl);
config.model = root.value(QStringLiteral("model")).toString(config.model);
config.path = root.value(QStringLiteral("path")).toString(config.path);
config.apiKeyStorage = root.value(QStringLiteral("apiKeyStorage")).toString(config.apiKeyStorage);
config.apiKeyEncrypted = root.value(QStringLiteral("apiKeyEncrypted")).toString(config.apiKeyEncrypted);
config.allowPlainApiKey = root.value(QStringLiteral("allowPlainApiKey")).toBool(config.allowPlainApiKey);
if (config.allowPlainApiKey && config.apiKeyStorage == QStringLiteral("plain-json"))
{
config.apiKey = root.value(QStringLiteral("apiKey")).toString(config.apiKey);
}
config.stream = root.value(QStringLiteral("stream")).toBool(config.stream);
config.timeoutMs = root.value(QStringLiteral("timeoutMs")).toInt(config.timeoutMs);
config.temperature = root.value(QStringLiteral("temperature")).toDouble(config.temperature);
config.maxTokens = root.value(QStringLiteral("maxTokens")).toInt(config.maxTokens);
return config;
}
AIConfig activeAIProviderConfig(const AIConfigStore &store)
{
const QString activeProvider = normalizedProviderName(store.activeProvider);
const auto iterator = store.providers.constFind(activeProvider);
if (iterator != store.providers.constEnd())
{
return iterator.value();
}
return defaultAIConfigForProvider(activeProvider);
}
} }
ConfigManager::ConfigManager() = default; ConfigManager::ConfigManager() = default;
@@ -124,20 +182,20 @@ AppConfig ConfigManager::loadAppConfig() const
return config; return config;
} }
AIConfig ConfigManager::loadAIConfig() const AIConfigStore ConfigManager::loadAIConfigStore() const
{ {
AIConfig config; AIConfigStore store;
QFile file(aiConfigPath()); QFile file(aiConfigPath());
if (!file.exists()) if (!file.exists())
{ {
return config; return store;
} }
if (!file.open(QIODevice::ReadOnly)) if (!file.open(QIODevice::ReadOnly))
{ {
Logger::warning(QStringLiteral("Unable to read AI config.")); Logger::warning(QStringLiteral("Unable to read AI config."));
return config; return store;
} }
QJsonParseError parseError; QJsonParseError parseError;
@@ -147,27 +205,30 @@ AIConfig ConfigManager::loadAIConfig() const
file.close(); file.close();
backupBrokenConfig(aiConfigPath()); backupBrokenConfig(aiConfigPath());
Logger::warning(QStringLiteral("AI config is broken; default config will be used.")); Logger::warning(QStringLiteral("AI config is broken; default config will be used."));
return config; return store;
} }
const QJsonObject root = document.object(); const QJsonObject root = document.object();
config.provider = root.value(QStringLiteral("provider")).toString(config.provider); store.activeProvider = normalizedProviderName(root.value(QStringLiteral("activeProvider")).toString(store.activeProvider));
config.protocol = root.value(QStringLiteral("protocol")).toString(root.value(QStringLiteral("providerType")).toString(config.protocol));
config.baseUrl = root.value(QStringLiteral("baseUrl")).toString(config.baseUrl); const QJsonObject providers = root.value(QStringLiteral("providers")).toObject();
config.model = root.value(QStringLiteral("model")).toString(config.model); for (auto iterator = providers.constBegin(); iterator != providers.constEnd(); ++iterator)
config.path = root.value(QStringLiteral("path")).toString(config.path);
config.apiKeyStorage = root.value(QStringLiteral("apiKeyStorage")).toString(config.apiKeyStorage);
config.apiKeyEncrypted = root.value(QStringLiteral("apiKeyEncrypted")).toString(config.apiKeyEncrypted);
config.allowPlainApiKey = root.value(QStringLiteral("allowPlainApiKey")).toBool(config.allowPlainApiKey);
if (config.allowPlainApiKey && config.apiKeyStorage == QStringLiteral("plain-json"))
{ {
config.apiKey = root.value(QStringLiteral("apiKey")).toString(config.apiKey); if (!iterator.value().isObject())
{
continue;
} }
config.stream = root.value(QStringLiteral("stream")).toBool(config.stream);
config.timeoutMs = root.value(QStringLiteral("timeoutMs")).toInt(config.timeoutMs); const QString provider = normalizedProviderName(iterator.key());
config.temperature = root.value(QStringLiteral("temperature")).toDouble(config.temperature); store.providers.insert(provider, aiProviderConfigFromObject(provider, iterator.value().toObject()));
config.maxTokens = root.value(QStringLiteral("maxTokens")).toInt(config.maxTokens); }
return config;
return store;
}
AIConfig ConfigManager::loadAIConfig() const
{
return activeAIProviderConfig(loadAIConfigStore());
} }
bool ConfigManager::saveAppConfig(const AppConfig &config) const bool ConfigManager::saveAppConfig(const AppConfig &config) const
@@ -195,7 +256,7 @@ bool ConfigManager::saveAppConfig(const AppConfig &config) const
return file.write(document.toJson(QJsonDocument::Indented)) >= 0; return file.write(document.toJson(QJsonDocument::Indented)) >= 0;
} }
bool ConfigManager::saveAIConfig(const AIConfig &config) const bool ConfigManager::saveAIConfigStore(const AIConfigStore &store) const
{ {
const QString directoryPath = configDirectoryPath(); const QString directoryPath = configDirectoryPath();
QDir directory(directoryPath); QDir directory(directoryPath);
@@ -212,10 +273,21 @@ bool ConfigManager::saveAIConfig(const AIConfig &config) const
return false; return false;
} }
const QJsonDocument document(objectFromAIConfig(config)); const QJsonDocument document(objectFromAIConfigStore(store));
return file.write(document.toJson(QJsonDocument::Indented)) >= 0; return file.write(document.toJson(QJsonDocument::Indented)) >= 0;
} }
bool ConfigManager::saveAIConfig(const AIConfig &config) const
{
AIConfig normalizedConfig = config;
normalizedConfig.provider = normalizedProviderName(normalizedConfig.provider);
AIConfigStore store = loadAIConfigStore();
store.activeProvider = normalizedConfig.provider;
store.providers.insert(normalizedConfig.provider, normalizedConfig);
return saveAIConfigStore(store);
}
QString ConfigManager::appConfigPath() const QString ConfigManager::appConfigPath() const
{ {
return QDir(configDirectoryPath()).filePath(AppConfigFileName); return QDir(configDirectoryPath()).filePath(AppConfigFileName);
+2
View File
@@ -11,8 +11,10 @@ public:
ConfigManager(); ConfigManager();
AppConfig loadAppConfig() const; AppConfig loadAppConfig() const;
AIConfigStore loadAIConfigStore() const;
AIConfig loadAIConfig() const; AIConfig loadAIConfig() const;
bool saveAppConfig(const AppConfig &config) const; bool saveAppConfig(const AppConfig &config) const;
bool saveAIConfigStore(const AIConfigStore &store) const;
bool saveAIConfig(const AIConfig &config) const; bool saveAIConfig(const AIConfig &config) const;
QString appConfigPath() const; QString appConfigPath() const;
QString aiConfigPath() const; QString aiConfigPath() const;
+197 -32
View File
@@ -1,5 +1,6 @@
#include "PetWindow.h" #include "PetWindow.h"
#include "../ai/ConversationManager.h"
#include "../ai/OpenAICompatibleProvider.h" #include "../ai/OpenAICompatibleProvider.h"
#include "../character/CharacterPackageLoader.h" #include "../character/CharacterPackageLoader.h"
#include "../config/ConfigManager.h" #include "../config/ConfigManager.h"
@@ -14,6 +15,7 @@
#include <QCursor> #include <QCursor>
#include <QDialog> #include <QDialog>
#include <QGuiApplication> #include <QGuiApplication>
#include <QInputDialog>
#include <QMenu> #include <QMenu>
#include <QMouseEvent> #include <QMouseEvent>
#include <QPixmap> #include <QPixmap>
@@ -38,6 +40,8 @@ QString previewImagePath()
return QStringLiteral(PET_SOURCE_DIR) + QStringLiteral("/shiroko/preview.png"); return QStringLiteral(PET_SOURCE_DIR) + QStringLiteral("/shiroko/preview.png");
} }
constexpr int MaxUserMessageLength = 4000;
bool populateRuntimeApiKey(AIConfig &config, QString *errorMessage) bool populateRuntimeApiKey(AIConfig &config, QString *errorMessage)
{ {
if (!config.apiKey.trimmed().isEmpty()) if (!config.apiKey.trimmed().isEmpty())
@@ -71,11 +75,56 @@ bool populateRuntimeApiKey(AIConfig &config, QString *errorMessage)
return true; return true;
} }
bool prepareRuntimeAIConfig(AIConfig &config, QString *errorMessage)
{
if (config.protocol != QStringLiteral("openai-compatible"))
{
*errorMessage = QStringLiteral("当前 Provider 协议暂未接入。");
return false;
}
if (!populateRuntimeApiKey(config, errorMessage))
{
return false;
}
if (config.baseUrl.trimmed().isEmpty())
{
*errorMessage = QStringLiteral("请先在设置里配置 Base URL。");
return false;
}
if (config.model.trimmed().isEmpty())
{
*errorMessage = QStringLiteral("请先在设置里配置 Model。");
return false;
}
return true;
}
QString userVisibleErrorMessage(const ChatResponse &response)
{
QString message = response.errorMessage.trimmed();
if (message.isEmpty())
{
message = QStringLiteral("未知错误。");
}
if (response.httpStatus > 0)
{
message = QStringLiteral("HTTP ") + QString::number(response.httpStatus) + QStringLiteral("") + message;
}
return message;
}
} }
PetWindow::PetWindow(QWidget *parent) PetWindow::PetWindow(QWidget *parent)
: QWidget(parent) : QWidget(parent)
, m_chatBubble(std::make_unique<ChatBubble>()) , m_chatBubble(std::make_unique<ChatBubble>())
, m_conversationManager(std::make_unique<ConversationManager>())
, m_petView(new PetView(this)) , m_petView(new PetView(this))
, m_dragging(false) , m_dragging(false)
, m_alwaysOnTop(true) , m_alwaysOnTop(true)
@@ -174,7 +223,15 @@ void PetWindow::contextMenuEvent(QContextMenuEvent *event)
QAction *maxBubbleAction = bubbleTestMenu->addAction(QStringLiteral("接近最大尺寸")); QAction *maxBubbleAction = bubbleTestMenu->addAction(QStringLiteral("接近最大尺寸"));
QAction *scrollBubbleAction = bubbleTestMenu->addAction(QStringLiteral("超长滚动文本")); QAction *scrollBubbleAction = bubbleTestMenu->addAction(QStringLiteral("超长滚动文本"));
const bool aiRequestRunning = hasActiveAIRequest();
QAction *aiTestAction = menu.addAction(QStringLiteral("AI 测试")); QAction *aiTestAction = menu.addAction(QStringLiteral("AI 测试"));
aiTestAction->setEnabled(!aiRequestRunning);
QAction *chatAction = menu.addAction(QStringLiteral("聊天"));
chatAction->setEnabled(!aiRequestRunning);
QAction *cancelAIAction = menu.addAction(QStringLiteral("取消 AI 请求"));
cancelAIAction->setEnabled(aiRequestRunning);
QAction *clearConversationAction = menu.addAction(QStringLiteral("清空对话"));
clearConversationAction->setEnabled(m_conversationManager && m_conversationManager->hasHistory());
QAction *settingsAction = menu.addAction(QStringLiteral("设置")); QAction *settingsAction = menu.addAction(QStringLiteral("设置"));
addStateTestActions(&menu); addStateTestActions(&menu);
@@ -203,11 +260,23 @@ void PetWindow::contextMenuEvent(QContextMenuEvent *event)
{ {
startAITest(); startAITest();
} }
else if (selectedAction == chatAction)
{
startChat();
}
else if (selectedAction == cancelAIAction)
{
cancelActiveAIRequest();
}
else if (selectedAction == clearConversationAction)
{
clearConversation();
}
else if (selectedAction == settingsAction) else if (selectedAction == settingsAction)
{ {
ConfigManager configManager; ConfigManager configManager;
SettingsDialog dialog(configManager.loadAIConfig(), this); SettingsDialog dialog(configManager.loadAIConfigStore(), this);
if (dialog.exec() == QDialog::Accepted && !configManager.saveAIConfig(dialog.aiConfig())) if (dialog.exec() == QDialog::Accepted && !configManager.saveAIConfigStore(dialog.aiConfigStore()))
{ {
Logger::warning(QStringLiteral("Failed to save AI config from settings dialog.")); Logger::warning(QStringLiteral("Failed to save AI config from settings dialog."));
} }
@@ -224,53 +293,38 @@ void PetWindow::contextMenuEvent(QContextMenuEvent *event)
void PetWindow::startAITest() void PetWindow::startAITest()
{ {
if (m_aiProvider && m_aiProvider->isBusy()) if (m_aiTestProvider && m_aiTestProvider->isBusy())
{ {
showBubbleMessage(QStringLiteral("AI 测试请求正在进行。")); showBubbleMessage(QStringLiteral("AI 测试请求正在进行。"));
return; return;
} }
ConfigManager configManager; if (m_conversationManager && m_conversationManager->isBusy())
AIConfig config = configManager.loadAIConfig();
if (config.protocol != QStringLiteral("openai-compatible"))
{ {
playState(QStringLiteral("error"), false); showBubbleMessage(QStringLiteral("AI 回复正在进行。"));
showBubbleMessage(QStringLiteral("当前 Provider 协议暂未接入 AI 测试。"));
return; return;
} }
ConfigManager configManager;
AIConfig config = configManager.loadAIConfig();
QString errorMessage; QString errorMessage;
if (!populateRuntimeApiKey(config, &errorMessage)) if (!prepareRuntimeAIConfig(config, &errorMessage))
{ {
playState(QStringLiteral("error"), false); playState(QStringLiteral("error"), false);
showBubbleMessage(errorMessage); showBubbleMessage(errorMessage);
return; return;
} }
if (config.baseUrl.trimmed().isEmpty())
{
playState(QStringLiteral("error"), false);
showBubbleMessage(QStringLiteral("请先在设置里配置 Base URL。"));
return;
}
if (config.model.trimmed().isEmpty())
{
playState(QStringLiteral("error"), false);
showBubbleMessage(QStringLiteral("请先在设置里配置 Model。"));
return;
}
ChatRequest request; ChatRequest request;
request.messages.append({QStringLiteral("system"), QStringLiteral("你是桌宠的最小连通性测试助手。")}); request.messages.append({QStringLiteral("system"), QStringLiteral("你是桌宠的最小连通性测试助手。")});
request.messages.append({QStringLiteral("user"), QStringLiteral("请用一句话回复测试成功。")}); request.messages.append({QStringLiteral("user"), QStringLiteral("请用一句话回复测试成功。")});
m_aiProvider = std::make_unique<OpenAICompatibleProvider>(config); m_aiTestProvider = std::make_unique<OpenAICompatibleProvider>(config);
playState(QStringLiteral("think"), false); playState(QStringLiteral("think"), false);
showBubbleMessage(QStringLiteral("正在测试 AI 连接...")); showBubbleMessage(QStringLiteral("正在测试 AI 连接..."));
QPointer<PetWindow> window(this); QPointer<PetWindow> window(this);
m_aiProvider->sendChatRequest(request, [window](const ChatResponse &response) { m_aiTestProvider->sendChatRequest(request, [window](const ChatResponse &response) {
if (window.isNull()) if (window.isNull())
{ {
return; return;
@@ -283,22 +337,133 @@ void PetWindow::startAITest()
return; return;
} }
QString message = response.errorMessage.trimmed(); window->playState(QStringLiteral("error"), false);
if (message.isEmpty()) window->showBubbleMessage(QStringLiteral("AI 测试失败:") + userVisibleErrorMessage(response));
{ });
message = QStringLiteral("未知错误。");
} }
if (response.httpStatus > 0) void PetWindow::startChat()
{ {
message = QStringLiteral("HTTP ") + QString::number(response.httpStatus) + QStringLiteral("") + message; if (m_aiTestProvider && m_aiTestProvider->isBusy())
{
showBubbleMessage(QStringLiteral("AI 测试请求正在进行。"));
return;
}
if (!m_conversationManager || m_conversationManager->isBusy())
{
showBubbleMessage(QStringLiteral("AI 回复正在进行。"));
return;
}
bool accepted = false;
const QString message = QInputDialog::getMultiLineText(
this,
QStringLiteral("聊天"),
QStringLiteral("输入消息"),
{},
&accepted).trimmed();
if (!accepted || message.isEmpty())
{
return;
}
if (message.size() > MaxUserMessageLength)
{
playState(QStringLiteral("error"), false);
showBubbleMessage(QStringLiteral("消息太长,请控制在 4000 字以内。"));
return;
}
ConfigManager configManager;
AIConfig config = configManager.loadAIConfig();
QString errorMessage;
if (!prepareRuntimeAIConfig(config, &errorMessage))
{
playState(QStringLiteral("error"), false);
showBubbleMessage(errorMessage);
return;
}
if (!m_conversationManager->setProvider(std::make_unique<OpenAICompatibleProvider>(config)))
{
showBubbleMessage(QStringLiteral("AI 回复正在进行。"));
return;
}
playState(QStringLiteral("think"), false);
showBubbleMessage(QStringLiteral("正在思考..."));
QPointer<PetWindow> window(this);
m_conversationManager->sendUserMessage(message, [window](const ChatResponse &response) {
if (window.isNull())
{
return;
}
if (response.success)
{
window->playState(QStringLiteral("talk"), false);
window->showBubbleMessage(response.content);
return;
} }
window->playState(QStringLiteral("error"), false); window->playState(QStringLiteral("error"), false);
window->showBubbleMessage(QStringLiteral("AI 测试失败:") + message); window->showBubbleMessage(QStringLiteral("AI 回复失败:") + userVisibleErrorMessage(response));
}); });
} }
void PetWindow::clearConversation()
{
if (!m_conversationManager)
{
return;
}
const bool hadActiveRequest = hasActiveAIRequest();
if (m_aiTestProvider && m_aiTestProvider->isBusy())
{
m_aiTestProvider->cancel();
}
m_conversationManager->clear();
showBubbleMessage(hadActiveRequest
? QStringLiteral("已取消 AI 请求,并清空对话。")
: QStringLiteral("对话已清空。"));
playState(QStringLiteral("idle"), false);
}
void PetWindow::cancelActiveAIRequest()
{
bool canceled = false;
if (m_aiTestProvider && m_aiTestProvider->isBusy())
{
m_aiTestProvider->cancel();
canceled = true;
}
if (m_conversationManager && m_conversationManager->isBusy())
{
m_conversationManager->cancel();
canceled = true;
}
if (!canceled)
{
showBubbleMessage(QStringLiteral("没有正在进行的 AI 请求。"));
return;
}
showBubbleMessage(QStringLiteral("AI 请求已取消。"));
playState(QStringLiteral("idle"), false);
}
bool PetWindow::hasActiveAIRequest() const
{
return (m_aiTestProvider && m_aiTestProvider->isBusy())
|| (m_conversationManager && m_conversationManager->isBusy());
}
void PetWindow::mouseMoveEvent(QMouseEvent *event) void PetWindow::mouseMoveEvent(QMouseEvent *event)
{ {
if (m_dragging && (event->buttons() & Qt::LeftButton)) if (m_dragging && (event->buttons() & Qt::LeftButton))
+7 -1
View File
@@ -17,6 +17,7 @@ class QMenu;
class QMoveEvent; class QMoveEvent;
class QPixmap; class QPixmap;
class ChatBubble; class ChatBubble;
class ConversationManager;
class LLMProvider; class LLMProvider;
class PetView; class PetView;
@@ -44,6 +45,10 @@ private:
void buildAnimationClips(); void buildAnimationClips();
void addStateTestActions(QMenu *menu); void addStateTestActions(QMenu *menu);
void startAITest(); void startAITest();
void startChat();
void clearConversation();
void cancelActiveAIRequest();
bool hasActiveAIRequest() const;
void updateBubblePosition(); void updateBubblePosition();
QPoint bubbleAnchorPosition() const; QPoint bubbleAnchorPosition() const;
void playState(const QString &stateName, bool centerWindow); void playState(const QString &stateName, bool centerWindow);
@@ -57,7 +62,8 @@ private:
void setAlwaysOnTop(bool enabled); void setAlwaysOnTop(bool enabled);
std::unique_ptr<ChatBubble> m_chatBubble; std::unique_ptr<ChatBubble> m_chatBubble;
std::unique_ptr<LLMProvider> m_aiProvider; std::unique_ptr<ConversationManager> m_conversationManager;
std::unique_ptr<LLMProvider> m_aiTestProvider;
PetView *m_petView; PetView *m_petView;
QTimer m_idleBehaviorTimer; QTimer m_idleBehaviorTimer;
QTimer m_behaviorReturnTimer; QTimer m_behaviorReturnTimer;
+81 -30
View File
@@ -14,7 +14,16 @@
#include <QSpinBox> #include <QSpinBox>
#include <QVBoxLayout> #include <QVBoxLayout>
SettingsDialog::SettingsDialog(const AIConfig &config, QWidget *parent) namespace
{
QString normalizedProviderName(const QString &provider)
{
const QString normalized = provider.trimmed().toLower();
return normalized.isEmpty() ? QStringLiteral("custom") : normalized;
}
}
SettingsDialog::SettingsDialog(const AIConfigStore &configStore, QWidget *parent)
: QDialog(parent) : QDialog(parent)
, m_providerComboBox(new QComboBox(this)) , m_providerComboBox(new QComboBox(this))
, m_baseUrlEdit(new QLineEdit(this)) , m_baseUrlEdit(new QLineEdit(this))
@@ -25,7 +34,7 @@ SettingsDialog::SettingsDialog(const AIConfig &config, QWidget *parent)
, m_temperatureSpinBox(new QDoubleSpinBox(this)) , m_temperatureSpinBox(new QDoubleSpinBox(this))
, m_maxTokensSpinBox(new QSpinBox(this)) , m_maxTokensSpinBox(new QSpinBox(this))
, m_allowPlainApiKeyCheckBox(new QCheckBox(QStringLiteral("允许在非 Windows 环境明文保存 API Key"), this)) , m_allowPlainApiKeyCheckBox(new QCheckBox(QStringLiteral("允许在非 Windows 环境明文保存 API Key"), this))
, m_initialConfig(config) , m_configStore(configStore)
{ {
setWindowTitle(QStringLiteral("设置")); setWindowTitle(QStringLiteral("设置"));
setModal(true); setModal(true);
@@ -44,39 +53,24 @@ SettingsDialog::SettingsDialog(const AIConfig &config, QWidget *parent)
m_providerComboBox->addItem(provider.second, provider.first); m_providerComboBox->addItem(provider.second, provider.first);
} }
const int providerIndex = m_providerComboBox->findData(config.provider); const QString activeProvider = normalizedProviderName(m_configStore.activeProvider);
const int providerIndex = m_providerComboBox->findData(activeProvider);
m_providerComboBox->setCurrentIndex(providerIndex >= 0 ? providerIndex : m_providerComboBox->findData(QStringLiteral("custom"))); m_providerComboBox->setCurrentIndex(providerIndex >= 0 ? providerIndex : m_providerComboBox->findData(QStringLiteral("custom")));
QString apiKey = config.apiKey;
if (config.apiKeyStorage == QStringLiteral("windows-dpapi") && !config.apiKeyEncrypted.isEmpty())
{
const SecretStore::Result result = SecretStore::unprotectText(config.apiKeyEncrypted);
if (result.success)
{
apiKey = result.value;
}
}
m_baseUrlEdit->setText(config.baseUrl);
m_apiKeyEdit->setEchoMode(QLineEdit::Password); m_apiKeyEdit->setEchoMode(QLineEdit::Password);
m_apiKeyEdit->setText(apiKey);
m_modelEdit->setText(config.model);
m_pathEdit->setText(config.path);
m_timeoutSpinBox->setRange(1000, 300000); m_timeoutSpinBox->setRange(1000, 300000);
m_timeoutSpinBox->setSingleStep(1000); m_timeoutSpinBox->setSingleStep(1000);
m_timeoutSpinBox->setValue(config.timeoutMs);
m_temperatureSpinBox->setRange(0.0, 2.0); m_temperatureSpinBox->setRange(0.0, 2.0);
m_temperatureSpinBox->setSingleStep(0.1); m_temperatureSpinBox->setSingleStep(0.1);
m_temperatureSpinBox->setDecimals(2); m_temperatureSpinBox->setDecimals(2);
m_temperatureSpinBox->setValue(config.temperature);
m_maxTokensSpinBox->setRange(1, 200000); m_maxTokensSpinBox->setRange(1, 200000);
m_maxTokensSpinBox->setValue(config.maxTokens);
m_allowPlainApiKeyCheckBox->setChecked(config.allowPlainApiKey);
m_allowPlainApiKeyCheckBox->setVisible(!SecretStore::isEncryptionAvailable()); m_allowPlainApiKeyCheckBox->setVisible(!SecretStore::isEncryptionAvailable());
m_currentProvider = normalizedProviderName(m_providerComboBox->currentData().toString());
loadProviderConfig(m_currentProvider);
auto *formLayout = new QFormLayout(); auto *formLayout = new QFormLayout();
formLayout->addRow(QStringLiteral("服务商"), m_providerComboBox); formLayout->addRow(QStringLiteral("服务商"), m_providerComboBox);
@@ -105,15 +99,63 @@ SettingsDialog::SettingsDialog(const AIConfig &config, QWidget *parent)
layout->addWidget(buttonBox); layout->addWidget(buttonBox);
connect(m_providerComboBox, &QComboBox::currentTextChanged, this, [this]() { connect(m_providerComboBox, &QComboBox::currentTextChanged, this, [this]() {
applyProviderPreset(m_providerComboBox->currentData().toString()); switchProvider(m_providerComboBox->currentData().toString());
}); });
} }
AIConfig SettingsDialog::aiConfig() const AIConfigStore SettingsDialog::aiConfigStore() const
{ {
AIConfig config = m_initialConfig; AIConfigStore store = m_configStore;
config.provider = m_providerComboBox->currentData().toString(); const QString provider = normalizedProviderName(m_providerComboBox->currentData().toString());
config.protocol = defaultAIConfigForProvider(config.provider).protocol; store.activeProvider = provider;
store.providers.insert(provider, configFromForm(provider));
return store;
}
void SettingsDialog::cacheCurrentProvider()
{
if (m_currentProvider.isEmpty())
{
return;
}
m_configStore.providers.insert(m_currentProvider, configFromForm(m_currentProvider));
}
void SettingsDialog::loadProviderConfig(const QString &provider)
{
const QString normalizedProvider = normalizedProviderName(provider);
const AIConfig config = m_configStore.providers.value(normalizedProvider, defaultAIConfigForProvider(normalizedProvider));
m_baseUrlEdit->setText(config.baseUrl);
m_apiKeyEdit->setText(decryptedApiKey(config));
m_modelEdit->setText(config.model);
m_pathEdit->setText(config.path);
m_timeoutSpinBox->setValue(config.timeoutMs);
m_temperatureSpinBox->setValue(config.temperature);
m_maxTokensSpinBox->setValue(config.maxTokens);
m_allowPlainApiKeyCheckBox->setChecked(config.allowPlainApiKey);
}
void SettingsDialog::switchProvider(const QString &provider)
{
const QString normalizedProvider = normalizedProviderName(provider);
if (normalizedProvider == m_currentProvider)
{
return;
}
cacheCurrentProvider();
loadProviderConfig(normalizedProvider);
m_currentProvider = normalizedProvider;
}
AIConfig SettingsDialog::configFromForm(const QString &provider) const
{
const QString normalizedProvider = normalizedProviderName(provider);
AIConfig config = m_configStore.providers.value(normalizedProvider, defaultAIConfigForProvider(normalizedProvider));
config.provider = normalizedProvider;
config.protocol = defaultAIConfigForProvider(normalizedProvider).protocol;
config.baseUrl = m_baseUrlEdit->text().trimmed(); config.baseUrl = m_baseUrlEdit->text().trimmed();
config.model = m_modelEdit->text().trimmed(); config.model = m_modelEdit->text().trimmed();
config.path = m_pathEdit->text().trimmed(); config.path = m_pathEdit->text().trimmed();
@@ -156,9 +198,18 @@ AIConfig SettingsDialog::aiConfig() const
return config; return config;
} }
void SettingsDialog::applyProviderPreset(const QString &provider) QString SettingsDialog::decryptedApiKey(const AIConfig &config) const
{ {
const AIConfig preset = defaultAIConfigForProvider(provider); if (config.apiKeyStorage == QStringLiteral("windows-dpapi") && !config.apiKeyEncrypted.isEmpty())
m_baseUrlEdit->setText(preset.baseUrl); {
m_pathEdit->setText(preset.path); const SecretStore::Result result = SecretStore::unprotectText(config.apiKeyEncrypted);
if (result.success)
{
return result.value;
}
return {};
}
return config.apiKey;
} }
+9 -4
View File
@@ -13,12 +13,16 @@ class QSpinBox;
class SettingsDialog : public QDialog class SettingsDialog : public QDialog
{ {
public: public:
explicit SettingsDialog(const AIConfig &config, QWidget *parent = nullptr); explicit SettingsDialog(const AIConfigStore &configStore, QWidget *parent = nullptr);
AIConfig aiConfig() const; AIConfigStore aiConfigStore() const;
private: private:
void applyProviderPreset(const QString &provider); void cacheCurrentProvider();
void loadProviderConfig(const QString &provider);
void switchProvider(const QString &provider);
AIConfig configFromForm(const QString &provider) const;
QString decryptedApiKey(const AIConfig &config) const;
QComboBox *m_providerComboBox = nullptr; QComboBox *m_providerComboBox = nullptr;
QLineEdit *m_baseUrlEdit = nullptr; QLineEdit *m_baseUrlEdit = nullptr;
@@ -29,5 +33,6 @@ private:
QDoubleSpinBox *m_temperatureSpinBox = nullptr; QDoubleSpinBox *m_temperatureSpinBox = nullptr;
QSpinBox *m_maxTokensSpinBox = nullptr; QSpinBox *m_maxTokensSpinBox = nullptr;
QCheckBox *m_allowPlainApiKeyCheckBox = nullptr; QCheckBox *m_allowPlainApiKeyCheckBox = nullptr;
AIConfig m_initialConfig; AIConfigStore m_configStore;
QString m_currentProvider;
}; };