Add vision analysis workflow and unify AI settings

This commit is contained in:
easyz committed 2026-03-27 22:39:52 +08:00
1 parent 920666e735
commit 609f158735
19 files changed
+1263 -7

No files matched your search

+6
View File
@@ -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
+177
View File
@@ -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 &params)
{
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 &params)
{
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("响应内容为空");
}
+36
View File
@@ -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 &params = 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 &params) 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
+4
View File
@@ -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"));
+2
View File
@@ -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);
}
+3
View File
@@ -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;
};
+1
View File
@@ -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(),
+91
View File
@@ -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;
+21
View File
@@ -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;
+12 -1
View File
@@ -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;
}
+3
View File
@@ -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
+276
View File
@@ -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);
}
+68
View File
@@ -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
+127 -4
View File
@@ -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 &currentModel)
{
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);
+16
View File
@@ -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 &currentModel = 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;
+29 -2
View File
@@ -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;
}
+7
View File
@@ -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;
+332
View File
@@ -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();
}
+52
View File
@@ -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