Add vision analysis workflow and unify AI settings
This commit is contained in:
1 parent
920666e735
commit
609f158735
19 files changed
+1263
-7
No files matched your search
@@ -66,6 +66,8 @@ set(SOURCES
|
||||
src/widgets/selection_mask_controller.h
|
||||
src/widgets/ocr_result_widget.cpp
|
||||
src/widgets/ocr_result_widget.h
|
||||
src/widgets/vision_result_widget.cpp
|
||||
src/widgets/vision_result_widget.h
|
||||
src/widgets/settings_dialog.cpp
|
||||
src/widgets/settings_dialog.h
|
||||
src/widgets/widget_window_bridge.cpp
|
||||
@@ -92,6 +94,8 @@ set(SOURCES
|
||||
src/viewmodel/scroll_capture_view_model.h
|
||||
src/viewmodel/ocr_view_model.cpp
|
||||
src/viewmodel/ocr_view_model.h
|
||||
src/viewmodel/vision_view_model.cpp
|
||||
src/viewmodel/vision_view_model.h
|
||||
|
||||
# GIF Encoder
|
||||
src/gif_encoder/gif.h
|
||||
@@ -138,6 +142,8 @@ set(SOURCES
|
||||
src/ai_call/qwen_ai_call.h
|
||||
src/ai_call/seedream_ai_call.cpp
|
||||
src/ai_call/seedream_ai_call.h
|
||||
src/ai_call/doubao_vision_call.cpp
|
||||
src/ai_call/doubao_vision_call.h
|
||||
|
||||
# Shortcut — 跨平台公共部分
|
||||
src/shortcut_key/globalshortcut.cpp
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
#include "doubao_vision_call.h"
|
||||
|
||||
#include <QJsonArray>
|
||||
|
||||
#include <stdexcept>
|
||||
|
||||
DoubaoVisionCall::DoubaoVisionCall(const QString &apiKey, QObject *parent)
|
||||
: AICallBase(parent)
|
||||
, m_apiKey(apiKey)
|
||||
{
|
||||
}
|
||||
|
||||
bool DoubaoVisionCall::sendRequest(const QString &prompt, const QJsonObject ¶ms)
|
||||
{
|
||||
if (m_apiKey.isEmpty()) {
|
||||
setError(AIErrorType::ParamError, QStringLiteral("视觉 API Key 不能为空"));
|
||||
return false;
|
||||
}
|
||||
if (prompt.trimmed().isEmpty()) {
|
||||
setError(AIErrorType::ParamError, QStringLiteral("提示词不能为空"));
|
||||
return false;
|
||||
}
|
||||
if (!params.contains(QStringLiteral("image_url"))
|
||||
|| params.value(QStringLiteral("image_url")).toString().isEmpty()) {
|
||||
setError(AIErrorType::ParamError, QStringLiteral("缺少图片数据"));
|
||||
return false;
|
||||
}
|
||||
|
||||
cancelRequest();
|
||||
|
||||
const QNetworkRequest request = buildRequest();
|
||||
const QByteArray requestBody = buildRequestBody(prompt, params);
|
||||
m_currentReply = m_networkManager->post(request, requestBody);
|
||||
QNetworkReply *reply = m_currentReply;
|
||||
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
|
||||
this->AICallBase::onReplyFinished(reply);
|
||||
});
|
||||
connect(reply, &QNetworkReply::errorOccurred, this, [this](QNetworkReply::NetworkError errorCode) {
|
||||
this->AICallBase::onReplyError(errorCode);
|
||||
});
|
||||
m_timeoutTimer->start(m_timeout);
|
||||
return true;
|
||||
}
|
||||
|
||||
void DoubaoVisionCall::setApiKey(const QString &apiKey)
|
||||
{
|
||||
m_apiKey = apiKey;
|
||||
}
|
||||
|
||||
void DoubaoVisionCall::setApiUrl(const QString &apiUrl)
|
||||
{
|
||||
if (!apiUrl.trimmed().isEmpty()) {
|
||||
m_apiUrl = apiUrl.trimmed();
|
||||
}
|
||||
}
|
||||
|
||||
void DoubaoVisionCall::setModel(const QString &model)
|
||||
{
|
||||
if (!model.trimmed().isEmpty()) {
|
||||
m_model = model.trimmed();
|
||||
}
|
||||
}
|
||||
|
||||
void DoubaoVisionCall::setProxy(bool enabled, int proxyType, const QString &host, int port)
|
||||
{
|
||||
m_proxyEnabled = enabled;
|
||||
m_proxyType = proxyType;
|
||||
m_proxyHost = host;
|
||||
m_proxyPort = port;
|
||||
|
||||
if (!m_networkManager) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!m_proxyEnabled || m_proxyHost.trimmed().isEmpty() || m_proxyPort <= 0) {
|
||||
m_networkManager->setProxy(QNetworkProxy::NoProxy);
|
||||
return;
|
||||
}
|
||||
|
||||
QNetworkProxy proxy;
|
||||
proxy.setType(m_proxyType == 1 ? QNetworkProxy::Socks5Proxy : QNetworkProxy::HttpProxy);
|
||||
proxy.setHostName(m_proxyHost.trimmed());
|
||||
proxy.setPort(static_cast<quint16>(m_proxyPort));
|
||||
m_networkManager->setProxy(proxy);
|
||||
}
|
||||
|
||||
QNetworkRequest DoubaoVisionCall::buildRequest()
|
||||
{
|
||||
QNetworkRequest request{QUrl(m_apiUrl)};
|
||||
request.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/json"));
|
||||
request.setRawHeader("Authorization", QStringLiteral("Bearer %1").arg(m_apiKey).toUtf8());
|
||||
return request;
|
||||
}
|
||||
|
||||
QByteArray DoubaoVisionCall::buildRequestBody(const QString &prompt, const QJsonObject ¶ms)
|
||||
{
|
||||
QJsonObject root;
|
||||
root[QStringLiteral("model")] = m_model;
|
||||
|
||||
QJsonObject textPart;
|
||||
textPart[QStringLiteral("type")] = QStringLiteral("text");
|
||||
textPart[QStringLiteral("text")] = prompt;
|
||||
|
||||
QJsonObject imageUrl;
|
||||
imageUrl[QStringLiteral("url")] = params.value(QStringLiteral("image_url")).toString();
|
||||
|
||||
QJsonObject imagePart;
|
||||
imagePart[QStringLiteral("type")] = QStringLiteral("image_url");
|
||||
imagePart[QStringLiteral("image_url")] = imageUrl;
|
||||
|
||||
QJsonArray content;
|
||||
content.append(textPart);
|
||||
content.append(imagePart);
|
||||
|
||||
QJsonObject message;
|
||||
message[QStringLiteral("role")] = QStringLiteral("user");
|
||||
message[QStringLiteral("content")] = content;
|
||||
|
||||
QJsonArray messages;
|
||||
messages.append(message);
|
||||
root[QStringLiteral("messages")] = messages;
|
||||
|
||||
return QJsonDocument(root).toJson(QJsonDocument::Compact);
|
||||
}
|
||||
|
||||
QString DoubaoVisionCall::parseResponse(const QByteArray &responseData)
|
||||
{
|
||||
QJsonParseError parseError;
|
||||
const QJsonDocument doc = QJsonDocument::fromJson(responseData, &parseError);
|
||||
if (parseError.error != QJsonParseError::NoError) {
|
||||
throw std::runtime_error(parseError.errorString().toStdString());
|
||||
}
|
||||
if (!doc.isObject()) {
|
||||
throw std::runtime_error("响应不是有效的JSON对象");
|
||||
}
|
||||
|
||||
const QJsonObject rootObj = doc.object();
|
||||
if (rootObj.contains(QStringLiteral("error"))) {
|
||||
const QJsonObject errorObj = rootObj.value(QStringLiteral("error")).toObject();
|
||||
throw std::runtime_error(errorObj.value(QStringLiteral("message"))
|
||||
.toString(QStringLiteral("未知 API 错误"))
|
||||
.toStdString());
|
||||
}
|
||||
|
||||
const QJsonArray choices = rootObj.value(QStringLiteral("choices")).toArray();
|
||||
if (choices.isEmpty()) {
|
||||
throw std::runtime_error("响应中无有效内容");
|
||||
}
|
||||
|
||||
const QJsonObject messageObj = choices.first().toObject().value(QStringLiteral("message")).toObject();
|
||||
const QJsonValue contentVal = messageObj.value(QStringLiteral("content"));
|
||||
if (contentVal.isString()) {
|
||||
const QString text = contentVal.toString().trimmed();
|
||||
if (!text.isEmpty()) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
if (contentVal.isArray()) {
|
||||
QStringList parts;
|
||||
const QJsonArray contentArray = contentVal.toArray();
|
||||
for (const QJsonValue &value : contentArray) {
|
||||
const QJsonObject item = value.toObject();
|
||||
if (item.value(QStringLiteral("type")).toString() == QStringLiteral("text")) {
|
||||
const QString text = item.value(QStringLiteral("text")).toString().trimmed();
|
||||
if (!text.isEmpty()) {
|
||||
parts.append(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!parts.isEmpty()) {
|
||||
return parts.join(QLatin1Char('\n'));
|
||||
}
|
||||
}
|
||||
|
||||
throw std::runtime_error("响应内容为空");
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
#ifndef DOUBAO_VISION_CALL_H
|
||||
#define DOUBAO_VISION_CALL_H
|
||||
|
||||
#include "ai_call_base.h"
|
||||
|
||||
#include <QNetworkProxy>
|
||||
|
||||
class DoubaoVisionCall : public AICallBase
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit DoubaoVisionCall(const QString &apiKey, QObject *parent = nullptr);
|
||||
|
||||
bool sendRequest(const QString &prompt, const QJsonObject ¶ms = QJsonObject()) override;
|
||||
void setApiKey(const QString &apiKey);
|
||||
void setApiUrl(const QString &apiUrl);
|
||||
void setModel(const QString &model);
|
||||
void setProxy(bool enabled, int proxyType, const QString &host, int port);
|
||||
|
||||
protected:
|
||||
QNetworkRequest buildRequest() override;
|
||||
QByteArray buildRequestBody(const QString &prompt, const QJsonObject ¶ms) override;
|
||||
QString parseResponse(const QByteArray &responseData) override;
|
||||
|
||||
private:
|
||||
QString m_apiKey;
|
||||
bool m_proxyEnabled = false;
|
||||
int m_proxyType = 0;
|
||||
QString m_proxyHost;
|
||||
int m_proxyPort = 0;
|
||||
QString m_model = QStringLiteral("doubao-vision-pro-32k-2410128");
|
||||
QString m_apiUrl = QStringLiteral("https://ark.cn-beijing.volces.com/api/v3/chat/completions");
|
||||
};
|
||||
|
||||
#endif // DOUBAO_VISION_CALL_H
|
||||
@@ -49,6 +49,10 @@ void TZShotApp::setupConnections()
|
||||
connect(&m_services->ocrViewModel(), &OcrViewModel::recognizeFailed, this, [this](const QString &errorMessage) {
|
||||
m_runtime->trayHelper().showMessage(tr("OCR 识别失败"), errorMessage);
|
||||
});
|
||||
connect(&m_runtime->settingsDialog(), &SettingsDialog::infoMessageRequested, this,
|
||||
[this](const QString &title, const QString &message) {
|
||||
m_runtime->trayHelper().showMessage(title, message);
|
||||
});
|
||||
|
||||
connect(m_instanceActivation, &InstanceActivation::activationRequested, this, [this]() {
|
||||
showCaptureOverlay(QStringLiteral("copy"));
|
||||
|
||||
@@ -13,7 +13,9 @@ TZShotServices::TZShotServices(QObject *parent)
|
||||
, m_gifRecordViewModel(m_appSettings, this)
|
||||
, m_scrollCaptureViewModel(m_screenshotViewModel, m_appSettings, this)
|
||||
, m_ocrViewModel(this)
|
||||
, m_visionViewModel(m_appSettings, m_stickyImageStore, this)
|
||||
, m_globalShortcut(this)
|
||||
{
|
||||
m_stickyViewModel.setAiViewModel(&m_aiViewModel);
|
||||
m_stickyViewModel.setVisionViewModel(&m_visionViewModel);
|
||||
}
|
||||
@@ -15,6 +15,7 @@
|
||||
#include "viewmodel/scroll_capture_view_model.h"
|
||||
#include "viewmodel/sticky_view_model.h"
|
||||
#include "viewmodel/storage_view_model.h"
|
||||
#include "viewmodel/vision_view_model.h"
|
||||
|
||||
class TZShotServices : public QObject
|
||||
{
|
||||
@@ -34,6 +35,7 @@ public:
|
||||
GifRecordViewModel& gifRecordViewModel() { return m_gifRecordViewModel; }
|
||||
ScrollCaptureViewModel& scrollCaptureViewModel() { return m_scrollCaptureViewModel; }
|
||||
OcrViewModel& ocrViewModel() { return m_ocrViewModel; }
|
||||
VisionViewModel& visionViewModel() { return m_visionViewModel; }
|
||||
GlobalShortcut& globalShortcut() { return m_globalShortcut; }
|
||||
|
||||
private:
|
||||
@@ -48,6 +50,7 @@ private:
|
||||
GifRecordViewModel m_gifRecordViewModel;
|
||||
ScrollCaptureViewModel m_scrollCaptureViewModel;
|
||||
OcrViewModel m_ocrViewModel;
|
||||
VisionViewModel m_visionViewModel;
|
||||
GlobalShortcut m_globalShortcut;
|
||||
};
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ TZShotWidgetRuntime::TZShotWidgetRuntime(TZShotServices &services, QObject *pare
|
||||
&services.ocrViewModel(),
|
||||
&services.gifRecordViewModel())
|
||||
, m_settingsDialog(&services.aiViewModel(),
|
||||
&services.visionViewModel(),
|
||||
&services.storageViewModel(),
|
||||
&services.languageManager(),
|
||||
&services.gifRecordViewModel(),
|
||||
|
||||
@@ -12,6 +12,13 @@ AppSettings::AppSettings()
|
||||
QSettings s;
|
||||
|
||||
m_apiKey = s.value("AI/apiKey", "").toString();
|
||||
m_visionApiKey = s.value("Vision/apiKey", "").toString();
|
||||
m_visionProvider = s.value("Vision/provider", 0).toInt();
|
||||
m_visionModel = s.value("Vision/model", "doubao-vision-pro-32k-2410128").toString();
|
||||
m_visionProxyEnabled = s.value("Vision/proxyEnabled", false).toBool();
|
||||
m_visionProxyType = s.value("Vision/proxyType", 0).toInt();
|
||||
m_visionProxyHost = s.value("Vision/proxyHost", "127.0.0.1").toString();
|
||||
m_visionProxyPort = s.value("Vision/proxyPort", 7890).toInt();
|
||||
m_selectedModel = s.value("AI/selectedModel", 0).toInt();
|
||||
// 语言:若用户从未手动设置过,则根据系统语言选择默认值
|
||||
if (s.contains("App/language")) {
|
||||
@@ -60,6 +67,90 @@ void AppSettings::setApiKey(const QString &key)
|
||||
QSettings().setValue("AI/apiKey", m_apiKey);
|
||||
}
|
||||
|
||||
QString AppSettings::visionApiKey() const
|
||||
{
|
||||
return m_visionApiKey;
|
||||
}
|
||||
|
||||
void AppSettings::setVisionApiKey(const QString &key)
|
||||
{
|
||||
if (m_visionApiKey == key) return;
|
||||
m_visionApiKey = key;
|
||||
QSettings().setValue("Vision/apiKey", m_visionApiKey);
|
||||
}
|
||||
|
||||
int AppSettings::visionProvider() const
|
||||
{
|
||||
return m_visionProvider;
|
||||
}
|
||||
|
||||
void AppSettings::setVisionProvider(int provider)
|
||||
{
|
||||
if (m_visionProvider == provider) return;
|
||||
m_visionProvider = provider;
|
||||
QSettings().setValue("Vision/provider", m_visionProvider);
|
||||
}
|
||||
|
||||
QString AppSettings::visionModel() const
|
||||
{
|
||||
return m_visionModel;
|
||||
}
|
||||
|
||||
void AppSettings::setVisionModel(const QString &model)
|
||||
{
|
||||
if (m_visionModel == model) return;
|
||||
m_visionModel = model;
|
||||
QSettings().setValue("Vision/model", m_visionModel);
|
||||
}
|
||||
|
||||
bool AppSettings::visionProxyEnabled() const
|
||||
{
|
||||
return m_visionProxyEnabled;
|
||||
}
|
||||
|
||||
void AppSettings::setVisionProxyEnabled(bool enabled)
|
||||
{
|
||||
if (m_visionProxyEnabled == enabled) return;
|
||||
m_visionProxyEnabled = enabled;
|
||||
QSettings().setValue("Vision/proxyEnabled", m_visionProxyEnabled);
|
||||
}
|
||||
|
||||
int AppSettings::visionProxyType() const
|
||||
{
|
||||
return m_visionProxyType;
|
||||
}
|
||||
|
||||
void AppSettings::setVisionProxyType(int type)
|
||||
{
|
||||
if (m_visionProxyType == type) return;
|
||||
m_visionProxyType = type;
|
||||
QSettings().setValue("Vision/proxyType", m_visionProxyType);
|
||||
}
|
||||
|
||||
QString AppSettings::visionProxyHost() const
|
||||
{
|
||||
return m_visionProxyHost;
|
||||
}
|
||||
|
||||
void AppSettings::setVisionProxyHost(const QString &host)
|
||||
{
|
||||
if (m_visionProxyHost == host) return;
|
||||
m_visionProxyHost = host;
|
||||
QSettings().setValue("Vision/proxyHost", m_visionProxyHost);
|
||||
}
|
||||
|
||||
int AppSettings::visionProxyPort() const
|
||||
{
|
||||
return m_visionProxyPort;
|
||||
}
|
||||
|
||||
void AppSettings::setVisionProxyPort(int port)
|
||||
{
|
||||
if (m_visionProxyPort == port) return;
|
||||
m_visionProxyPort = port;
|
||||
QSettings().setValue("Vision/proxyPort", m_visionProxyPort);
|
||||
}
|
||||
|
||||
int AppSettings::selectedModel() const
|
||||
{
|
||||
return m_selectedModel;
|
||||
|
||||
@@ -18,6 +18,20 @@ public:
|
||||
// AI 配置
|
||||
QString apiKey() const;
|
||||
void setApiKey(const QString &key);
|
||||
QString visionApiKey() const;
|
||||
void setVisionApiKey(const QString &key);
|
||||
int visionProvider() const;
|
||||
void setVisionProvider(int provider);
|
||||
QString visionModel() const;
|
||||
void setVisionModel(const QString &model);
|
||||
bool visionProxyEnabled() const;
|
||||
void setVisionProxyEnabled(bool enabled);
|
||||
int visionProxyType() const;
|
||||
void setVisionProxyType(int type);
|
||||
QString visionProxyHost() const;
|
||||
void setVisionProxyHost(const QString &host);
|
||||
int visionProxyPort() const;
|
||||
void setVisionProxyPort(int port);
|
||||
|
||||
int selectedModel() const;
|
||||
void setSelectedModel(int index);
|
||||
@@ -36,6 +50,13 @@ public:
|
||||
|
||||
private:
|
||||
QString m_apiKey;
|
||||
QString m_visionApiKey;
|
||||
int m_visionProvider = 0;
|
||||
QString m_visionModel = QStringLiteral("doubao-vision-pro-32k-2410128");
|
||||
bool m_visionProxyEnabled = false;
|
||||
int m_visionProxyType = 0;
|
||||
QString m_visionProxyHost = QStringLiteral("127.0.0.1");
|
||||
int m_visionProxyPort = 7890;
|
||||
int m_selectedModel = 0;
|
||||
QString m_language = "zh_CN";
|
||||
QString m_savePath;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#include "sticky_view_model.h"
|
||||
|
||||
#include "ai_view_model.h"
|
||||
#include "viewmodel/vision_view_model.h"
|
||||
#include "widgets/sticky_pin_widget.h"
|
||||
|
||||
#include <QGuiApplication>
|
||||
@@ -24,6 +25,11 @@ void StickyViewModel::setAiViewModel(AIViewModel *aiViewModel)
|
||||
m_aiViewModel = aiViewModel;
|
||||
}
|
||||
|
||||
void StickyViewModel::setVisionViewModel(VisionViewModel *visionViewModel)
|
||||
{
|
||||
m_visionViewModel = visionViewModel;
|
||||
}
|
||||
|
||||
void StickyViewModel::requestSticky(const QString &imageUrl, const QRect &imgRect)
|
||||
{
|
||||
if (imageUrl.isEmpty()) {
|
||||
@@ -34,7 +40,12 @@ void StickyViewModel::requestSticky(const QString &imageUrl, const QRect &imgRec
|
||||
#ifdef Q_OS_WIN
|
||||
const QImage image = m_store.getImageByUrl(imageUrl);
|
||||
if (!image.isNull()) {
|
||||
auto *widget = new StickyPinWidget(imageUrl, targetRect, image, &m_store, m_aiViewModel);
|
||||
auto *widget = new StickyPinWidget(imageUrl,
|
||||
targetRect,
|
||||
image,
|
||||
&m_store,
|
||||
m_aiViewModel,
|
||||
m_visionViewModel);
|
||||
Q_UNUSED(widget);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
class QScreen;
|
||||
class AIViewModel;
|
||||
class VisionViewModel;
|
||||
|
||||
// StickyViewModel
|
||||
// ---------------
|
||||
@@ -23,6 +24,7 @@ class StickyViewModel : public QObject
|
||||
public:
|
||||
explicit StickyViewModel(StickyImageStore &store, QObject *parent = nullptr);
|
||||
void setAiViewModel(AIViewModel *aiViewModel);
|
||||
void setVisionViewModel(VisionViewModel *visionViewModel);
|
||||
|
||||
// 在 Windows 上直接创建 QWidget 贴图;其他平台通过 stickyReady 回退。
|
||||
Q_INVOKABLE void requestSticky(const QString &imageUrl, const QRect &imgRect);
|
||||
@@ -50,6 +52,7 @@ private:
|
||||
|
||||
StickyImageStore &m_store;
|
||||
AIViewModel *m_aiViewModel = nullptr;
|
||||
VisionViewModel *m_visionViewModel = nullptr;
|
||||
};
|
||||
|
||||
#endif // STICKY_VIEW_MODEL_H
|
||||
@@ -0,0 +1,276 @@
|
||||
#include "viewmodel/vision_view_model.h"
|
||||
|
||||
#include "ai_call/doubao_vision_call.h"
|
||||
#include "model/app_settings.h"
|
||||
#include "sticky_image_store.h"
|
||||
|
||||
#include <QBuffer>
|
||||
|
||||
namespace {
|
||||
constexpr int kMaxImageEdge = 1600;
|
||||
|
||||
QString defaultVisionModelForProvider(int provider)
|
||||
{
|
||||
switch (provider) {
|
||||
case 1:
|
||||
return QStringLiteral("qwen-vl-plus");
|
||||
case 0:
|
||||
default:
|
||||
return QStringLiteral("doubao-vision-pro-32k-2410128");
|
||||
}
|
||||
}
|
||||
|
||||
QString apiUrlForProvider(int provider)
|
||||
{
|
||||
switch (provider) {
|
||||
case 1:
|
||||
return QStringLiteral("https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions");
|
||||
case 0:
|
||||
default:
|
||||
return QStringLiteral("https://ark.cn-beijing.volces.com/api/v3/chat/completions");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
VisionViewModel::VisionViewModel(AppSettings &settings,
|
||||
StickyImageStore &store,
|
||||
QObject *parent)
|
||||
: QObject(parent)
|
||||
, m_settings(settings)
|
||||
, m_store(store)
|
||||
, m_call(new DoubaoVisionCall(settings.visionApiKey(), this))
|
||||
{
|
||||
syncCallSettings();
|
||||
syncProxySettings();
|
||||
connect(m_call, &AICallBase::requestSuccess, this, [this](const QString &response) {
|
||||
setLoading(false);
|
||||
emit analysisSucceeded(m_pendingImageUrl, m_pendingPrompt, response, m_pendingImage);
|
||||
});
|
||||
connect(m_call, &AICallBase::requestFailed, this,
|
||||
[this](AIErrorType errorType, const QString &errorMsg) {
|
||||
QString prefix;
|
||||
switch (errorType) {
|
||||
case AIErrorType::NetworkError: prefix = QStringLiteral("网络错误:"); break;
|
||||
case AIErrorType::ApiError: prefix = QStringLiteral("API 错误:"); break;
|
||||
case AIErrorType::TimeoutError: prefix = QStringLiteral("超时错误:"); break;
|
||||
case AIErrorType::ParamError: prefix = QStringLiteral("参数错误:"); break;
|
||||
default: break;
|
||||
}
|
||||
setLoading(false);
|
||||
emit analysisFailed(m_pendingImageUrl, m_pendingPrompt, prefix + errorMsg);
|
||||
});
|
||||
}
|
||||
|
||||
QString VisionViewModel::apiKey() const
|
||||
{
|
||||
return m_settings.visionApiKey();
|
||||
}
|
||||
|
||||
bool VisionViewModel::isLoading() const
|
||||
{
|
||||
return m_loading;
|
||||
}
|
||||
|
||||
int VisionViewModel::provider() const
|
||||
{
|
||||
return m_settings.visionProvider();
|
||||
}
|
||||
|
||||
QString VisionViewModel::model() const
|
||||
{
|
||||
return m_settings.visionModel();
|
||||
}
|
||||
|
||||
bool VisionViewModel::proxyEnabled() const
|
||||
{
|
||||
return m_settings.visionProxyEnabled();
|
||||
}
|
||||
|
||||
int VisionViewModel::proxyType() const
|
||||
{
|
||||
return m_settings.visionProxyType();
|
||||
}
|
||||
|
||||
QString VisionViewModel::proxyHost() const
|
||||
{
|
||||
return m_settings.visionProxyHost();
|
||||
}
|
||||
|
||||
int VisionViewModel::proxyPort() const
|
||||
{
|
||||
return m_settings.visionProxyPort();
|
||||
}
|
||||
|
||||
bool VisionViewModel::setApiKey(const QString &key)
|
||||
{
|
||||
if (m_settings.visionApiKey() == key) {
|
||||
return true;
|
||||
}
|
||||
m_settings.setVisionApiKey(key);
|
||||
if (m_call) {
|
||||
m_call->setApiKey(key);
|
||||
}
|
||||
emit apiKeyChanged(key);
|
||||
return true;
|
||||
}
|
||||
|
||||
void VisionViewModel::setProvider(int provider)
|
||||
{
|
||||
if (provider < 0) {
|
||||
provider = 0;
|
||||
}
|
||||
if (m_settings.visionProvider() == provider) {
|
||||
return;
|
||||
}
|
||||
|
||||
m_settings.setVisionProvider(provider);
|
||||
m_settings.setVisionModel(defaultVisionModelForProvider(provider));
|
||||
syncCallSettings();
|
||||
}
|
||||
|
||||
void VisionViewModel::setModel(const QString &model)
|
||||
{
|
||||
const QString normalizedModel = model.trimmed();
|
||||
if (normalizedModel.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
if (m_settings.visionModel() == normalizedModel) {
|
||||
return;
|
||||
}
|
||||
|
||||
m_settings.setVisionModel(normalizedModel);
|
||||
syncCallSettings();
|
||||
}
|
||||
|
||||
void VisionViewModel::setProxyEnabled(bool enabled)
|
||||
{
|
||||
m_settings.setVisionProxyEnabled(enabled);
|
||||
syncProxySettings();
|
||||
}
|
||||
|
||||
void VisionViewModel::setProxyType(int type)
|
||||
{
|
||||
m_settings.setVisionProxyType(type);
|
||||
syncProxySettings();
|
||||
}
|
||||
|
||||
void VisionViewModel::setProxyHost(const QString &host)
|
||||
{
|
||||
m_settings.setVisionProxyHost(host);
|
||||
syncProxySettings();
|
||||
}
|
||||
|
||||
void VisionViewModel::setProxyPort(int port)
|
||||
{
|
||||
m_settings.setVisionProxyPort(port);
|
||||
syncProxySettings();
|
||||
}
|
||||
|
||||
void VisionViewModel::analyzeStickyImage(const QString &prompt, const QString &imageUrl)
|
||||
{
|
||||
if (m_loading) {
|
||||
emit analysisFailed(imageUrl, prompt, QStringLiteral("已有视觉分析任务正在进行中"));
|
||||
return;
|
||||
}
|
||||
if (prompt.trimmed().isEmpty()) {
|
||||
emit analysisFailed(imageUrl, prompt, QStringLiteral("提示词不能为空"));
|
||||
return;
|
||||
}
|
||||
|
||||
const QImage originalImage = m_store.getImageByUrl(imageUrl);
|
||||
if (originalImage.isNull()) {
|
||||
emit analysisFailed(imageUrl, prompt, QStringLiteral("读取图片失败"));
|
||||
return;
|
||||
}
|
||||
|
||||
const QImage normalizedImage = normalizeImage(originalImage);
|
||||
const QString imageDataUrl = imageToDataUrl(normalizedImage);
|
||||
if (imageDataUrl.isEmpty()) {
|
||||
emit analysisFailed(imageUrl, prompt, QStringLiteral("图片编码失败"));
|
||||
return;
|
||||
}
|
||||
|
||||
m_pendingImageUrl = imageUrl;
|
||||
m_pendingPrompt = prompt.trimmed();
|
||||
m_pendingImage = normalizedImage;
|
||||
emit analysisStarted(m_pendingImageUrl, m_pendingPrompt, m_pendingImage);
|
||||
setLoading(true);
|
||||
|
||||
const bool ok = m_call->sendRequest(m_pendingPrompt, QJsonObject {
|
||||
{ QStringLiteral("image_url"), imageDataUrl }
|
||||
});
|
||||
if (!ok) {
|
||||
setLoading(false);
|
||||
emit analysisFailed(m_pendingImageUrl,
|
||||
m_pendingPrompt,
|
||||
QStringLiteral("请求发起失败:") + m_call->lastErrorString());
|
||||
}
|
||||
}
|
||||
|
||||
void VisionViewModel::setLoading(bool loading)
|
||||
{
|
||||
if (m_loading == loading) {
|
||||
return;
|
||||
}
|
||||
m_loading = loading;
|
||||
emit isLoadingChanged(loading);
|
||||
}
|
||||
|
||||
void VisionViewModel::syncCallSettings()
|
||||
{
|
||||
if (!m_call) {
|
||||
return;
|
||||
}
|
||||
|
||||
QString modelName = m_settings.visionModel().trimmed();
|
||||
if (modelName.isEmpty()) {
|
||||
modelName = defaultVisionModelForProvider(m_settings.visionProvider());
|
||||
m_settings.setVisionModel(modelName);
|
||||
}
|
||||
|
||||
m_call->setApiUrl(apiUrlForProvider(m_settings.visionProvider()));
|
||||
m_call->setModel(modelName);
|
||||
}
|
||||
|
||||
void VisionViewModel::syncProxySettings()
|
||||
{
|
||||
if (!m_call) {
|
||||
return;
|
||||
}
|
||||
m_call->setProxy(m_settings.visionProxyEnabled(),
|
||||
m_settings.visionProxyType(),
|
||||
m_settings.visionProxyHost(),
|
||||
m_settings.visionProxyPort());
|
||||
}
|
||||
|
||||
QString VisionViewModel::imageToDataUrl(const QImage &image) const
|
||||
{
|
||||
if (image.isNull()) {
|
||||
return {};
|
||||
}
|
||||
|
||||
QByteArray bytes;
|
||||
QBuffer buffer(&bytes);
|
||||
buffer.open(QIODevice::WriteOnly);
|
||||
if (!image.save(&buffer, "PNG")) {
|
||||
return {};
|
||||
}
|
||||
return QStringLiteral("data:image/png;base64,%1")
|
||||
.arg(QString::fromLatin1(bytes.toBase64()));
|
||||
}
|
||||
|
||||
QImage VisionViewModel::normalizeImage(const QImage &image) const
|
||||
{
|
||||
if (image.isNull()) {
|
||||
return {};
|
||||
}
|
||||
|
||||
if (image.width() <= kMaxImageEdge && image.height() <= kMaxImageEdge) {
|
||||
return image;
|
||||
}
|
||||
|
||||
return image.scaled(kMaxImageEdge,
|
||||
kMaxImageEdge,
|
||||
Qt::KeepAspectRatio,
|
||||
Qt::SmoothTransformation);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
#ifndef VISION_VIEW_MODEL_H
|
||||
#define VISION_VIEW_MODEL_H
|
||||
|
||||
#include <QObject>
|
||||
#include <QImage>
|
||||
|
||||
#include "ai_call/ai_call_base.h"
|
||||
|
||||
class AppSettings;
|
||||
class StickyImageStore;
|
||||
class DoubaoVisionCall;
|
||||
|
||||
class VisionViewModel : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
Q_PROPERTY(QString apiKey READ apiKey NOTIFY apiKeyChanged)
|
||||
Q_PROPERTY(bool isLoading READ isLoading NOTIFY isLoadingChanged)
|
||||
|
||||
public:
|
||||
explicit VisionViewModel(AppSettings &settings,
|
||||
StickyImageStore &store,
|
||||
QObject *parent = nullptr);
|
||||
|
||||
QString apiKey() const;
|
||||
bool isLoading() const;
|
||||
int provider() const;
|
||||
QString model() const;
|
||||
bool proxyEnabled() const;
|
||||
int proxyType() const;
|
||||
QString proxyHost() const;
|
||||
int proxyPort() const;
|
||||
|
||||
Q_INVOKABLE bool setApiKey(const QString &key);
|
||||
Q_INVOKABLE void setProvider(int provider);
|
||||
Q_INVOKABLE void setModel(const QString &model);
|
||||
Q_INVOKABLE void setProxyEnabled(bool enabled);
|
||||
Q_INVOKABLE void setProxyType(int type);
|
||||
Q_INVOKABLE void setProxyHost(const QString &host);
|
||||
Q_INVOKABLE void setProxyPort(int port);
|
||||
Q_INVOKABLE void analyzeStickyImage(const QString &prompt, const QString &imageUrl);
|
||||
|
||||
signals:
|
||||
void apiKeyChanged(const QString &apiKey);
|
||||
void isLoadingChanged(bool loading);
|
||||
void analysisStarted(const QString &imageUrl, const QString &prompt, const QImage &image);
|
||||
void analysisSucceeded(const QString &imageUrl,
|
||||
const QString &prompt,
|
||||
const QString &result,
|
||||
const QImage &image);
|
||||
void analysisFailed(const QString &imageUrl, const QString &prompt, const QString &errorMsg);
|
||||
|
||||
private:
|
||||
void syncCallSettings();
|
||||
void setLoading(bool loading);
|
||||
void syncProxySettings();
|
||||
QString imageToDataUrl(const QImage &image) const;
|
||||
QImage normalizeImage(const QImage &image) const;
|
||||
|
||||
AppSettings &m_settings;
|
||||
StickyImageStore &m_store;
|
||||
DoubaoVisionCall *m_call = nullptr;
|
||||
bool m_loading = false;
|
||||
QString m_pendingImageUrl;
|
||||
QString m_pendingPrompt;
|
||||
QImage m_pendingImage;
|
||||
};
|
||||
|
||||
#endif // VISION_VIEW_MODEL_H
|
||||
@@ -5,9 +5,11 @@
|
||||
#include "viewmodel/gif_record_view_model.h"
|
||||
#include "viewmodel/ocr_view_model.h"
|
||||
#include "viewmodel/storage_view_model.h"
|
||||
#include "viewmodel/vision_view_model.h"
|
||||
#include "shortcut_key/globalshortcut.h"
|
||||
|
||||
#include <QComboBox>
|
||||
#include <QCheckBox>
|
||||
#include <QFile>
|
||||
#include <QFileDialog>
|
||||
#include <QFrame>
|
||||
@@ -25,6 +27,7 @@
|
||||
#include <QTextEdit>
|
||||
#include <QVBoxLayout>
|
||||
#include <QLineEdit>
|
||||
#include <QSpinBox>
|
||||
#include <QWheelEvent>
|
||||
|
||||
namespace {
|
||||
@@ -181,6 +184,7 @@ protected:
|
||||
}
|
||||
|
||||
SettingsDialog::SettingsDialog(AIViewModel *aiViewModel,
|
||||
VisionViewModel *visionViewModel,
|
||||
StorageViewModel *storageViewModel,
|
||||
LanguageManager *languageManager,
|
||||
GifRecordViewModel *gifRecordViewModel,
|
||||
@@ -189,6 +193,7 @@ SettingsDialog::SettingsDialog(AIViewModel *aiViewModel,
|
||||
QWidget *parent)
|
||||
: QDialog(parent)
|
||||
, m_aiViewModel(aiViewModel)
|
||||
, m_visionViewModel(visionViewModel)
|
||||
, m_storageViewModel(storageViewModel)
|
||||
, m_languageManager(languageManager)
|
||||
, m_gifRecordViewModel(gifRecordViewModel)
|
||||
@@ -326,25 +331,105 @@ QWidget *SettingsDialog::buildGeneralPage()
|
||||
grid->setHorizontalSpacing(12);
|
||||
grid->setVerticalSpacing(12);
|
||||
|
||||
addRowLabel(grid, 0, tr("大模型选择"), card);
|
||||
addRowLabel(grid, 0, tr("AI 服务商"), card);
|
||||
m_modelCombo = new SettingsComboBox(card);
|
||||
m_modelCombo->addItems({ tr("阿里云百炼"), tr("火山引擎") });
|
||||
if (m_aiViewModel) m_modelCombo->setCurrentIndex(m_aiViewModel->selectedModel());
|
||||
connect(m_modelCombo, qOverload<int>(&QComboBox::currentIndexChanged), this, [this](int index) {
|
||||
if (m_aiViewModel) m_aiViewModel->setSelectedModel(index);
|
||||
if (m_aiViewModel) {
|
||||
m_aiViewModel->setSelectedModel(index);
|
||||
}
|
||||
if (m_visionViewModel) {
|
||||
const int visionProvider = aiProviderToVisionProvider(index);
|
||||
m_visionViewModel->setProvider(visionProvider);
|
||||
populateVisionModelOptions(visionProvider, m_visionViewModel->model());
|
||||
}
|
||||
});
|
||||
grid->addWidget(m_modelCombo, 0, 1, 1, 2);
|
||||
|
||||
addRowLabel(grid, 1, QStringLiteral("API Key"), card);
|
||||
m_apiKeyEdit = new QLineEdit(card);
|
||||
if (m_aiViewModel) m_apiKeyEdit->setText(m_aiViewModel->apiKey());
|
||||
const QString sharedApiKey = m_aiViewModel && !m_aiViewModel->apiKey().isEmpty()
|
||||
? m_aiViewModel->apiKey()
|
||||
: (m_visionViewModel ? m_visionViewModel->apiKey() : QString());
|
||||
m_apiKeyEdit->setText(sharedApiKey);
|
||||
grid->addWidget(m_apiKeyEdit, 1, 1);
|
||||
auto *saveApi = new QPushButton(tr("保存"), card);
|
||||
saveApi->setObjectName(QStringLiteral("primaryButton"));
|
||||
connect(saveApi, &QPushButton::clicked, this, [this]() {
|
||||
if (m_aiViewModel) m_aiViewModel->setApiKey(m_apiKeyEdit->text());
|
||||
const QString apiKey = m_apiKeyEdit ? m_apiKeyEdit->text() : QString();
|
||||
if (m_aiViewModel) {
|
||||
m_aiViewModel->setApiKey(apiKey);
|
||||
}
|
||||
if (m_visionViewModel) {
|
||||
m_visionViewModel->setApiKey(apiKey);
|
||||
}
|
||||
emit infoMessageRequested(tr("API Key 已保存"),
|
||||
tr("AI 图像编辑和视觉理解已同步更新。"));
|
||||
});
|
||||
grid->addWidget(saveApi, 1, 2);
|
||||
|
||||
if (m_visionViewModel && m_modelCombo) {
|
||||
m_modelCombo->setCurrentIndex(visionProviderToAiProvider(m_visionViewModel->provider()));
|
||||
}
|
||||
|
||||
addRowLabel(grid, 2, tr("视觉模型"), card);
|
||||
m_visionModelCombo = new SettingsComboBox(card);
|
||||
m_visionModelCombo->setEditable(true);
|
||||
populateVisionModelOptions(m_visionViewModel ? m_visionViewModel->provider()
|
||||
: aiProviderToVisionProvider(m_aiViewModel ? m_aiViewModel->selectedModel() : 0),
|
||||
m_visionViewModel ? m_visionViewModel->model() : QString());
|
||||
connect(m_visionModelCombo, &QComboBox::editTextChanged, this, [this](const QString &text) {
|
||||
if (m_visionViewModel) {
|
||||
m_visionViewModel->setModel(text);
|
||||
}
|
||||
});
|
||||
grid->addWidget(m_visionModelCombo, 2, 1, 1, 2);
|
||||
|
||||
addRowLabel(grid, 3, tr("启用代理"), card);
|
||||
m_visionProxyEnabledCheck = new QCheckBox(tr("视觉请求走代理"), card);
|
||||
if (m_visionViewModel) m_visionProxyEnabledCheck->setChecked(m_visionViewModel->proxyEnabled());
|
||||
connect(m_visionProxyEnabledCheck, &QCheckBox::toggled, this, [this](bool checked) {
|
||||
if (m_visionViewModel) m_visionViewModel->setProxyEnabled(checked);
|
||||
if (m_visionProxyTypeCombo) m_visionProxyTypeCombo->setEnabled(checked);
|
||||
if (m_visionProxyHostEdit) m_visionProxyHostEdit->setEnabled(checked);
|
||||
if (m_visionProxyPortSpin) m_visionProxyPortSpin->setEnabled(checked);
|
||||
});
|
||||
grid->addWidget(m_visionProxyEnabledCheck, 3, 1, 1, 2);
|
||||
|
||||
addRowLabel(grid, 4, tr("代理类型"), card);
|
||||
m_visionProxyTypeCombo = new SettingsComboBox(card);
|
||||
m_visionProxyTypeCombo->addItems({ QStringLiteral("HTTP"), QStringLiteral("SOCKS5") });
|
||||
if (m_visionViewModel) m_visionProxyTypeCombo->setCurrentIndex(m_visionViewModel->proxyType());
|
||||
connect(m_visionProxyTypeCombo, qOverload<int>(&QComboBox::currentIndexChanged), this, [this](int index) {
|
||||
if (m_visionViewModel) m_visionViewModel->setProxyType(index);
|
||||
});
|
||||
grid->addWidget(m_visionProxyTypeCombo, 4, 1, 1, 2);
|
||||
|
||||
addRowLabel(grid, 5, tr("代理地址"), card);
|
||||
m_visionProxyHostEdit = new QLineEdit(card);
|
||||
m_visionProxyHostEdit->setPlaceholderText(QStringLiteral("127.0.0.1"));
|
||||
if (m_visionViewModel) m_visionProxyHostEdit->setText(m_visionViewModel->proxyHost());
|
||||
connect(m_visionProxyHostEdit, &QLineEdit::editingFinished, this, [this]() {
|
||||
if (m_visionViewModel && m_visionProxyHostEdit) {
|
||||
m_visionViewModel->setProxyHost(m_visionProxyHostEdit->text().trimmed());
|
||||
}
|
||||
});
|
||||
grid->addWidget(m_visionProxyHostEdit, 5, 1, 1, 2);
|
||||
|
||||
addRowLabel(grid, 6, tr("代理端口"), card);
|
||||
m_visionProxyPortSpin = new QSpinBox(card);
|
||||
m_visionProxyPortSpin->setRange(1, 65535);
|
||||
if (m_visionViewModel) m_visionProxyPortSpin->setValue(m_visionViewModel->proxyPort());
|
||||
connect(m_visionProxyPortSpin, &QSpinBox::valueChanged, this, [this](int value) {
|
||||
if (m_visionViewModel) m_visionViewModel->setProxyPort(value);
|
||||
});
|
||||
grid->addWidget(m_visionProxyPortSpin, 6, 1, 1, 2);
|
||||
|
||||
const bool proxyEnabled = m_visionViewModel && m_visionViewModel->proxyEnabled();
|
||||
m_visionProxyTypeCombo->setEnabled(proxyEnabled);
|
||||
m_visionProxyHostEdit->setEnabled(proxyEnabled);
|
||||
m_visionProxyPortSpin->setEnabled(proxyEnabled);
|
||||
outer->addWidget(card);
|
||||
}
|
||||
|
||||
@@ -455,6 +540,44 @@ QWidget *SettingsDialog::buildGeneralPage()
|
||||
return container;
|
||||
}
|
||||
|
||||
void SettingsDialog::populateVisionModelOptions(int provider, const QString ¤tModel)
|
||||
{
|
||||
if (!m_visionModelCombo) {
|
||||
return;
|
||||
}
|
||||
|
||||
const QString normalizedCurrent = currentModel.trimmed();
|
||||
m_visionModelCombo->blockSignals(true);
|
||||
m_visionModelCombo->clear();
|
||||
|
||||
if (provider == 1) {
|
||||
m_visionModelCombo->addItems({ QStringLiteral("qwen-vl-plus"),
|
||||
QStringLiteral("qwen-vl-max") });
|
||||
} else {
|
||||
m_visionModelCombo->addItem(QStringLiteral("doubao-vision-pro-32k-2410128"));
|
||||
}
|
||||
|
||||
const QString finalModel = normalizedCurrent.isEmpty()
|
||||
? m_visionModelCombo->itemText(0)
|
||||
: normalizedCurrent;
|
||||
|
||||
if (m_visionModelCombo->findText(finalModel) < 0) {
|
||||
m_visionModelCombo->addItem(finalModel);
|
||||
}
|
||||
m_visionModelCombo->setCurrentText(finalModel);
|
||||
m_visionModelCombo->blockSignals(false);
|
||||
}
|
||||
|
||||
int SettingsDialog::aiProviderToVisionProvider(int aiProvider) const
|
||||
{
|
||||
return aiProvider == 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
int SettingsDialog::visionProviderToAiProvider(int visionProvider) const
|
||||
{
|
||||
return visionProvider == 1 ? 0 : 1;
|
||||
}
|
||||
|
||||
QWidget *SettingsDialog::createHotkeyRow(const QString &labelText, int actionId, const QString &seq)
|
||||
{
|
||||
auto *rowWrap = new QWidget(this);
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
#include <QDialog>
|
||||
|
||||
class AIViewModel;
|
||||
class VisionViewModel;
|
||||
class StorageViewModel;
|
||||
class LanguageManager;
|
||||
class GifRecordViewModel;
|
||||
@@ -16,6 +17,8 @@ class QLabel;
|
||||
class QLineEdit;
|
||||
class QComboBox;
|
||||
class QTextEdit;
|
||||
class QCheckBox;
|
||||
class QSpinBox;
|
||||
|
||||
class SettingsDialog : public QDialog
|
||||
{
|
||||
@@ -23,6 +26,7 @@ class SettingsDialog : public QDialog
|
||||
|
||||
public:
|
||||
SettingsDialog(AIViewModel *aiViewModel,
|
||||
VisionViewModel *visionViewModel,
|
||||
StorageViewModel *storageViewModel,
|
||||
LanguageManager *languageManager,
|
||||
GifRecordViewModel *gifRecordViewModel,
|
||||
@@ -32,16 +36,23 @@ public:
|
||||
|
||||
void showAndActivate();
|
||||
|
||||
signals:
|
||||
void infoMessageRequested(const QString &title, const QString &message);
|
||||
|
||||
private:
|
||||
QWidget *buildGeneralPage();
|
||||
QWidget *buildHotkeyPage();
|
||||
QWidget *createSectionTitle(const QString &title);
|
||||
QWidget *createCard();
|
||||
QWidget *createHotkeyRow(const QString &label, int actionId, const QString &seq);
|
||||
void populateVisionModelOptions(int provider, const QString ¤tModel = QString());
|
||||
int aiProviderToVisionProvider(int aiProvider) const;
|
||||
int visionProviderToAiProvider(int visionProvider) const;
|
||||
QString shortcutForAction(int actionId) const;
|
||||
void refreshHotkeyPage();
|
||||
|
||||
QPointer<AIViewModel> m_aiViewModel;
|
||||
QPointer<VisionViewModel> m_visionViewModel;
|
||||
QPointer<StorageViewModel> m_storageViewModel;
|
||||
QPointer<LanguageManager> m_languageManager;
|
||||
QPointer<GifRecordViewModel> m_gifRecordViewModel;
|
||||
@@ -53,6 +64,11 @@ private:
|
||||
|
||||
QComboBox *m_modelCombo = nullptr;
|
||||
QLineEdit *m_apiKeyEdit = nullptr;
|
||||
QComboBox *m_visionModelCombo = nullptr;
|
||||
QCheckBox *m_visionProxyEnabledCheck = nullptr;
|
||||
QComboBox *m_visionProxyTypeCombo = nullptr;
|
||||
QLineEdit *m_visionProxyHostEdit = nullptr;
|
||||
QSpinBox *m_visionProxyPortSpin = nullptr;
|
||||
QLabel *m_savePathLabel = nullptr;
|
||||
QComboBox *m_languageCombo = nullptr;
|
||||
QComboBox *m_gifPresetCombo = nullptr;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
#include "widgets/sticky_pin_widget.h"
|
||||
|
||||
#include "viewmodel/ai_view_model.h"
|
||||
#include "viewmodel/vision_view_model.h"
|
||||
#include "widgets/sticky_canvas_widget.h"
|
||||
#include "widgets/vision_result_widget.h"
|
||||
#include "sticky_image_store.h"
|
||||
|
||||
#include <QCheckBox>
|
||||
@@ -206,6 +208,7 @@ StickyPinWidget::StickyPinWidget(const QString &imageUrl,
|
||||
const QImage &image,
|
||||
StickyImageStore *store,
|
||||
AIViewModel *aiViewModel,
|
||||
VisionViewModel *visionViewModel,
|
||||
QWidget *parent)
|
||||
: QWidget(parent)
|
||||
, m_imageUrl(imageUrl)
|
||||
@@ -213,6 +216,7 @@ StickyPinWidget::StickyPinWidget(const QString &imageUrl,
|
||||
, m_image(image)
|
||||
, m_store(store)
|
||||
, m_aiViewModel(aiViewModel)
|
||||
, m_visionViewModel(visionViewModel)
|
||||
{
|
||||
setAttribute(Qt::WA_DeleteOnClose);
|
||||
setAttribute(Qt::WA_TranslucentBackground);
|
||||
@@ -275,6 +279,9 @@ StickyPinWidget::StickyPinWidget(const QString &imageUrl,
|
||||
QMessageBox::warning(this, tr("AI 编辑失败"), errorMsg);
|
||||
});
|
||||
}
|
||||
if (m_visionViewModel) {
|
||||
m_visionResultWidget = new VisionResultWidget(m_visionViewModel, this);
|
||||
}
|
||||
|
||||
m_toolOptions = new QWidget(this);
|
||||
m_toolOptions->setObjectName(QStringLiteral("stickyToolOptions"));
|
||||
@@ -429,6 +436,7 @@ StickyPinWidget::StickyPinWidget(const QString &imageUrl,
|
||||
m_numberButton = new QToolButton(m_toolbar);
|
||||
m_undoButton = new QToolButton(m_toolbar);
|
||||
m_aiButton = new QToolButton(m_toolbar);
|
||||
m_visionButton = new QToolButton(m_toolbar);
|
||||
m_copyButton = new QToolButton(m_toolbar);
|
||||
m_saveButton = new QToolButton(m_toolbar);
|
||||
m_zoomLabel = new QLabel(m_toolbar);
|
||||
@@ -455,6 +463,7 @@ StickyPinWidget::StickyPinWidget(const QString &imageUrl,
|
||||
setupButton(m_numberButton, tr("序号"));
|
||||
setupButton(m_undoButton, tr("撤销"));
|
||||
setupButton(m_aiButton, tr("AI 编辑"));
|
||||
setupButton(m_visionButton, tr("AI 理解"));
|
||||
setupButton(m_copyButton, tr("复制"));
|
||||
setupButton(m_saveButton, tr("保存"));
|
||||
setupButton(m_toggleToolbarButton, tr("隐藏工具栏"));
|
||||
@@ -491,6 +500,7 @@ StickyPinWidget::StickyPinWidget(const QString &imageUrl,
|
||||
m_numberButton->setIcon(QIcon(QStringLiteral(":/resource/img/lc_list_ordered.svg")));
|
||||
m_undoButton->setIcon(QIcon(QStringLiteral(":/resource/img/lc_undo.svg")));
|
||||
m_aiButton->setIcon(QIcon(QStringLiteral(":/resource/img/lc_chat.svg")));
|
||||
m_visionButton->setIcon(QIcon(QStringLiteral(":/resource/img/lc_ocr.svg")));
|
||||
QIcon copyIcon = QIcon::fromTheme(QStringLiteral("edit-copy"));
|
||||
if (copyIcon.isNull()) {
|
||||
copyIcon = style()->standardIcon(QStyle::SP_FileDialogDetailedView);
|
||||
@@ -527,6 +537,7 @@ StickyPinWidget::StickyPinWidget(const QString &imageUrl,
|
||||
}
|
||||
});
|
||||
connect(m_aiButton, &QToolButton::clicked, this, &StickyPinWidget::openAiEditor);
|
||||
connect(m_visionButton, &QToolButton::clicked, this, &StickyPinWidget::openVisionWindow);
|
||||
connect(m_copyButton, &QToolButton::clicked, this, [this]() {
|
||||
if (m_store) {
|
||||
m_store->copyImageToClipboard(m_imageUrl);
|
||||
@@ -692,6 +703,7 @@ void StickyPinWidget::contextMenuEvent(QContextMenuEvent *event)
|
||||
QAction *copyAction = menu.addAction(tr("复制"));
|
||||
QAction *saveAction = menu.addAction(tr("保存原图"));
|
||||
QAction *aiAction = menu.addAction(tr("AI 编辑"));
|
||||
QAction *visionAction = menu.addAction(tr("AI 理解"));
|
||||
QAction *toggleAction = menu.addAction(m_toolbarVisible ? tr("隐藏工具栏") : tr("显示工具栏"));
|
||||
QAction *shadowAction = menu.addAction(m_shadowVisible ? tr("隐藏阴影") : tr("显示阴影"));
|
||||
menu.addSeparator();
|
||||
@@ -711,6 +723,10 @@ void StickyPinWidget::contextMenuEvent(QContextMenuEvent *event)
|
||||
openAiEditor();
|
||||
return;
|
||||
}
|
||||
if (chosen == visionAction) {
|
||||
openVisionWindow();
|
||||
return;
|
||||
}
|
||||
if (chosen == toggleAction) {
|
||||
m_toolbarVisible = !m_toolbarVisible;
|
||||
updateLayoutAndSize();
|
||||
@@ -809,6 +825,15 @@ void StickyPinWidget::openAiEditor()
|
||||
m_aiViewModel->sendPrompt(trimmed, m_imageUrl);
|
||||
}
|
||||
|
||||
void StickyPinWidget::openVisionWindow()
|
||||
{
|
||||
if (!m_visionViewModel || !m_visionResultWidget) {
|
||||
QMessageBox::information(this, tr("AI 理解"), tr("视觉理解功能当前不可用。"));
|
||||
return;
|
||||
}
|
||||
m_visionResultWidget->showForImage(m_imageUrl, m_image);
|
||||
}
|
||||
|
||||
void StickyPinWidget::applyAiImage(const QString &oldImageUrl, const QString &newImageUrl)
|
||||
{
|
||||
if (oldImageUrl != m_imageUrl || !m_store) {
|
||||
@@ -924,7 +949,7 @@ void StickyPinWidget::updateLayoutAndSize()
|
||||
if (m_pencilButton && m_rectButton && m_circleButton && m_arrowButton
|
||||
&& m_highlightButton && m_mosaicButton && m_blurButton
|
||||
&& m_textButton && m_numberButton
|
||||
&& m_undoButton && m_aiButton && m_copyButton && m_saveButton && m_zoomLabel
|
||||
&& m_undoButton && m_aiButton && m_visionButton && m_copyButton && m_saveButton && m_zoomLabel
|
||||
&& m_toggleToolbarButton && m_closeButton) {
|
||||
const int buttonH = 32;
|
||||
const int buttonW = 32;
|
||||
@@ -954,6 +979,8 @@ void StickyPinWidget::updateLayoutAndSize()
|
||||
x += buttonW + gap;
|
||||
m_aiButton->setGeometry(x, buttonY, buttonW, buttonH);
|
||||
x += buttonW + gap;
|
||||
m_visionButton->setGeometry(x, buttonY, buttonW, buttonH);
|
||||
x += buttonW + gap;
|
||||
m_copyButton->setGeometry(x, buttonY, buttonW, buttonH);
|
||||
x += buttonW + gap;
|
||||
m_saveButton->setGeometry(x, buttonY, buttonW, buttonH);
|
||||
@@ -1069,5 +1096,5 @@ int StickyPinWidget::toolbarHeight() const
|
||||
|
||||
int StickyPinWidget::toolbarWidth() const
|
||||
{
|
||||
return 32 * 15 + 52 + 2 * 15 + 12;
|
||||
return 32 * 16 + 52 + 2 * 16 + 12;
|
||||
}
|
||||
@@ -12,12 +12,14 @@
|
||||
|
||||
class StickyImageStore;
|
||||
class AIViewModel;
|
||||
class VisionViewModel;
|
||||
class QCheckBox;
|
||||
class QLabel;
|
||||
class QLineEdit;
|
||||
class QSpinBox;
|
||||
class QToolButton;
|
||||
class StickyCanvasWidget;
|
||||
class VisionResultWidget;
|
||||
|
||||
class StickyPinWidget : public QWidget
|
||||
{
|
||||
@@ -29,6 +31,7 @@ public:
|
||||
const QImage &image,
|
||||
StickyImageStore *store,
|
||||
AIViewModel *aiViewModel = nullptr,
|
||||
VisionViewModel *visionViewModel = nullptr,
|
||||
QWidget *parent = nullptr);
|
||||
~StickyPinWidget() override;
|
||||
|
||||
@@ -47,6 +50,7 @@ private:
|
||||
QToolButton *currentActiveToolButton() const;
|
||||
void setZoomFactor(qreal value);
|
||||
void openAiEditor();
|
||||
void openVisionWindow();
|
||||
void applyAiImage(const QString &oldImageUrl, const QString &newImageUrl);
|
||||
void releaseStoredImage();
|
||||
bool saveCurrentImage();
|
||||
@@ -71,7 +75,9 @@ private:
|
||||
QSize m_imageDisplaySize;
|
||||
QPointer<StickyImageStore> m_store;
|
||||
QPointer<AIViewModel> m_aiViewModel;
|
||||
QPointer<VisionViewModel> m_visionViewModel;
|
||||
StickyCanvasWidget *m_canvas = nullptr;
|
||||
VisionResultWidget *m_visionResultWidget = nullptr;
|
||||
QWidget *m_toolOptions = nullptr;
|
||||
QWidget *m_toolbar = nullptr;
|
||||
QLabel *m_zoomLabel = nullptr;
|
||||
@@ -96,6 +102,7 @@ private:
|
||||
QToolButton *m_numberButton = nullptr;
|
||||
QToolButton *m_undoButton = nullptr;
|
||||
QToolButton *m_aiButton = nullptr;
|
||||
QToolButton *m_visionButton = nullptr;
|
||||
QToolButton *m_copyButton = nullptr;
|
||||
QToolButton *m_saveButton = nullptr;
|
||||
QToolButton *m_toggleToolbarButton = nullptr;
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
#include "widgets/vision_result_widget.h"
|
||||
|
||||
#include "viewmodel/vision_view_model.h"
|
||||
|
||||
#include <QApplication>
|
||||
#include <QClipboard>
|
||||
#include <QCloseEvent>
|
||||
#include <QGuiApplication>
|
||||
#include <QHBoxLayout>
|
||||
#include <QImage>
|
||||
#include <QKeyEvent>
|
||||
#include <QLabel>
|
||||
#include <QMouseEvent>
|
||||
#include <QPropertyAnimation>
|
||||
#include <QPushButton>
|
||||
#include <QScreen>
|
||||
#include <QTextEdit>
|
||||
#include <QVBoxLayout>
|
||||
#include <QLineEdit>
|
||||
|
||||
VisionResultWidget::VisionResultWidget(VisionViewModel *viewModel, QWidget *parent)
|
||||
: QWidget(parent)
|
||||
, m_viewModel(viewModel)
|
||||
{
|
||||
setAttribute(Qt::WA_DeleteOnClose, false);
|
||||
setWindowFlags(Qt::FramelessWindowHint | Qt::WindowStaysOnTopHint | Qt::Tool);
|
||||
setAttribute(Qt::WA_TranslucentBackground);
|
||||
resize(760, 480);
|
||||
|
||||
auto *root = new QVBoxLayout(this);
|
||||
root->setContentsMargins(10, 10, 10, 10);
|
||||
root->setSpacing(0);
|
||||
|
||||
m_card = new QWidget(this);
|
||||
m_card->setObjectName(QStringLiteral("visionResultCard"));
|
||||
m_card->setStyleSheet(QStringLiteral(
|
||||
"#visionResultCard { background:#FFFFFF; border:1px solid #D1D5DB; border-radius:12px; }"
|
||||
"#visionResultTitleBar { background:#F8FAFC; border-top-left-radius:12px; border-top-right-radius:12px; }"
|
||||
"#visionPreviewBox { background:#F8FAFC; border:1px solid #E2E8F0; border-radius:10px; }"
|
||||
));
|
||||
root->addWidget(m_card);
|
||||
|
||||
auto *cardLayout = new QVBoxLayout(m_card);
|
||||
cardLayout->setContentsMargins(0, 0, 0, 0);
|
||||
cardLayout->setSpacing(0);
|
||||
|
||||
m_titleBar = new QWidget(m_card);
|
||||
m_titleBar->setObjectName(QStringLiteral("visionResultTitleBar"));
|
||||
m_titleBar->setFixedHeight(40);
|
||||
auto *titleLayout = new QHBoxLayout(m_titleBar);
|
||||
titleLayout->setContentsMargins(14, 0, 8, 0);
|
||||
titleLayout->setSpacing(8);
|
||||
auto *title = new QLabel(tr("AI 视觉理解"), m_titleBar);
|
||||
title->setStyleSheet(QStringLiteral("color:#1E293B; font-size:14px; font-weight:600;"));
|
||||
titleLayout->addWidget(title);
|
||||
titleLayout->addStretch();
|
||||
auto *closeButton = new QPushButton(QStringLiteral("✕"), m_titleBar);
|
||||
closeButton->setFixedSize(26, 26);
|
||||
closeButton->setStyleSheet(QStringLiteral(
|
||||
"QPushButton { border:none; background:transparent; color:#94A3B8; border-radius:5px; }"
|
||||
"QPushButton:hover { background:#FEE2E2; color:#DC2626; }"));
|
||||
connect(closeButton, &QPushButton::clicked, this, &QWidget::close);
|
||||
titleLayout->addWidget(closeButton);
|
||||
cardLayout->addWidget(m_titleBar);
|
||||
|
||||
auto *divider = new QWidget(m_card);
|
||||
divider->setFixedHeight(1);
|
||||
divider->setStyleSheet(QStringLiteral("background:#E2E8F0;"));
|
||||
cardLayout->addWidget(divider);
|
||||
|
||||
auto *content = new QWidget(m_card);
|
||||
auto *contentLayout = new QHBoxLayout(content);
|
||||
contentLayout->setContentsMargins(14, 14, 14, 14);
|
||||
contentLayout->setSpacing(14);
|
||||
|
||||
auto *leftPane = new QWidget(content);
|
||||
auto *leftLayout = new QVBoxLayout(leftPane);
|
||||
leftLayout->setContentsMargins(0, 0, 0, 0);
|
||||
leftLayout->setSpacing(10);
|
||||
|
||||
auto *previewBox = new QWidget(leftPane);
|
||||
previewBox->setObjectName(QStringLiteral("visionPreviewBox"));
|
||||
previewBox->setMinimumSize(220, 280);
|
||||
auto *previewLayout = new QVBoxLayout(previewBox);
|
||||
previewLayout->setContentsMargins(10, 10, 10, 10);
|
||||
previewLayout->setSpacing(8);
|
||||
auto *previewTitle = new QLabel(tr("图片预览"), previewBox);
|
||||
previewTitle->setStyleSheet(QStringLiteral("color:#334155; font-size:12px; font-weight:600;"));
|
||||
previewLayout->addWidget(previewTitle);
|
||||
m_previewLabel = new QLabel(previewBox);
|
||||
m_previewLabel->setAlignment(Qt::AlignCenter);
|
||||
m_previewLabel->setMinimumSize(180, 220);
|
||||
m_previewLabel->setStyleSheet(QStringLiteral("background:#FFFFFF; border:1px solid #E2E8F0; border-radius:8px;"));
|
||||
previewLayout->addWidget(m_previewLabel, 1);
|
||||
leftLayout->addWidget(previewBox, 1);
|
||||
|
||||
m_statusLabel = new QLabel(tr("输入提示词后点击发送。"), leftPane);
|
||||
m_statusLabel->setWordWrap(true);
|
||||
m_statusLabel->setStyleSheet(QStringLiteral("color:#64748B; font-size:12px;"));
|
||||
leftLayout->addWidget(m_statusLabel);
|
||||
|
||||
contentLayout->addWidget(leftPane, 0);
|
||||
|
||||
auto *rightPane = new QWidget(content);
|
||||
auto *rightLayout = new QVBoxLayout(rightPane);
|
||||
rightLayout->setContentsMargins(0, 0, 0, 0);
|
||||
rightLayout->setSpacing(10);
|
||||
|
||||
auto *promptTitle = new QLabel(tr("提示词"), rightPane);
|
||||
promptTitle->setStyleSheet(QStringLiteral("color:#334155; font-size:12px; font-weight:600;"));
|
||||
rightLayout->addWidget(promptTitle);
|
||||
|
||||
auto *promptRow = new QHBoxLayout;
|
||||
promptRow->setContentsMargins(0, 0, 0, 0);
|
||||
promptRow->setSpacing(8);
|
||||
m_promptEdit = new QLineEdit(rightPane);
|
||||
m_promptEdit->setPlaceholderText(tr("例如:请总结这张截图里的重点信息"));
|
||||
m_promptEdit->setStyleSheet(QStringLiteral(
|
||||
"QLineEdit { background:#F8FAFC; border:1px solid #E2E8F0; border-radius:8px; padding:8px 10px; color:#1E293B; }"
|
||||
"QLineEdit:focus { border-color:#3B82F6; }"));
|
||||
connect(m_promptEdit, &QLineEdit::returnPressed, this, &VisionResultWidget::sendPrompt);
|
||||
promptRow->addWidget(m_promptEdit, 1);
|
||||
m_sendButton = new QPushButton(tr("发送"), rightPane);
|
||||
m_sendButton->setFixedSize(72, 34);
|
||||
m_sendButton->setStyleSheet(QStringLiteral(
|
||||
"QPushButton { background:#2563EB; border:none; border-radius:8px; color:white; font-weight:600; }"
|
||||
"QPushButton:hover { background:#1D4ED8; }"
|
||||
"QPushButton:disabled { background:#94A3B8; }"));
|
||||
connect(m_sendButton, &QPushButton::clicked, this, &VisionResultWidget::sendPrompt);
|
||||
promptRow->addWidget(m_sendButton);
|
||||
rightLayout->addLayout(promptRow);
|
||||
|
||||
auto *resultTitle = new QLabel(tr("分析结果"), rightPane);
|
||||
resultTitle->setStyleSheet(QStringLiteral("color:#334155; font-size:12px; font-weight:600;"));
|
||||
rightLayout->addWidget(resultTitle);
|
||||
|
||||
m_resultEdit = new QTextEdit(rightPane);
|
||||
m_resultEdit->setReadOnly(true);
|
||||
m_resultEdit->setPlaceholderText(tr("分析结果会显示在这里…"));
|
||||
m_resultEdit->setStyleSheet(QStringLiteral(
|
||||
"QTextEdit { background:#FFFFFF; border:1px solid #E2E8F0; border-radius:10px; padding:8px; color:#1E293B; font-size:14px; }"));
|
||||
rightLayout->addWidget(m_resultEdit, 1);
|
||||
|
||||
auto *bottomRow = new QHBoxLayout;
|
||||
bottomRow->setContentsMargins(0, 0, 0, 0);
|
||||
bottomRow->addStretch();
|
||||
m_copyButton = new QPushButton(tr("复制结果"), rightPane);
|
||||
m_copyButton->setFixedSize(92, 32);
|
||||
m_copyButton->setStyleSheet(QStringLiteral(
|
||||
"QPushButton { background:#EFF6FF; border:1px solid #3B82F6; border-radius:8px; color:#2563EB; }"
|
||||
"QPushButton:hover { background:#DBEAFE; }"));
|
||||
connect(m_copyButton, &QPushButton::clicked, this, [this]() {
|
||||
if (!m_resultMarkdown.trimmed().isEmpty()) {
|
||||
qApp->clipboard()->setText(m_resultMarkdown);
|
||||
showCopyFeedback();
|
||||
}
|
||||
});
|
||||
bottomRow->addWidget(m_copyButton);
|
||||
rightLayout->addLayout(bottomRow);
|
||||
contentLayout->addWidget(rightPane, 1);
|
||||
|
||||
cardLayout->addWidget(content, 1);
|
||||
|
||||
m_copyFeedback = new QLabel(tr("已复制!"), m_card);
|
||||
m_copyFeedback->setAlignment(Qt::AlignCenter);
|
||||
m_copyFeedback->setFixedSize(100, 28);
|
||||
m_copyFeedback->setStyleSheet(QStringLiteral(
|
||||
"background:#22C55E; color:white; border-radius:6px;"));
|
||||
m_copyFeedback->hide();
|
||||
|
||||
if (m_viewModel) {
|
||||
connect(m_viewModel, &VisionViewModel::analysisStarted, this,
|
||||
[this](const QString &imageUrl, const QString &prompt, const QImage &image) {
|
||||
if (imageUrl != m_imageUrl) {
|
||||
return;
|
||||
}
|
||||
m_image = image;
|
||||
m_promptEdit->setText(prompt);
|
||||
if (!image.isNull()) {
|
||||
m_previewLabel->setText(QString());
|
||||
m_previewLabel->setPixmap(QPixmap::fromImage(image).scaled(m_previewLabel->size(),
|
||||
Qt::KeepAspectRatio,
|
||||
Qt::SmoothTransformation));
|
||||
}
|
||||
m_resultMarkdown.clear();
|
||||
m_resultEdit->clear();
|
||||
setLoadingState(true, tr("正在分析图片内容…"));
|
||||
});
|
||||
connect(m_viewModel, &VisionViewModel::analysisSucceeded, this,
|
||||
[this](const QString &imageUrl, const QString &, const QString &result, const QImage &image) {
|
||||
if (imageUrl != m_imageUrl) {
|
||||
return;
|
||||
}
|
||||
m_image = image;
|
||||
m_resultMarkdown = result;
|
||||
m_resultEdit->setMarkdown(result);
|
||||
setLoadingState(false, tr("分析完成。"));
|
||||
m_copyButton->setEnabled(!result.trimmed().isEmpty());
|
||||
showAndActivate();
|
||||
});
|
||||
connect(m_viewModel, &VisionViewModel::analysisFailed, this,
|
||||
[this](const QString &imageUrl, const QString &, const QString &errorMsg) {
|
||||
if (imageUrl != m_imageUrl) {
|
||||
return;
|
||||
}
|
||||
setLoadingState(false, errorMsg);
|
||||
if (m_resultMarkdown.trimmed().isEmpty()) {
|
||||
m_resultEdit->setPlainText(errorMsg);
|
||||
}
|
||||
m_copyButton->setEnabled(!m_resultMarkdown.trimmed().isEmpty());
|
||||
showAndActivate();
|
||||
});
|
||||
}
|
||||
|
||||
setLoadingState(false);
|
||||
}
|
||||
|
||||
void VisionResultWidget::showForImage(const QString &imageUrl,
|
||||
const QImage &image,
|
||||
const QString &prompt)
|
||||
{
|
||||
m_imageUrl = imageUrl;
|
||||
m_image = image;
|
||||
const QString initialPrompt = prompt.isEmpty()
|
||||
? tr("请总结这张截图里的重点信息")
|
||||
: prompt;
|
||||
m_promptEdit->setText(initialPrompt);
|
||||
m_resultMarkdown.clear();
|
||||
m_resultEdit->clear();
|
||||
setLoadingState(false, tr("输入提示词后点击发送。"));
|
||||
if (!image.isNull()) {
|
||||
m_previewLabel->setText(QString());
|
||||
m_previewLabel->setPixmap(QPixmap::fromImage(image).scaled(m_previewLabel->size(),
|
||||
Qt::KeepAspectRatio,
|
||||
Qt::SmoothTransformation));
|
||||
} else {
|
||||
m_previewLabel->setPixmap(QPixmap());
|
||||
m_previewLabel->setText(tr("暂无预览"));
|
||||
}
|
||||
showAndActivate();
|
||||
}
|
||||
|
||||
void VisionResultWidget::showAndActivate()
|
||||
{
|
||||
if (!isVisible()) {
|
||||
if (QScreen *screen = QGuiApplication::primaryScreen()) {
|
||||
const QRect available = screen->availableGeometry();
|
||||
move(available.center() - QPoint(width() / 2, height() / 2));
|
||||
}
|
||||
show();
|
||||
}
|
||||
raise();
|
||||
activateWindow();
|
||||
}
|
||||
|
||||
void VisionResultWidget::closeEvent(QCloseEvent *event)
|
||||
{
|
||||
hide();
|
||||
event->ignore();
|
||||
}
|
||||
|
||||
void VisionResultWidget::mousePressEvent(QMouseEvent *event)
|
||||
{
|
||||
if (event->button() == Qt::LeftButton && m_titleBar->geometry().contains(event->position().toPoint())) {
|
||||
m_dragOffset = event->globalPosition().toPoint() - frameGeometry().topLeft();
|
||||
}
|
||||
QWidget::mousePressEvent(event);
|
||||
}
|
||||
|
||||
void VisionResultWidget::mouseMoveEvent(QMouseEvent *event)
|
||||
{
|
||||
if ((event->buttons() & Qt::LeftButton) && !m_dragOffset.isNull()) {
|
||||
move(event->globalPosition().toPoint() - m_dragOffset);
|
||||
}
|
||||
QWidget::mouseMoveEvent(event);
|
||||
}
|
||||
|
||||
void VisionResultWidget::keyPressEvent(QKeyEvent *event)
|
||||
{
|
||||
if (event->key() == Qt::Key_Escape) {
|
||||
close();
|
||||
return;
|
||||
}
|
||||
QWidget::keyPressEvent(event);
|
||||
}
|
||||
|
||||
void VisionResultWidget::sendPrompt()
|
||||
{
|
||||
if (!m_viewModel) {
|
||||
setLoadingState(false, tr("视觉功能当前不可用。"));
|
||||
return;
|
||||
}
|
||||
if (m_imageUrl.isEmpty()) {
|
||||
setLoadingState(false, tr("当前没有可分析的图片。"));
|
||||
return;
|
||||
}
|
||||
const QString prompt = m_promptEdit->text().trimmed();
|
||||
if (prompt.isEmpty()) {
|
||||
setLoadingState(false, tr("提示词不能为空。"));
|
||||
return;
|
||||
}
|
||||
m_viewModel->analyzeStickyImage(prompt, m_imageUrl);
|
||||
}
|
||||
|
||||
void VisionResultWidget::setLoadingState(bool loading, const QString &statusText)
|
||||
{
|
||||
m_sendButton->setEnabled(!loading);
|
||||
m_copyButton->setEnabled(!loading && !m_resultMarkdown.trimmed().isEmpty());
|
||||
m_promptEdit->setEnabled(!loading);
|
||||
if (!statusText.isEmpty()) {
|
||||
m_statusLabel->setText(statusText);
|
||||
}
|
||||
}
|
||||
|
||||
void VisionResultWidget::showCopyFeedback()
|
||||
{
|
||||
m_copyFeedback->move(m_card->width() - m_copyFeedback->width() - 16,
|
||||
m_card->height() - m_copyFeedback->height() - 16);
|
||||
m_copyFeedback->show();
|
||||
m_copyFeedback->raise();
|
||||
|
||||
auto *anim = new QPropertyAnimation(m_copyFeedback, "windowOpacity", m_copyFeedback);
|
||||
anim->setDuration(1200);
|
||||
anim->setStartValue(1.0);
|
||||
anim->setEndValue(0.0);
|
||||
connect(anim, &QPropertyAnimation::finished, this, [this, anim]() {
|
||||
m_copyFeedback->hide();
|
||||
m_copyFeedback->setWindowOpacity(1.0);
|
||||
anim->deleteLater();
|
||||
});
|
||||
anim->start();
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
#ifndef VISION_RESULT_WIDGET_H
|
||||
#define VISION_RESULT_WIDGET_H
|
||||
|
||||
#include <QPointer>
|
||||
#include <QWidget>
|
||||
|
||||
class QLabel;
|
||||
class QTextEdit;
|
||||
class QPushButton;
|
||||
class QLineEdit;
|
||||
class VisionViewModel;
|
||||
|
||||
class VisionResultWidget : public QWidget
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit VisionResultWidget(VisionViewModel *viewModel, QWidget *parent = nullptr);
|
||||
|
||||
void showForImage(const QString &imageUrl,
|
||||
const QImage &image,
|
||||
const QString &prompt = QString());
|
||||
void showAndActivate();
|
||||
|
||||
protected:
|
||||
void closeEvent(QCloseEvent *event) override;
|
||||
void mousePressEvent(QMouseEvent *event) override;
|
||||
void mouseMoveEvent(QMouseEvent *event) override;
|
||||
void keyPressEvent(QKeyEvent *event) override;
|
||||
|
||||
private:
|
||||
void sendPrompt();
|
||||
void setLoadingState(bool loading, const QString &statusText = QString());
|
||||
void showCopyFeedback();
|
||||
|
||||
QPointer<VisionViewModel> m_viewModel;
|
||||
QString m_imageUrl;
|
||||
QImage m_image;
|
||||
QWidget *m_card = nullptr;
|
||||
QWidget *m_titleBar = nullptr;
|
||||
QLabel *m_previewLabel = nullptr;
|
||||
QLabel *m_statusLabel = nullptr;
|
||||
QLineEdit *m_promptEdit = nullptr;
|
||||
QTextEdit *m_resultEdit = nullptr;
|
||||
QPushButton *m_sendButton = nullptr;
|
||||
QPushButton *m_copyButton = nullptr;
|
||||
QLabel *m_copyFeedback = nullptr;
|
||||
QPoint m_dragOffset;
|
||||
QString m_resultMarkdown;
|
||||
};
|
||||
|
||||
#endif // VISION_RESULT_WIDGET_H
|
||||
Reference in new issue
Block a user