完善 AI 会话和配置隔离
This commit is contained in:
@@ -14,6 +14,8 @@ find_package(Qt6 REQUIRED COMPONENTS Widgets Network)
|
||||
|
||||
qt_add_executable(QtDesktopPet
|
||||
main.cpp
|
||||
src/ai/ConversationManager.h
|
||||
src/ai/ConversationManager.cpp
|
||||
src/ai/LLMProvider.h
|
||||
src/ai/LLMTypes.h
|
||||
src/ai/OpenAICompatibleProvider.h
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
#include <QMap>
|
||||
#include <QString>
|
||||
|
||||
struct AIConfig
|
||||
@@ -19,4 +20,10 @@ struct AIConfig
|
||||
int maxTokens = 1024;
|
||||
};
|
||||
|
||||
struct AIConfigStore
|
||||
{
|
||||
QString activeProvider = QStringLiteral("custom");
|
||||
QMap<QString, AIConfig> providers;
|
||||
};
|
||||
|
||||
AIConfig defaultAIConfigForProvider(const QString &provider);
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QJsonParseError>
|
||||
#include <QJsonValue>
|
||||
#include <QStandardPaths>
|
||||
|
||||
namespace
|
||||
@@ -34,10 +35,16 @@ QJsonObject performanceObjectFromConfig(const AppConfig &config)
|
||||
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;
|
||||
root.insert(QStringLiteral("provider"), config.provider);
|
||||
root.insert(QStringLiteral("provider"), normalizedProviderName(config.provider));
|
||||
root.insert(QStringLiteral("protocol"), config.protocol);
|
||||
root.insert(QStringLiteral("baseUrl"), config.baseUrl);
|
||||
root.insert(QStringLiteral("model"), config.model);
|
||||
@@ -55,6 +62,57 @@ QJsonObject objectFromAIConfig(const AIConfig &config)
|
||||
root.insert(QStringLiteral("maxTokens"), config.maxTokens);
|
||||
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;
|
||||
@@ -124,20 +182,20 @@ AppConfig ConfigManager::loadAppConfig() const
|
||||
return config;
|
||||
}
|
||||
|
||||
AIConfig ConfigManager::loadAIConfig() const
|
||||
AIConfigStore ConfigManager::loadAIConfigStore() const
|
||||
{
|
||||
AIConfig config;
|
||||
AIConfigStore store;
|
||||
|
||||
QFile file(aiConfigPath());
|
||||
if (!file.exists())
|
||||
{
|
||||
return config;
|
||||
return store;
|
||||
}
|
||||
|
||||
if (!file.open(QIODevice::ReadOnly))
|
||||
{
|
||||
Logger::warning(QStringLiteral("Unable to read AI config."));
|
||||
return config;
|
||||
return store;
|
||||
}
|
||||
|
||||
QJsonParseError parseError;
|
||||
@@ -147,27 +205,30 @@ AIConfig ConfigManager::loadAIConfig() const
|
||||
file.close();
|
||||
backupBrokenConfig(aiConfigPath());
|
||||
Logger::warning(QStringLiteral("AI config is broken; default config will be used."));
|
||||
return config;
|
||||
return store;
|
||||
}
|
||||
|
||||
const QJsonObject root = document.object();
|
||||
config.provider = root.value(QStringLiteral("provider")).toString(config.provider);
|
||||
config.protocol = root.value(QStringLiteral("protocol")).toString(root.value(QStringLiteral("providerType")).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"))
|
||||
store.activeProvider = normalizedProviderName(root.value(QStringLiteral("activeProvider")).toString(store.activeProvider));
|
||||
|
||||
const QJsonObject providers = root.value(QStringLiteral("providers")).toObject();
|
||||
for (auto iterator = providers.constBegin(); iterator != providers.constEnd(); ++iterator)
|
||||
{
|
||||
config.apiKey = root.value(QStringLiteral("apiKey")).toString(config.apiKey);
|
||||
if (!iterator.value().isObject())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
const QString provider = normalizedProviderName(iterator.key());
|
||||
store.providers.insert(provider, aiProviderConfigFromObject(provider, iterator.value().toObject()));
|
||||
}
|
||||
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;
|
||||
|
||||
return store;
|
||||
}
|
||||
|
||||
AIConfig ConfigManager::loadAIConfig() const
|
||||
{
|
||||
return activeAIProviderConfig(loadAIConfigStore());
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
bool ConfigManager::saveAIConfig(const AIConfig &config) const
|
||||
bool ConfigManager::saveAIConfigStore(const AIConfigStore &store) const
|
||||
{
|
||||
const QString directoryPath = configDirectoryPath();
|
||||
QDir directory(directoryPath);
|
||||
@@ -212,10 +273,21 @@ bool ConfigManager::saveAIConfig(const AIConfig &config) const
|
||||
return false;
|
||||
}
|
||||
|
||||
const QJsonDocument document(objectFromAIConfig(config));
|
||||
const QJsonDocument document(objectFromAIConfigStore(store));
|
||||
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
|
||||
{
|
||||
return QDir(configDirectoryPath()).filePath(AppConfigFileName);
|
||||
|
||||
@@ -11,8 +11,10 @@ public:
|
||||
ConfigManager();
|
||||
|
||||
AppConfig loadAppConfig() const;
|
||||
AIConfigStore loadAIConfigStore() const;
|
||||
AIConfig loadAIConfig() const;
|
||||
bool saveAppConfig(const AppConfig &config) const;
|
||||
bool saveAIConfigStore(const AIConfigStore &store) const;
|
||||
bool saveAIConfig(const AIConfig &config) const;
|
||||
QString appConfigPath() const;
|
||||
QString aiConfigPath() const;
|
||||
|
||||
+196
-31
@@ -1,5 +1,6 @@
|
||||
#include "PetWindow.h"
|
||||
|
||||
#include "../ai/ConversationManager.h"
|
||||
#include "../ai/OpenAICompatibleProvider.h"
|
||||
#include "../character/CharacterPackageLoader.h"
|
||||
#include "../config/ConfigManager.h"
|
||||
@@ -14,6 +15,7 @@
|
||||
#include <QCursor>
|
||||
#include <QDialog>
|
||||
#include <QGuiApplication>
|
||||
#include <QInputDialog>
|
||||
#include <QMenu>
|
||||
#include <QMouseEvent>
|
||||
#include <QPixmap>
|
||||
@@ -38,6 +40,8 @@ QString previewImagePath()
|
||||
return QStringLiteral(PET_SOURCE_DIR) + QStringLiteral("/shiroko/preview.png");
|
||||
}
|
||||
|
||||
constexpr int MaxUserMessageLength = 4000;
|
||||
|
||||
bool populateRuntimeApiKey(AIConfig &config, QString *errorMessage)
|
||||
{
|
||||
if (!config.apiKey.trimmed().isEmpty())
|
||||
@@ -71,11 +75,56 @@ bool populateRuntimeApiKey(AIConfig &config, QString *errorMessage)
|
||||
|
||||
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)
|
||||
: QWidget(parent)
|
||||
, m_chatBubble(std::make_unique<ChatBubble>())
|
||||
, m_conversationManager(std::make_unique<ConversationManager>())
|
||||
, m_petView(new PetView(this))
|
||||
, m_dragging(false)
|
||||
, m_alwaysOnTop(true)
|
||||
@@ -174,7 +223,15 @@ void PetWindow::contextMenuEvent(QContextMenuEvent *event)
|
||||
QAction *maxBubbleAction = bubbleTestMenu->addAction(QStringLiteral("接近最大尺寸"));
|
||||
QAction *scrollBubbleAction = bubbleTestMenu->addAction(QStringLiteral("超长滚动文本"));
|
||||
|
||||
const bool aiRequestRunning = hasActiveAIRequest();
|
||||
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("设置"));
|
||||
|
||||
addStateTestActions(&menu);
|
||||
@@ -203,11 +260,23 @@ void PetWindow::contextMenuEvent(QContextMenuEvent *event)
|
||||
{
|
||||
startAITest();
|
||||
}
|
||||
else if (selectedAction == chatAction)
|
||||
{
|
||||
startChat();
|
||||
}
|
||||
else if (selectedAction == cancelAIAction)
|
||||
{
|
||||
cancelActiveAIRequest();
|
||||
}
|
||||
else if (selectedAction == clearConversationAction)
|
||||
{
|
||||
clearConversation();
|
||||
}
|
||||
else if (selectedAction == settingsAction)
|
||||
{
|
||||
ConfigManager configManager;
|
||||
SettingsDialog dialog(configManager.loadAIConfig(), this);
|
||||
if (dialog.exec() == QDialog::Accepted && !configManager.saveAIConfig(dialog.aiConfig()))
|
||||
SettingsDialog dialog(configManager.loadAIConfigStore(), this);
|
||||
if (dialog.exec() == QDialog::Accepted && !configManager.saveAIConfigStore(dialog.aiConfigStore()))
|
||||
{
|
||||
Logger::warning(QStringLiteral("Failed to save AI config from settings dialog."));
|
||||
}
|
||||
@@ -224,53 +293,38 @@ void PetWindow::contextMenuEvent(QContextMenuEvent *event)
|
||||
|
||||
void PetWindow::startAITest()
|
||||
{
|
||||
if (m_aiProvider && m_aiProvider->isBusy())
|
||||
if (m_aiTestProvider && m_aiTestProvider->isBusy())
|
||||
{
|
||||
showBubbleMessage(QStringLiteral("AI 测试请求正在进行。"));
|
||||
return;
|
||||
}
|
||||
|
||||
ConfigManager configManager;
|
||||
AIConfig config = configManager.loadAIConfig();
|
||||
if (config.protocol != QStringLiteral("openai-compatible"))
|
||||
if (m_conversationManager && m_conversationManager->isBusy())
|
||||
{
|
||||
playState(QStringLiteral("error"), false);
|
||||
showBubbleMessage(QStringLiteral("当前 Provider 协议暂未接入 AI 测试。"));
|
||||
showBubbleMessage(QStringLiteral("AI 回复正在进行。"));
|
||||
return;
|
||||
}
|
||||
|
||||
ConfigManager configManager;
|
||||
AIConfig config = configManager.loadAIConfig();
|
||||
QString errorMessage;
|
||||
if (!populateRuntimeApiKey(config, &errorMessage))
|
||||
if (!prepareRuntimeAIConfig(config, &errorMessage))
|
||||
{
|
||||
playState(QStringLiteral("error"), false);
|
||||
showBubbleMessage(errorMessage);
|
||||
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;
|
||||
request.messages.append({QStringLiteral("system"), 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);
|
||||
showBubbleMessage(QStringLiteral("正在测试 AI 连接..."));
|
||||
|
||||
QPointer<PetWindow> window(this);
|
||||
m_aiProvider->sendChatRequest(request, [window](const ChatResponse &response) {
|
||||
m_aiTestProvider->sendChatRequest(request, [window](const ChatResponse &response) {
|
||||
if (window.isNull())
|
||||
{
|
||||
return;
|
||||
@@ -283,22 +337,133 @@ void PetWindow::startAITest()
|
||||
return;
|
||||
}
|
||||
|
||||
QString message = response.errorMessage.trimmed();
|
||||
if (message.isEmpty())
|
||||
window->playState(QStringLiteral("error"), false);
|
||||
window->showBubbleMessage(QStringLiteral("AI 测试失败:") + userVisibleErrorMessage(response));
|
||||
});
|
||||
}
|
||||
|
||||
void PetWindow::startChat()
|
||||
{
|
||||
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())
|
||||
{
|
||||
message = QStringLiteral("未知错误。");
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.httpStatus > 0)
|
||||
if (response.success)
|
||||
{
|
||||
message = QStringLiteral("HTTP ") + QString::number(response.httpStatus) + QStringLiteral(":") + message;
|
||||
window->playState(QStringLiteral("talk"), false);
|
||||
window->showBubbleMessage(response.content);
|
||||
return;
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
if (m_dragging && (event->buttons() & Qt::LeftButton))
|
||||
|
||||
+7
-1
@@ -17,6 +17,7 @@ class QMenu;
|
||||
class QMoveEvent;
|
||||
class QPixmap;
|
||||
class ChatBubble;
|
||||
class ConversationManager;
|
||||
class LLMProvider;
|
||||
class PetView;
|
||||
|
||||
@@ -44,6 +45,10 @@ private:
|
||||
void buildAnimationClips();
|
||||
void addStateTestActions(QMenu *menu);
|
||||
void startAITest();
|
||||
void startChat();
|
||||
void clearConversation();
|
||||
void cancelActiveAIRequest();
|
||||
bool hasActiveAIRequest() const;
|
||||
void updateBubblePosition();
|
||||
QPoint bubbleAnchorPosition() const;
|
||||
void playState(const QString &stateName, bool centerWindow);
|
||||
@@ -57,7 +62,8 @@ private:
|
||||
void setAlwaysOnTop(bool enabled);
|
||||
|
||||
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;
|
||||
QTimer m_idleBehaviorTimer;
|
||||
QTimer m_behaviorReturnTimer;
|
||||
|
||||
+81
-30
@@ -14,7 +14,16 @@
|
||||
#include <QSpinBox>
|
||||
#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)
|
||||
, m_providerComboBox(new QComboBox(this))
|
||||
, m_baseUrlEdit(new QLineEdit(this))
|
||||
@@ -25,7 +34,7 @@ SettingsDialog::SettingsDialog(const AIConfig &config, QWidget *parent)
|
||||
, m_temperatureSpinBox(new QDoubleSpinBox(this))
|
||||
, m_maxTokensSpinBox(new QSpinBox(this))
|
||||
, m_allowPlainApiKeyCheckBox(new QCheckBox(QStringLiteral("允许在非 Windows 环境明文保存 API Key"), this))
|
||||
, m_initialConfig(config)
|
||||
, m_configStore(configStore)
|
||||
{
|
||||
setWindowTitle(QStringLiteral("设置"));
|
||||
setModal(true);
|
||||
@@ -44,39 +53,24 @@ SettingsDialog::SettingsDialog(const AIConfig &config, QWidget *parent)
|
||||
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")));
|
||||
|
||||
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->setText(apiKey);
|
||||
m_modelEdit->setText(config.model);
|
||||
m_pathEdit->setText(config.path);
|
||||
|
||||
m_timeoutSpinBox->setRange(1000, 300000);
|
||||
m_timeoutSpinBox->setSingleStep(1000);
|
||||
m_timeoutSpinBox->setValue(config.timeoutMs);
|
||||
|
||||
m_temperatureSpinBox->setRange(0.0, 2.0);
|
||||
m_temperatureSpinBox->setSingleStep(0.1);
|
||||
m_temperatureSpinBox->setDecimals(2);
|
||||
m_temperatureSpinBox->setValue(config.temperature);
|
||||
|
||||
m_maxTokensSpinBox->setRange(1, 200000);
|
||||
m_maxTokensSpinBox->setValue(config.maxTokens);
|
||||
|
||||
m_allowPlainApiKeyCheckBox->setChecked(config.allowPlainApiKey);
|
||||
m_allowPlainApiKeyCheckBox->setVisible(!SecretStore::isEncryptionAvailable());
|
||||
m_currentProvider = normalizedProviderName(m_providerComboBox->currentData().toString());
|
||||
loadProviderConfig(m_currentProvider);
|
||||
|
||||
auto *formLayout = new QFormLayout();
|
||||
formLayout->addRow(QStringLiteral("服务商"), m_providerComboBox);
|
||||
@@ -105,15 +99,63 @@ SettingsDialog::SettingsDialog(const AIConfig &config, QWidget *parent)
|
||||
layout->addWidget(buttonBox);
|
||||
|
||||
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;
|
||||
config.provider = m_providerComboBox->currentData().toString();
|
||||
config.protocol = defaultAIConfigForProvider(config.provider).protocol;
|
||||
AIConfigStore store = m_configStore;
|
||||
const QString provider = normalizedProviderName(m_providerComboBox->currentData().toString());
|
||||
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.model = m_modelEdit->text().trimmed();
|
||||
config.path = m_pathEdit->text().trimmed();
|
||||
@@ -156,9 +198,18 @@ AIConfig SettingsDialog::aiConfig() const
|
||||
return config;
|
||||
}
|
||||
|
||||
void SettingsDialog::applyProviderPreset(const QString &provider)
|
||||
QString SettingsDialog::decryptedApiKey(const AIConfig &config) const
|
||||
{
|
||||
const AIConfig preset = defaultAIConfigForProvider(provider);
|
||||
m_baseUrlEdit->setText(preset.baseUrl);
|
||||
m_pathEdit->setText(preset.path);
|
||||
if (config.apiKeyStorage == QStringLiteral("windows-dpapi") && !config.apiKeyEncrypted.isEmpty())
|
||||
{
|
||||
const SecretStore::Result result = SecretStore::unprotectText(config.apiKeyEncrypted);
|
||||
if (result.success)
|
||||
{
|
||||
return result.value;
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
return config.apiKey;
|
||||
}
|
||||
|
||||
@@ -13,12 +13,16 @@ class QSpinBox;
|
||||
class SettingsDialog : public QDialog
|
||||
{
|
||||
public:
|
||||
explicit SettingsDialog(const AIConfig &config, QWidget *parent = nullptr);
|
||||
explicit SettingsDialog(const AIConfigStore &configStore, QWidget *parent = nullptr);
|
||||
|
||||
AIConfig aiConfig() const;
|
||||
AIConfigStore aiConfigStore() const;
|
||||
|
||||
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;
|
||||
QLineEdit *m_baseUrlEdit = nullptr;
|
||||
@@ -29,5 +33,6 @@ private:
|
||||
QDoubleSpinBox *m_temperatureSpinBox = nullptr;
|
||||
QSpinBox *m_maxTokensSpinBox = nullptr;
|
||||
QCheckBox *m_allowPlainApiKeyCheckBox = nullptr;
|
||||
AIConfig m_initialConfig;
|
||||
AIConfigStore m_configStore;
|
||||
QString m_currentProvider;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user