diff --git a/CMakeLists.txt b/CMakeLists.txt index 11c27f0..b4bdfdc 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 diff --git a/src/ai_call/doubao_vision_call.cpp b/src/ai_call/doubao_vision_call.cpp new file mode 100644 index 0000000..c725621 --- /dev/null +++ b/src/ai_call/doubao_vision_call.cpp @@ -0,0 +1,177 @@ +#include "doubao_vision_call.h" + +#include + +#include + +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(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("响应内容为空"); +} diff --git a/src/ai_call/doubao_vision_call.h b/src/ai_call/doubao_vision_call.h new file mode 100644 index 0000000..5c96bbd --- /dev/null +++ b/src/ai_call/doubao_vision_call.h @@ -0,0 +1,36 @@ +#ifndef DOUBAO_VISION_CALL_H +#define DOUBAO_VISION_CALL_H + +#include "ai_call_base.h" + +#include + +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 diff --git a/src/app/tzshot_app.cpp b/src/app/tzshot_app.cpp index 6961708..c0cd7a3 100644 --- a/src/app/tzshot_app.cpp +++ b/src/app/tzshot_app.cpp @@ -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")); diff --git a/src/app/tzshot_services.cpp b/src/app/tzshot_services.cpp index 8b077a7..ea6211e 100644 --- a/src/app/tzshot_services.cpp +++ b/src/app/tzshot_services.cpp @@ -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); } diff --git a/src/app/tzshot_services.h b/src/app/tzshot_services.h index f79312b..af97819 100644 --- a/src/app/tzshot_services.h +++ b/src/app/tzshot_services.h @@ -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; }; diff --git a/src/app/tzshot_widget_runtime.cpp b/src/app/tzshot_widget_runtime.cpp index c747c70..7466494 100644 --- a/src/app/tzshot_widget_runtime.cpp +++ b/src/app/tzshot_widget_runtime.cpp @@ -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(), diff --git a/src/model/app_settings.cpp b/src/model/app_settings.cpp index 9c1c7b0..ed6373e 100644 --- a/src/model/app_settings.cpp +++ b/src/model/app_settings.cpp @@ -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; diff --git a/src/model/app_settings.h b/src/model/app_settings.h index 88db133..0ae0f7d 100644 --- a/src/model/app_settings.h +++ b/src/model/app_settings.h @@ -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; diff --git a/src/viewmodel/sticky_view_model.cpp b/src/viewmodel/sticky_view_model.cpp index 792f83e..9e293c5 100644 --- a/src/viewmodel/sticky_view_model.cpp +++ b/src/viewmodel/sticky_view_model.cpp @@ -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 @@ -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; } diff --git a/src/viewmodel/sticky_view_model.h b/src/viewmodel/sticky_view_model.h index 6b3ac25..29ccaab 100644 --- a/src/viewmodel/sticky_view_model.h +++ b/src/viewmodel/sticky_view_model.h @@ -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 diff --git a/src/viewmodel/vision_view_model.cpp b/src/viewmodel/vision_view_model.cpp new file mode 100644 index 0000000..a5bf1b9 --- /dev/null +++ b/src/viewmodel/vision_view_model.cpp @@ -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 + +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); +} diff --git a/src/viewmodel/vision_view_model.h b/src/viewmodel/vision_view_model.h new file mode 100644 index 0000000..db37333 --- /dev/null +++ b/src/viewmodel/vision_view_model.h @@ -0,0 +1,68 @@ +#ifndef VISION_VIEW_MODEL_H +#define VISION_VIEW_MODEL_H + +#include +#include + +#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 diff --git a/src/widgets/settings_dialog.cpp b/src/widgets/settings_dialog.cpp index bf3ad80..9878bbf 100644 --- a/src/widgets/settings_dialog.cpp +++ b/src/widgets/settings_dialog.cpp @@ -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 +#include #include #include #include @@ -25,6 +27,7 @@ #include #include #include +#include #include 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(&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(&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); diff --git a/src/widgets/settings_dialog.h b/src/widgets/settings_dialog.h index 2caa6a9..9d29d4b 100644 --- a/src/widgets/settings_dialog.h +++ b/src/widgets/settings_dialog.h @@ -5,6 +5,7 @@ #include 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 m_aiViewModel; + QPointer m_visionViewModel; QPointer m_storageViewModel; QPointer m_languageManager; QPointer 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; diff --git a/src/widgets/sticky_pin_widget.cpp b/src/widgets/sticky_pin_widget.cpp index 84b7547..858b6cc 100644 --- a/src/widgets/sticky_pin_widget.cpp +++ b/src/widgets/sticky_pin_widget.cpp @@ -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 @@ -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; } diff --git a/src/widgets/sticky_pin_widget.h b/src/widgets/sticky_pin_widget.h index 813badb..2dcbbd9 100644 --- a/src/widgets/sticky_pin_widget.h +++ b/src/widgets/sticky_pin_widget.h @@ -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 m_store; QPointer m_aiViewModel; + QPointer 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; diff --git a/src/widgets/vision_result_widget.cpp b/src/widgets/vision_result_widget.cpp new file mode 100644 index 0000000..0e9344b --- /dev/null +++ b/src/widgets/vision_result_widget.cpp @@ -0,0 +1,332 @@ +#include "widgets/vision_result_widget.h" + +#include "viewmodel/vision_view_model.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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(); +} diff --git a/src/widgets/vision_result_widget.h b/src/widgets/vision_result_widget.h new file mode 100644 index 0000000..ffb4ad4 --- /dev/null +++ b/src/widgets/vision_result_widget.h @@ -0,0 +1,52 @@ +#ifndef VISION_RESULT_WIDGET_H +#define VISION_RESULT_WIDGET_H + +#include +#include + +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 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