feat(chat,panel): 对话增强与面板窗口管理;chore: debian 打包
Chat: - 消息操作:编辑用户消息、重试、从任意回复分支 - 上下文:条数设置 + 超出自动压缩(摘要旧消息) - 关于对话框;侧栏更多菜单 Panel: - 拖动边框移动、拖顶部调整高度,位置/高度持久化 - 右键菜单:固定位置、恢复默认位置、固定大小、全屏时隐藏(预留)、 开机自启与保活(写入 gxde-k9 .slimy)、清空暂存区、关于、退出 Packaging: - debian/(control/rules/changelog/copyright/source format) - README 构建与使用说明、CHANGELOG.md
This commit is contained in:
@@ -26,3 +26,10 @@ ui_*.h
|
|||||||
|
|
||||||
# test helpers
|
# test helpers
|
||||||
tests/portal/pointer
|
tests/portal/pointer
|
||||||
|
|
||||||
|
# debian build
|
||||||
|
obj-*/
|
||||||
|
debian/.debhelper/
|
||||||
|
debian/files
|
||||||
|
debian/debhelper-build-stamp
|
||||||
|
debian/gxde-ai-see/
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## 0.1.0 - 2026-10-30
|
||||||
|
|
||||||
|
### 新增
|
||||||
|
|
||||||
|
- 传送门面板:DBlur 双栏、layer-shell 定位、拖拽自动弹出、位置/高度可调并持久化
|
||||||
|
- 暂存区:文件/文字/图片、去重、上限、文件消失清理、固定/消耗语义、全选/反选/删除选中
|
||||||
|
- 内容工厂:按类型给出动作,动作按钮可直接接收 drop
|
||||||
|
- http(s) 链接按扩展名分类,支持下载/另存为/复制/Markdown/待办/问小埃
|
||||||
|
- thunder:// 解析真实地址,magnet 解析名称,非标准 scheme 尝试打开
|
||||||
|
- 文字:ICU 分词、提取链接、朗读
|
||||||
|
- 文件/图片:打开、复制路径、保存图片、占位 OCR/描述
|
||||||
|
- 待办窗口:layer-shell、不抢焦点、复选框/删除/新建、持久化
|
||||||
|
- 小埃同学对话:DeepSeek/自定义 API 流式输出、多会话、设置、三块拖放区、
|
||||||
|
编辑/重试/分支、上下文条数与自动压缩、关于
|
||||||
|
- 合成器 portal(flake-wm / gxde-wlcom):org.gxde.Portal、缩略图坐标映射、
|
||||||
|
原生 drop 到目标窗口
|
||||||
|
- gxde-k9 开机自启与保活开关
|
||||||
|
|
||||||
|
-- shenmo <shenmo@spark-app.store>
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
它把「拖拽」变成贯穿整个桌面的动作:拖到 **传送门**,选一个窗口,内容就落进那个窗口的输入框;
|
它把「拖拽」变成贯穿整个桌面的动作:拖到 **传送门**,选一个窗口,内容就落进那个窗口的输入框;
|
||||||
拖到 **内容工厂**,按类型分词、提链接、翻译、OCR、加待办、问小埃;拖进 **暂存区**,东西先放着,随时再拖出去。
|
拖到 **内容工厂**,按类型分词、提链接、翻译、OCR、加待办、问小埃;拖进 **暂存区**,东西先放着,随时再拖出去。
|
||||||
|
|
||||||
> 当前状态:**方案评审中(v0.2)**,代码尚未开始。设计文档见 [docs/PLAN.md](docs/PLAN.md)。
|
> 当前状态:**0.1.0 初版**,可安装试用。设计文档见 [docs/PLAN.md](docs/PLAN.md)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -106,14 +106,33 @@
|
|||||||
|
|
||||||
## 🔨 构建
|
## 🔨 构建
|
||||||
|
|
||||||
尚未开始编码;构建说明会在 M1 完成后补充:
|
依赖:Qt 6.8、dtk2widget 6、LayerShellQt 6、ICU、Wayland。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cmake -B build -G Ninja
|
cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Release
|
||||||
cmake --build build
|
cmake --build build
|
||||||
|
sudo cmake --install build # 安装程序、desktop 与图标
|
||||||
```
|
```
|
||||||
|
|
||||||
依赖(计划):Qt 6.8、dtk2widget 6、LayerShellQt 6、Wayland、ICU、SQLite。
|
打 deb:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dpkg-buildpackage -b -us -uc
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚀 使用
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gxde-ai-see --show # 打开传送门面板
|
||||||
|
gxde-ai-see --chat # 打开小埃同学对话
|
||||||
|
gxde-ai-see --todo # 打开待办
|
||||||
|
gxde-ai-see --daemon # 后台常驻(由 gxde-k9 拉起)
|
||||||
|
```
|
||||||
|
|
||||||
|
- 首次对话:`--chat` → 左下「设置」→ 填 DeepSeek API Key。
|
||||||
|
- 传送门需要 GXDE Wayland 合成器(flake-wm / gxde-wlcom)配合,见
|
||||||
|
[方案 §4](docs/PLAN.md);其他环境自动降级。
|
||||||
|
- 面板右键菜单:固定位置/大小、恢复默认位置、开机自启与保活、清空、关于。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Vendored
+5
@@ -0,0 +1,5 @@
|
|||||||
|
gxde-ai-see (0.1.0) unstable; urgency=medium
|
||||||
|
|
||||||
|
* Initial release: 传送门面板、暂存区、内容工厂、待办与小埃同学对话。
|
||||||
|
|
||||||
|
-- shenmo <shenmo@spark-app.store> Thu, 30 Oct 2026 20:20:00 +0800
|
||||||
Vendored
+26
@@ -0,0 +1,26 @@
|
|||||||
|
Source: gxde-ai-see
|
||||||
|
Section: utils
|
||||||
|
Priority: optional
|
||||||
|
Maintainer: shenmo <shenmo@spark-app.store>
|
||||||
|
Build-Depends: debhelper-compat (= 13),
|
||||||
|
cmake,
|
||||||
|
ninja-build,
|
||||||
|
pkg-config,
|
||||||
|
qt6-base-dev,
|
||||||
|
qt6-base-dev-tools,
|
||||||
|
qt6-svg-dev,
|
||||||
|
libdtk6core-dev,
|
||||||
|
libdtk2widget6-dev,
|
||||||
|
liblayershellqtinterface-dev,
|
||||||
|
libicu-dev
|
||||||
|
Standards-Version: 4.6.2
|
||||||
|
Rules-Requires-Root: no
|
||||||
|
Homepage: https://gitee.com/GXDE-OS/gxde-ai-see
|
||||||
|
|
||||||
|
Package: gxde-ai-see
|
||||||
|
Architecture: any
|
||||||
|
Depends: ${shlibs:Depends}, ${misc:Depends}
|
||||||
|
Recommends: gxde-k9
|
||||||
|
Description: GXDE AI See - 小埃同学
|
||||||
|
GXDE 桌面的 AI 助手:传送门、内容工厂、待办与小埃同学对话。
|
||||||
|
支持拖拽暂存、原生 drop 到目标窗口,以及 DeepSeek / 自定义 API 的流式对话。
|
||||||
Vendored
+9
@@ -0,0 +1,9 @@
|
|||||||
|
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
|
||||||
|
Upstream-Name: gxde-ai-see
|
||||||
|
Source: https://gitee.com/GXDE-OS/gxde-ai-see
|
||||||
|
|
||||||
|
Files: *
|
||||||
|
Copyright: 2026 GXDE OS Team
|
||||||
|
License: GPL-3.0-or-later
|
||||||
|
On Debian systems, the complete text of the GNU General Public License
|
||||||
|
version 3 can be found in "/usr/share/common-licenses/GPL-3".
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
dh_auto_configure
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
#!/usr/bin/make -f
|
||||||
|
|
||||||
|
%:
|
||||||
|
dh $@
|
||||||
|
|
||||||
|
override_dh_auto_configure:
|
||||||
|
dh_auto_configure -- -DCMAKE_BUILD_TYPE=RelWithDebInfo
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
3.0 (native)
|
||||||
+126
-49
@@ -12,24 +12,16 @@
|
|||||||
|
|
||||||
#include "config.h"
|
#include "config.h"
|
||||||
|
|
||||||
ChatClient::ChatClient(QObject *parent)
|
namespace {
|
||||||
: QObject(parent)
|
|
||||||
{
|
|
||||||
m_manager.setRedirectPolicy(QNetworkRequest::NoLessSafeRedirectPolicy);
|
|
||||||
}
|
|
||||||
|
|
||||||
void ChatClient::send(const QVector<ChatMessage> &messages)
|
bool prepareRequest(const QVector<ChatMessage> &messages, bool stream,
|
||||||
|
QNetworkRequest *request, QByteArray *body, QString *error)
|
||||||
{
|
{
|
||||||
cancel();
|
|
||||||
m_buffer.clear();
|
|
||||||
m_fullText.clear();
|
|
||||||
m_done = false;
|
|
||||||
|
|
||||||
const QString apiKey =
|
const QString apiKey =
|
||||||
Config::instance()->get(QStringLiteral("chat/apiKey")).toString().trimmed();
|
Config::instance()->get(QStringLiteral("chat/apiKey")).toString().trimmed();
|
||||||
if (apiKey.isEmpty()) {
|
if (apiKey.isEmpty()) {
|
||||||
Q_EMIT failed(tr("还没有配置 API Key。请先点右上角设置,填入 DeepSeek API Key。"));
|
*error = QObject::tr("还没有配置 API Key。请先点右上角设置,填入 DeepSeek API Key。");
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
QString baseUrl = Config::instance()
|
QString baseUrl = Config::instance()
|
||||||
@@ -44,11 +36,6 @@ void ChatClient::send(const QVector<ChatMessage> &messages)
|
|||||||
baseUrl += QStringLiteral("/chat/completions");
|
baseUrl += QStringLiteral("/chat/completions");
|
||||||
}
|
}
|
||||||
|
|
||||||
const QString model = Config::instance()
|
|
||||||
->get(QStringLiteral("chat/model"),
|
|
||||||
QStringLiteral("deepseek-chat"))
|
|
||||||
.toString();
|
|
||||||
|
|
||||||
QJsonArray jsonMessages;
|
QJsonArray jsonMessages;
|
||||||
for (const ChatMessage &message : messages) {
|
for (const ChatMessage &message : messages) {
|
||||||
QJsonObject object;
|
QJsonObject object;
|
||||||
@@ -57,33 +44,133 @@ void ChatClient::send(const QVector<ChatMessage> &messages)
|
|||||||
jsonMessages.append(object);
|
jsonMessages.append(object);
|
||||||
}
|
}
|
||||||
|
|
||||||
QJsonObject body;
|
QJsonObject payload;
|
||||||
body.insert(QStringLiteral("model"), model);
|
payload.insert(QStringLiteral("model"),
|
||||||
body.insert(QStringLiteral("messages"), jsonMessages);
|
Config::instance()
|
||||||
body.insert(QStringLiteral("stream"), true);
|
->get(QStringLiteral("chat/model"), QStringLiteral("deepseek-chat"))
|
||||||
body.insert(QStringLiteral("temperature"), 0.7);
|
.toString());
|
||||||
|
payload.insert(QStringLiteral("messages"), jsonMessages);
|
||||||
|
payload.insert(QStringLiteral("stream"), stream);
|
||||||
|
payload.insert(QStringLiteral("temperature"), 0.7);
|
||||||
|
|
||||||
QNetworkRequest request{QUrl(baseUrl)};
|
*request = QNetworkRequest{QUrl(baseUrl)};
|
||||||
request.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/json"));
|
request->setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/json"));
|
||||||
request.setRawHeader("Authorization", "Bearer " + apiKey.toUtf8());
|
request->setRawHeader("Authorization", "Bearer " + apiKey.toUtf8());
|
||||||
request.setAttribute(QNetworkRequest::RedirectPolicyAttribute,
|
request->setAttribute(QNetworkRequest::RedirectPolicyAttribute,
|
||||||
QNetworkRequest::NoLessSafeRedirectPolicy);
|
QNetworkRequest::NoLessSafeRedirectPolicy);
|
||||||
|
*body = QJsonDocument(payload).toJson(QJsonDocument::Compact);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
m_reply = m_manager.post(request, QJsonDocument(body).toJson(QJsonDocument::Compact));
|
QString errorFromBody(const QByteArray &body, const QString &fallback)
|
||||||
|
{
|
||||||
|
const QJsonDocument document = QJsonDocument::fromJson(body);
|
||||||
|
if (document.isObject()) {
|
||||||
|
const QString detail = document.object()
|
||||||
|
.value(QStringLiteral("error"))
|
||||||
|
.toObject()
|
||||||
|
.value(QStringLiteral("message"))
|
||||||
|
.toString();
|
||||||
|
if (!detail.isEmpty()) {
|
||||||
|
return detail;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
ChatClient::ChatClient(QObject *parent)
|
||||||
|
: QObject(parent)
|
||||||
|
{
|
||||||
|
m_manager.setRedirectPolicy(QNetworkRequest::NoLessSafeRedirectPolicy);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChatClient::send(const QVector<ChatMessage> &messages)
|
||||||
|
{
|
||||||
|
cancel();
|
||||||
|
m_buffer.clear();
|
||||||
|
m_fullText.clear();
|
||||||
|
m_done = false;
|
||||||
|
|
||||||
|
QNetworkRequest request;
|
||||||
|
QByteArray body;
|
||||||
|
QString error;
|
||||||
|
if (!prepareRequest(messages, true, &request, &body, &error)) {
|
||||||
|
Q_EMIT failed(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_reply = m_manager.post(request, body);
|
||||||
connect(m_reply, &QNetworkReply::readyRead, this, &ChatClient::handleReadyRead);
|
connect(m_reply, &QNetworkReply::readyRead, this, &ChatClient::handleReadyRead);
|
||||||
connect(m_reply, &QNetworkReply::finished, this, &ChatClient::handleFinished);
|
connect(m_reply, &QNetworkReply::finished, this, &ChatClient::handleFinished);
|
||||||
}
|
}
|
||||||
|
|
||||||
void ChatClient::cancel()
|
void ChatClient::sendOnce(const QVector<ChatMessage> &messages)
|
||||||
{
|
{
|
||||||
if (!m_reply) {
|
if (m_onceReply) {
|
||||||
|
m_onceReply->abort();
|
||||||
|
m_onceReply->deleteLater();
|
||||||
|
m_onceReply = nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
QNetworkRequest request;
|
||||||
|
QByteArray body;
|
||||||
|
QString error;
|
||||||
|
if (!prepareRequest(messages, false, &request, &body, &error)) {
|
||||||
|
Q_EMIT onceFailed(error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
QNetworkReply *reply = m_reply;
|
|
||||||
m_reply = nullptr;
|
m_onceReply = m_manager.post(request, body);
|
||||||
reply->disconnect(this);
|
connect(m_onceReply, &QNetworkReply::finished, this, [this]() {
|
||||||
reply->abort();
|
if (!m_onceReply) {
|
||||||
reply->deleteLater();
|
return;
|
||||||
|
}
|
||||||
|
QNetworkReply *reply = m_onceReply;
|
||||||
|
m_onceReply = nullptr;
|
||||||
|
|
||||||
|
if (reply->error() != QNetworkReply::NoError) {
|
||||||
|
Q_EMIT onceFailed(errorFromBody(reply->readAll(), reply->errorString()));
|
||||||
|
reply->deleteLater();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QJsonDocument document = QJsonDocument::fromJson(reply->readAll());
|
||||||
|
const QJsonArray choices =
|
||||||
|
document.object().value(QStringLiteral("choices")).toArray();
|
||||||
|
if (choices.isEmpty()) {
|
||||||
|
Q_EMIT onceFailed(tr("模型没有返回内容"));
|
||||||
|
reply->deleteLater();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const QString content = choices.first()
|
||||||
|
.toObject()
|
||||||
|
.value(QStringLiteral("message"))
|
||||||
|
.toObject()
|
||||||
|
.value(QStringLiteral("content"))
|
||||||
|
.toString();
|
||||||
|
Q_EMIT onceFinished(content);
|
||||||
|
reply->deleteLater();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChatClient::cancel()
|
||||||
|
{
|
||||||
|
if (m_reply) {
|
||||||
|
QNetworkReply *reply = m_reply;
|
||||||
|
m_reply = nullptr;
|
||||||
|
reply->disconnect(this);
|
||||||
|
reply->abort();
|
||||||
|
reply->deleteLater();
|
||||||
|
}
|
||||||
|
if (m_onceReply) {
|
||||||
|
QNetworkReply *reply = m_onceReply;
|
||||||
|
m_onceReply = nullptr;
|
||||||
|
reply->disconnect(this);
|
||||||
|
reply->abort();
|
||||||
|
reply->deleteLater();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void ChatClient::handleReadyRead()
|
void ChatClient::handleReadyRead()
|
||||||
@@ -148,18 +235,9 @@ void ChatClient::handleFinished()
|
|||||||
m_reply = nullptr;
|
m_reply = nullptr;
|
||||||
|
|
||||||
const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
|
const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
|
||||||
if (reply->error() != QNetworkReply::NoError && reply->error() != QNetworkReply::OperationCanceledError) {
|
if (reply->error() != QNetworkReply::NoError &&
|
||||||
const QByteArray body = reply->readAll();
|
reply->error() != QNetworkReply::OperationCanceledError) {
|
||||||
QString message = reply->errorString();
|
QString message = errorFromBody(reply->readAll(), reply->errorString());
|
||||||
const QJsonDocument document = QJsonDocument::fromJson(body);
|
|
||||||
if (document.isObject()) {
|
|
||||||
const QJsonObject error =
|
|
||||||
document.object().value(QStringLiteral("error")).toObject();
|
|
||||||
const QString detail = error.value(QStringLiteral("message")).toString();
|
|
||||||
if (!detail.isEmpty()) {
|
|
||||||
message = detail;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (status == 401) {
|
if (status == 401) {
|
||||||
message = tr("API Key 无效或已过期");
|
message = tr("API Key 无效或已过期");
|
||||||
}
|
}
|
||||||
@@ -168,7 +246,6 @@ void ChatClient::handleFinished()
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* flush a final partial line */
|
|
||||||
if (!m_buffer.isEmpty()) {
|
if (!m_buffer.isEmpty()) {
|
||||||
QByteArray line = m_buffer.trimmed();
|
QByteArray line = m_buffer.trimmed();
|
||||||
if (line.startsWith("data:")) {
|
if (line.startsWith("data:")) {
|
||||||
|
|||||||
@@ -15,7 +15,10 @@ struct ChatMessage
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Minimal streaming client for the DeepSeek (OpenAI compatible) chat API.
|
* Minimal client for the DeepSeek (OpenAI compatible) chat API.
|
||||||
|
*
|
||||||
|
* \a send streams (SSE), \a sendOnce performs a non streaming request used for
|
||||||
|
* context compression and other housekeeping calls.
|
||||||
*/
|
*/
|
||||||
class ChatClient : public QObject
|
class ChatClient : public QObject
|
||||||
{
|
{
|
||||||
@@ -26,12 +29,15 @@ public:
|
|||||||
|
|
||||||
bool busy() const { return m_reply != nullptr; }
|
bool busy() const { return m_reply != nullptr; }
|
||||||
void send(const QVector<ChatMessage> &messages);
|
void send(const QVector<ChatMessage> &messages);
|
||||||
|
void sendOnce(const QVector<ChatMessage> &messages);
|
||||||
void cancel();
|
void cancel();
|
||||||
|
|
||||||
signals:
|
signals:
|
||||||
void delta(const QString &text);
|
void delta(const QString &text);
|
||||||
void finished(const QString &text);
|
void finished(const QString &text);
|
||||||
void failed(const QString &error);
|
void failed(const QString &error);
|
||||||
|
void onceFinished(const QString &text);
|
||||||
|
void onceFailed(const QString &error);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
void handleReadyRead();
|
void handleReadyRead();
|
||||||
@@ -40,6 +46,7 @@ private:
|
|||||||
|
|
||||||
QNetworkAccessManager m_manager;
|
QNetworkAccessManager m_manager;
|
||||||
class QNetworkReply *m_reply = nullptr;
|
class QNetworkReply *m_reply = nullptr;
|
||||||
|
class QNetworkReply *m_onceReply = nullptr;
|
||||||
QByteArray m_buffer;
|
QByteArray m_buffer;
|
||||||
QString m_fullText;
|
QString m_fullText;
|
||||||
bool m_done = false;
|
bool m_done = false;
|
||||||
|
|||||||
+292
-7
@@ -5,7 +5,9 @@
|
|||||||
|
|
||||||
#include "config.h"
|
#include "config.h"
|
||||||
|
|
||||||
|
#include <QCheckBox>
|
||||||
#include <QComboBox>
|
#include <QComboBox>
|
||||||
|
#include <QDesktopServices>
|
||||||
#include <QDateTime>
|
#include <QDateTime>
|
||||||
#include <QDialog>
|
#include <QDialog>
|
||||||
#include <QDialogButtonBox>
|
#include <QDialogButtonBox>
|
||||||
@@ -23,12 +25,16 @@
|
|||||||
#include <QLabel>
|
#include <QLabel>
|
||||||
#include <QLineEdit>
|
#include <QLineEdit>
|
||||||
#include <QListWidget>
|
#include <QListWidget>
|
||||||
|
#include <QMenu>
|
||||||
|
#include <QMessageBox>
|
||||||
#include <QMimeData>
|
#include <QMimeData>
|
||||||
#include <QPlainTextEdit>
|
#include <QPlainTextEdit>
|
||||||
#include <QPushButton>
|
#include <QPushButton>
|
||||||
#include <QSaveFile>
|
#include <QSaveFile>
|
||||||
#include <QScreen>
|
#include <QScreen>
|
||||||
|
#include <QSpinBox>
|
||||||
#include <QShortcut>
|
#include <QShortcut>
|
||||||
|
#include <QTextCursor>
|
||||||
#include <QTextBrowser>
|
#include <QTextBrowser>
|
||||||
#include <QUrl>
|
#include <QUrl>
|
||||||
#include <QUuid>
|
#include <QUuid>
|
||||||
@@ -170,6 +176,42 @@ ChatWindow::ChatWindow(QWidget *parent)
|
|||||||
save();
|
save();
|
||||||
renderConversation();
|
renderConversation();
|
||||||
});
|
});
|
||||||
|
connect(m_client, &ChatClient::onceFinished, this, [this](const QString &summary) {
|
||||||
|
m_compressing = false;
|
||||||
|
if (m_current >= 0) {
|
||||||
|
ChatConversation &conversation = m_conversations[m_current];
|
||||||
|
const int keep = qMax(2, Config::instance()
|
||||||
|
->getInt(QStringLiteral("chat/contextLength"), 40) /
|
||||||
|
4);
|
||||||
|
const int keepCount = qMin(keep, conversation.messages.size());
|
||||||
|
const QVector<ChatMessage> kept =
|
||||||
|
conversation.messages.mid(conversation.messages.size() - keepCount);
|
||||||
|
conversation.messages.clear();
|
||||||
|
conversation.messages.append(
|
||||||
|
ChatMessage{ QStringLiteral("system"),
|
||||||
|
QStringLiteral("【此前对话摘要】\n") + summary });
|
||||||
|
conversation.messages += kept;
|
||||||
|
save();
|
||||||
|
renderConversation();
|
||||||
|
}
|
||||||
|
m_status->hide();
|
||||||
|
if (m_afterCompressSend) {
|
||||||
|
m_afterCompressSend = false;
|
||||||
|
m_skipCompress = true;
|
||||||
|
requestCompletion();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
connect(m_client, &ChatClient::onceFailed, this, [this](const QString &error) {
|
||||||
|
m_compressing = false;
|
||||||
|
m_status->hide();
|
||||||
|
if (m_afterCompressSend) {
|
||||||
|
m_afterCompressSend = false;
|
||||||
|
m_skipCompress = true;
|
||||||
|
requestCompletion();
|
||||||
|
} else {
|
||||||
|
appendError(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
connect(m_client, &ChatClient::failed, this, [this](const QString &error) {
|
connect(m_client, &ChatClient::failed, this, [this](const QString &error) {
|
||||||
if (m_current >= 0 && m_streamingIndex >= 0 &&
|
if (m_current >= 0 && m_streamingIndex >= 0 &&
|
||||||
m_streamingIndex < m_conversations[m_current].messages.size()) {
|
m_streamingIndex < m_conversations[m_current].messages.size()) {
|
||||||
@@ -228,6 +270,20 @@ void ChatWindow::setupUi()
|
|||||||
"QPushButton:hover { background: rgba(120,180,255,0.28); color: white; }"));
|
"QPushButton:hover { background: rgba(120,180,255,0.28); color: white; }"));
|
||||||
sidebarLayout->addWidget(newButton);
|
sidebarLayout->addWidget(newButton);
|
||||||
|
|
||||||
|
auto *menuButton = new QPushButton(tr("⋯ 更多"), sidebarPane);
|
||||||
|
menuButton->setCursor(Qt::PointingHandCursor);
|
||||||
|
menuButton->setStyleSheet(QStringLiteral(
|
||||||
|
"QPushButton { color: rgba(255,255,255,0.55); background: transparent;"
|
||||||
|
" border: none; padding: 2px; font-size: 12px; }"
|
||||||
|
"QPushButton:hover { color: white; }"));
|
||||||
|
auto *menu = new QMenu(menuButton);
|
||||||
|
menu->addAction(tr("压缩上下文"), this, [this]() { beginCompression(false); });
|
||||||
|
menu->addAction(tr("重试上一条"), this, &ChatWindow::retryLast);
|
||||||
|
menu->addSeparator();
|
||||||
|
menu->addAction(tr("关于"), this, &ChatWindow::showAbout);
|
||||||
|
menuButton->setMenu(menu);
|
||||||
|
sidebarLayout->addWidget(menuButton);
|
||||||
|
|
||||||
auto *settingsButton = new QPushButton(tr("设置"), sidebarPane);
|
auto *settingsButton = new QPushButton(tr("设置"), sidebarPane);
|
||||||
settingsButton->setCursor(Qt::PointingHandCursor);
|
settingsButton->setCursor(Qt::PointingHandCursor);
|
||||||
settingsButton->setStyleSheet(QStringLiteral(
|
settingsButton->setStyleSheet(QStringLiteral(
|
||||||
@@ -244,7 +300,7 @@ void ChatWindow::setupUi()
|
|||||||
rightLayout->setSpacing(10);
|
rightLayout->setSpacing(10);
|
||||||
|
|
||||||
m_view = new ChatView(right);
|
m_view = new ChatView(right);
|
||||||
m_view->setOpenExternalLinks(true);
|
m_view->setOpenExternalLinks(false);
|
||||||
m_view->setFrameShape(QFrame::NoFrame);
|
m_view->setFrameShape(QFrame::NoFrame);
|
||||||
m_view->setStyleSheet(QStringLiteral(
|
m_view->setStyleSheet(QStringLiteral(
|
||||||
"QTextBrowser { background: transparent; color: rgba(240,240,240,0.94);"
|
"QTextBrowser { background: transparent; color: rgba(240,240,240,0.94);"
|
||||||
@@ -295,6 +351,7 @@ void ChatWindow::setupUi()
|
|||||||
&ChatWindow::handleDropOnConversation);
|
&ChatWindow::handleDropOnConversation);
|
||||||
connect(static_cast<ChatView *>(m_view), &ChatView::dropped, this,
|
connect(static_cast<ChatView *>(m_view), &ChatView::dropped, this,
|
||||||
&ChatWindow::handleDroppedMime);
|
&ChatWindow::handleDroppedMime);
|
||||||
|
connect(m_view, &QTextBrowser::anchorClicked, this, &ChatWindow::handleAnchor);
|
||||||
|
|
||||||
auto *sendShortcut = new QShortcut(QKeySequence(Qt::CTRL | Qt::Key_Return), m_input);
|
auto *sendShortcut = new QShortcut(QKeySequence(Qt::CTRL | Qt::Key_Return), m_input);
|
||||||
connect(sendShortcut, &QShortcut::activated, this, &ChatWindow::sendMessage);
|
connect(sendShortcut, &QShortcut::activated, this, &ChatWindow::sendMessage);
|
||||||
@@ -444,7 +501,8 @@ void ChatWindow::renderConversation()
|
|||||||
.arg(escapedHtml(conversation.title));
|
.arg(escapedHtml(conversation.title));
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const ChatMessage &message : conversation.messages) {
|
for (int i = 0; i < conversation.messages.size(); ++i) {
|
||||||
|
const ChatMessage &message = conversation.messages.at(i);
|
||||||
const QString role = message.role == QStringLiteral("user") ? tr("你") : tr("小埃");
|
const QString role = message.role == QStringLiteral("user") ? tr("你") : tr("小埃");
|
||||||
const QString cssClass =
|
const QString cssClass =
|
||||||
message.role == QStringLiteral("user") ? QStringLiteral("user")
|
message.role == QStringLiteral("user") ? QStringLiteral("user")
|
||||||
@@ -452,6 +510,17 @@ void ChatWindow::renderConversation()
|
|||||||
html += QStringLiteral("<div class='role'>%1</div>").arg(role);
|
html += QStringLiteral("<div class='role'>%1</div>").arg(role);
|
||||||
html += QStringLiteral("<div class='%1'>%2</div>")
|
html += QStringLiteral("<div class='%1'>%2</div>")
|
||||||
.arg(cssClass, escapedHtml(message.content));
|
.arg(cssClass, escapedHtml(message.content));
|
||||||
|
|
||||||
|
html += QStringLiteral("<div class='actions'>");
|
||||||
|
if (message.role == QStringLiteral("user")) {
|
||||||
|
html += QStringLiteral("<a href='gxde-ai-see:edit/%1'>编辑</a>").arg(i);
|
||||||
|
} else if (message.role == QStringLiteral("assistant")) {
|
||||||
|
html += QStringLiteral("<a href='gxde-ai-see:fork/%1'>分支</a>").arg(i);
|
||||||
|
if (i == conversation.messages.size() - 1) {
|
||||||
|
html += QStringLiteral(" · <a href='gxde-ai-see:retry'>重试</a>");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
html += QStringLiteral("</div>");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (m_client->busy() && m_streamingIndex >= 0) {
|
if (m_client->busy() && m_streamingIndex >= 0) {
|
||||||
@@ -466,7 +535,7 @@ void ChatWindow::renderConversation()
|
|||||||
|
|
||||||
void ChatWindow::sendMessage()
|
void ChatWindow::sendMessage()
|
||||||
{
|
{
|
||||||
if (m_client->busy()) {
|
if (m_client->busy() || m_compressing) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const QString text = m_input->toPlainText().trimmed();
|
const QString text = m_input->toPlainText().trimmed();
|
||||||
@@ -482,15 +551,40 @@ void ChatWindow::sendMessage()
|
|||||||
conversation.title = text.left(24);
|
conversation.title = text.left(24);
|
||||||
}
|
}
|
||||||
conversation.messages.append(ChatMessage{ QStringLiteral("user"), text });
|
conversation.messages.append(ChatMessage{ QStringLiteral("user"), text });
|
||||||
conversation.messages.append(ChatMessage{ QStringLiteral("assistant"), QString() });
|
|
||||||
conversation.updatedAt = QDateTime::currentMSecsSinceEpoch();
|
conversation.updatedAt = QDateTime::currentMSecsSinceEpoch();
|
||||||
m_streamingIndex = conversation.messages.size() - 1;
|
m_skipCompress = false;
|
||||||
|
|
||||||
m_input->clear();
|
m_input->clear();
|
||||||
m_status->hide();
|
m_status->hide();
|
||||||
m_sendButton->setEnabled(false);
|
|
||||||
updateSidebar();
|
updateSidebar();
|
||||||
|
requestCompletion();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChatWindow::requestCompletion()
|
||||||
|
{
|
||||||
|
if (m_current < 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (needsCompression()) {
|
||||||
|
beginCompression(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ChatConversation &conversation = m_conversations[m_current];
|
||||||
|
conversation.messages.append(ChatMessage{ QStringLiteral("assistant"), QString() });
|
||||||
|
m_streamingIndex = conversation.messages.size() - 1;
|
||||||
|
m_sendButton->setEnabled(false);
|
||||||
renderConversation();
|
renderConversation();
|
||||||
|
sendPayload();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChatWindow::sendPayload()
|
||||||
|
{
|
||||||
|
if (m_current < 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ChatConversation &conversation = m_conversations[m_current];
|
||||||
|
|
||||||
QVector<ChatMessage> payload;
|
QVector<ChatMessage> payload;
|
||||||
const QString systemPrompt =
|
const QString systemPrompt =
|
||||||
@@ -498,12 +592,189 @@ void ChatWindow::sendMessage()
|
|||||||
if (!systemPrompt.isEmpty()) {
|
if (!systemPrompt.isEmpty()) {
|
||||||
payload.append(ChatMessage{ QStringLiteral("system"), systemPrompt });
|
payload.append(ChatMessage{ QStringLiteral("system"), systemPrompt });
|
||||||
}
|
}
|
||||||
for (int i = 0; i < conversation.messages.size() - 1; ++i) {
|
|
||||||
|
const int keep = Config::instance()->getInt(QStringLiteral("chat/contextLength"), 40);
|
||||||
|
int start = 0;
|
||||||
|
if (keep > 0 && conversation.messages.size() > keep) {
|
||||||
|
start = conversation.messages.size() - keep;
|
||||||
|
}
|
||||||
|
for (int i = start; i < conversation.messages.size() - 1; ++i) {
|
||||||
payload.append(conversation.messages.at(i));
|
payload.append(conversation.messages.at(i));
|
||||||
}
|
}
|
||||||
|
|
||||||
m_client->send(payload);
|
m_client->send(payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool ChatWindow::needsCompression() const
|
||||||
|
{
|
||||||
|
if (m_current < 0 || m_skipCompress) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!Config::instance()->getBool(QStringLiteral("chat/autoCompress"), true)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const int keep = Config::instance()->getInt(QStringLiteral("chat/contextLength"), 40);
|
||||||
|
return m_conversations.at(m_current).messages.size() > keep + 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChatWindow::beginCompression(bool thenSend)
|
||||||
|
{
|
||||||
|
if (m_current < 0 || m_compressing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ChatConversation &conversation = m_conversations[m_current];
|
||||||
|
const int keep = qMax(2, Config::instance()
|
||||||
|
->getInt(QStringLiteral("chat/contextLength"), 40) /
|
||||||
|
4);
|
||||||
|
if (conversation.messages.size() <= keep + 2) {
|
||||||
|
if (thenSend) {
|
||||||
|
m_skipCompress = true;
|
||||||
|
requestCompletion();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QVector<ChatMessage> older =
|
||||||
|
conversation.messages.mid(0, conversation.messages.size() - keep);
|
||||||
|
QString transcript;
|
||||||
|
for (const ChatMessage &message : older) {
|
||||||
|
transcript += (message.role == QStringLiteral("user") ? QStringLiteral("用户:")
|
||||||
|
: QStringLiteral("助手:"));
|
||||||
|
transcript += message.content;
|
||||||
|
transcript += QLatin1Char('\n');
|
||||||
|
}
|
||||||
|
transcript = transcript.left(20000);
|
||||||
|
|
||||||
|
QVector<ChatMessage> request;
|
||||||
|
request.append(ChatMessage{
|
||||||
|
QStringLiteral("system"),
|
||||||
|
QStringLiteral("你是一个对话摘要助手。请用中文把下面的对话压缩成要点,保留事实、结论、"
|
||||||
|
"待办和用户偏好,不要编造。") });
|
||||||
|
request.append(ChatMessage{ QStringLiteral("user"), transcript });
|
||||||
|
|
||||||
|
m_compressing = true;
|
||||||
|
m_afterCompressSend = thenSend;
|
||||||
|
m_status->setText(tr("正在压缩上下文…"));
|
||||||
|
m_status->show();
|
||||||
|
m_client->sendOnce(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChatWindow::editMessage(int index)
|
||||||
|
{
|
||||||
|
if (m_client->busy() || m_compressing || m_current < 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ChatConversation &conversation = m_conversations[m_current];
|
||||||
|
if (index < 0 || index >= conversation.messages.size()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (conversation.messages.at(index).role != QStringLiteral("user")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
QDialog dialog(this);
|
||||||
|
dialog.setWindowTitle(tr("编辑消息"));
|
||||||
|
dialog.setMinimumWidth(520);
|
||||||
|
auto *layout = new QVBoxLayout(&dialog);
|
||||||
|
auto *edit = new QPlainTextEdit(&dialog);
|
||||||
|
edit->setPlainText(conversation.messages.at(index).content);
|
||||||
|
edit->setFixedHeight(160);
|
||||||
|
layout->addWidget(edit);
|
||||||
|
auto *buttons =
|
||||||
|
new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, &dialog);
|
||||||
|
connect(buttons, &QDialogButtonBox::accepted, &dialog, &QDialog::accept);
|
||||||
|
connect(buttons, &QDialogButtonBox::rejected, &dialog, &QDialog::reject);
|
||||||
|
layout->addWidget(buttons);
|
||||||
|
|
||||||
|
if (dialog.exec() != QDialog::Accepted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const QString text = edit->toPlainText().trimmed();
|
||||||
|
if (text.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
conversation.messages = conversation.messages.mid(0, index);
|
||||||
|
conversation.messages.append(ChatMessage{ QStringLiteral("user"), text });
|
||||||
|
conversation.updatedAt = QDateTime::currentMSecsSinceEpoch();
|
||||||
|
save();
|
||||||
|
updateSidebar();
|
||||||
|
requestCompletion();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChatWindow::retryLast()
|
||||||
|
{
|
||||||
|
if (m_client->busy() || m_compressing || m_current < 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ChatConversation &conversation = m_conversations[m_current];
|
||||||
|
int lastUser = -1;
|
||||||
|
for (int i = conversation.messages.size() - 1; i >= 0; --i) {
|
||||||
|
if (conversation.messages.at(i).role == QStringLiteral("user")) {
|
||||||
|
lastUser = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (lastUser < 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
conversation.messages = conversation.messages.mid(0, lastUser + 1);
|
||||||
|
requestCompletion();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChatWindow::forkFrom(int index)
|
||||||
|
{
|
||||||
|
if (m_current < 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ChatConversation &source = m_conversations[m_current];
|
||||||
|
if (index < 0 || index >= source.messages.size()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ChatConversation copy;
|
||||||
|
copy.id = QUuid::createUuid().toString(QUuid::WithoutBraces);
|
||||||
|
copy.title = tr("%1 · 分支").arg(source.title);
|
||||||
|
copy.updatedAt = QDateTime::currentMSecsSinceEpoch();
|
||||||
|
copy.messages = source.messages.mid(0, index + 1);
|
||||||
|
|
||||||
|
m_conversations.prepend(copy);
|
||||||
|
m_current = 0;
|
||||||
|
updateSidebar();
|
||||||
|
save();
|
||||||
|
renderConversation();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChatWindow::handleAnchor(const QUrl &url)
|
||||||
|
{
|
||||||
|
const QString target = url.toString();
|
||||||
|
if (target.startsWith(QStringLiteral("http://")) ||
|
||||||
|
target.startsWith(QStringLiteral("https://"))) {
|
||||||
|
QDesktopServices::openUrl(url);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (target.startsWith(QStringLiteral("gxde-ai-see:edit/"))) {
|
||||||
|
editMessage(target.section(QLatin1Char('/'), -1).toInt());
|
||||||
|
} else if (target.startsWith(QStringLiteral("gxde-ai-see:fork/"))) {
|
||||||
|
forkFrom(target.section(QLatin1Char('/'), -1).toInt());
|
||||||
|
} else if (target == QStringLiteral("gxde-ai-see:retry")) {
|
||||||
|
retryLast();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChatWindow::showAbout()
|
||||||
|
{
|
||||||
|
QMessageBox::about(
|
||||||
|
this, tr("关于小埃同学"),
|
||||||
|
tr("<h3>GXDE 小埃同学</h3>"
|
||||||
|
"<p>版本 %1</p>"
|
||||||
|
"<p>GXDE 桌面的 AI 助手:传送门、内容工厂、待办与对话。</p>"
|
||||||
|
"<p>许可证:GPL-3.0-or-later<br/>"
|
||||||
|
"仓库:<a href='https://gitee.com/GXDE-OS/gxde-ai-see'>"
|
||||||
|
"gitee.com/GXDE-OS/gxde-ai-see</a></p>")
|
||||||
|
.arg(QCoreApplication::applicationVersion()));
|
||||||
|
}
|
||||||
|
|
||||||
void ChatWindow::appendError(const QString &message)
|
void ChatWindow::appendError(const QString &message)
|
||||||
{
|
{
|
||||||
m_status->setText(message);
|
m_status->setText(message);
|
||||||
@@ -544,6 +815,17 @@ void ChatWindow::openSettings()
|
|||||||
.toString());
|
.toString());
|
||||||
form->addRow(tr("模型"), modelBox);
|
form->addRow(tr("模型"), modelBox);
|
||||||
|
|
||||||
|
auto *contextSpin = new QSpinBox(&dialog);
|
||||||
|
contextSpin->setRange(4, 200);
|
||||||
|
contextSpin->setValue(
|
||||||
|
Config::instance()->getInt(QStringLiteral("chat/contextLength"), 40));
|
||||||
|
form->addRow(tr("上下文条数"), contextSpin);
|
||||||
|
|
||||||
|
auto *autoCompressBox = new QCheckBox(tr("超出后自动压缩旧消息"), &dialog);
|
||||||
|
autoCompressBox->setChecked(
|
||||||
|
Config::instance()->getBool(QStringLiteral("chat/autoCompress"), true));
|
||||||
|
form->addRow(QString(), autoCompressBox);
|
||||||
|
|
||||||
auto *systemEdit = new QPlainTextEdit(&dialog);
|
auto *systemEdit = new QPlainTextEdit(&dialog);
|
||||||
systemEdit->setFixedHeight(80);
|
systemEdit->setFixedHeight(80);
|
||||||
systemEdit->setPlainText(Config::instance()
|
systemEdit->setPlainText(Config::instance()
|
||||||
@@ -574,6 +856,9 @@ void ChatWindow::openSettings()
|
|||||||
Config::instance()->set(QStringLiteral("chat/model"), modelBox->currentText());
|
Config::instance()->set(QStringLiteral("chat/model"), modelBox->currentText());
|
||||||
Config::instance()->set(QStringLiteral("chat/systemPrompt"),
|
Config::instance()->set(QStringLiteral("chat/systemPrompt"),
|
||||||
systemEdit->toPlainText());
|
systemEdit->toPlainText());
|
||||||
|
Config::instance()->set(QStringLiteral("chat/contextLength"), contextSpin->value());
|
||||||
|
Config::instance()->set(QStringLiteral("chat/autoCompress"),
|
||||||
|
autoCompressBox->isChecked());
|
||||||
Config::instance()->sync();
|
Config::instance()->sync();
|
||||||
renderConversation();
|
renderConversation();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ class QPlainTextEdit;
|
|||||||
class QPushButton;
|
class QPushButton;
|
||||||
class QTextBrowser;
|
class QTextBrowser;
|
||||||
class QMimeData;
|
class QMimeData;
|
||||||
|
class QUrl;
|
||||||
|
|
||||||
struct ChatConversation
|
struct ChatConversation
|
||||||
{
|
{
|
||||||
@@ -56,6 +57,15 @@ private:
|
|||||||
void renderConversation();
|
void renderConversation();
|
||||||
void updateSidebar();
|
void updateSidebar();
|
||||||
void sendMessage();
|
void sendMessage();
|
||||||
|
void requestCompletion();
|
||||||
|
void sendPayload();
|
||||||
|
void editMessage(int index);
|
||||||
|
void retryLast();
|
||||||
|
void forkFrom(int index);
|
||||||
|
void beginCompression(bool thenSend);
|
||||||
|
void handleAnchor(const QUrl &url);
|
||||||
|
void showAbout();
|
||||||
|
bool needsCompression() const;
|
||||||
void openSettings();
|
void openSettings();
|
||||||
void appendError(const QString &message);
|
void appendError(const QString &message);
|
||||||
int currentIndex() const { return m_current; }
|
int currentIndex() const { return m_current; }
|
||||||
@@ -70,5 +80,8 @@ private:
|
|||||||
QVector<ChatConversation> m_conversations;
|
QVector<ChatConversation> m_conversations;
|
||||||
int m_current = -1;
|
int m_current = -1;
|
||||||
int m_streamingIndex = -1;
|
int m_streamingIndex = -1;
|
||||||
|
bool m_compressing = false;
|
||||||
|
bool m_afterCompressSend = false;
|
||||||
|
bool m_skipCompress = false;
|
||||||
QString m_storePath;
|
QString m_storePath;
|
||||||
};
|
};
|
||||||
|
|||||||
+211
-3
@@ -15,11 +15,17 @@
|
|||||||
#include <QApplication>
|
#include <QApplication>
|
||||||
#include <QClipboard>
|
#include <QClipboard>
|
||||||
#include <QDesktopServices>
|
#include <QDesktopServices>
|
||||||
|
#include <QDir>
|
||||||
#include <QFileDialog>
|
#include <QFileDialog>
|
||||||
#include <QMessageBox>
|
#include <QMessageBox>
|
||||||
|
#include <QMenu>
|
||||||
#include <QProcess>
|
#include <QProcess>
|
||||||
#include <QStandardPaths>
|
#include <QStandardPaths>
|
||||||
#include <QTemporaryDir>#include "staging/stagingitem.h"
|
#include <QFile>
|
||||||
|
#include <QMouseEvent>
|
||||||
|
#include <QTemporaryDir>
|
||||||
|
|
||||||
|
#include "staging/stagingitem.h"
|
||||||
#include "staging/stagingmodel.h"
|
#include "staging/stagingmodel.h"
|
||||||
|
|
||||||
#include <QApplication>
|
#include <QApplication>
|
||||||
@@ -674,6 +680,12 @@ PortalPanel::PortalPanel(StagingModel *model, QWidget *parent)
|
|||||||
setupWindow();
|
setupWindow();
|
||||||
setupUi();
|
setupUi();
|
||||||
|
|
||||||
|
m_rightMargin = Config::instance()->getInt(QStringLiteral("panel/rightMargin"), 16);
|
||||||
|
m_bottomMargin = Config::instance()->getInt(QStringLiteral("panel/bottomMargin"), 16);
|
||||||
|
m_manualListHeight = Config::instance()->getInt(QStringLiteral("panel/listHeight"), 0);
|
||||||
|
m_fixedPosition = Config::instance()->getBool(QStringLiteral("panel/fixedPosition"), true);
|
||||||
|
m_fixedSize = Config::instance()->getBool(QStringLiteral("panel/fixedSize"), false);
|
||||||
|
|
||||||
connect(m_model, &StagingModel::countChanged, this, [this]() {
|
connect(m_model, &StagingModel::countChanged, this, [this]() {
|
||||||
if (m_countLabel) {
|
if (m_countLabel) {
|
||||||
m_countLabel->setText(tr("%1 项").arg(m_model->count()));
|
m_countLabel->setText(tr("%1 项").arg(m_model->count()));
|
||||||
@@ -724,7 +736,7 @@ void PortalPanel::setupWindow()
|
|||||||
anchors |= LayerShellQt::Window::AnchorRight;
|
anchors |= LayerShellQt::Window::AnchorRight;
|
||||||
anchors |= LayerShellQt::Window::AnchorBottom;
|
anchors |= LayerShellQt::Window::AnchorBottom;
|
||||||
layer->setAnchors(anchors);
|
layer->setAnchors(anchors);
|
||||||
layer->setMargins(QMargins(0, 0, 16, 16));
|
layer->setMargins(QMargins(0, 0, m_rightMargin, m_bottomMargin));
|
||||||
layer->setLayer(LayerShellQt::Window::LayerOverlay);
|
layer->setLayer(LayerShellQt::Window::LayerOverlay);
|
||||||
layer->setKeyboardInteractivity(
|
layer->setKeyboardInteractivity(
|
||||||
LayerShellQt::Window::KeyboardInteractivityNone);
|
LayerShellQt::Window::KeyboardInteractivityNone);
|
||||||
@@ -867,7 +879,8 @@ void PortalPanel::updateGeometryForContent()
|
|||||||
const int maxListHeight = qMax(120, screenHeight / 3);
|
const int maxListHeight = qMax(120, screenHeight / 3);
|
||||||
|
|
||||||
const int contentHeight = m_model->count() * kRowHeight + 8;
|
const int contentHeight = m_model->count() * kRowHeight + 8;
|
||||||
const int listHeight = qBound(140, contentHeight, maxListHeight);
|
const int wanted = m_manualListHeight > 0 ? m_manualListHeight : contentHeight;
|
||||||
|
const int listHeight = qBound(140, wanted, maxListHeight);
|
||||||
m_view->setFixedHeight(listHeight);
|
m_view->setFixedHeight(listHeight);
|
||||||
|
|
||||||
const int headerHeight = 26;
|
const int headerHeight = 26;
|
||||||
@@ -1086,10 +1099,205 @@ void PortalPanel::dismiss()
|
|||||||
hide();
|
hide();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void PortalPanel::applyMargins()
|
||||||
|
{
|
||||||
|
#ifdef GXDE_HAVE_LAYERSHELLQT
|
||||||
|
if (QGuiApplication::platformName().startsWith(QLatin1String("wayland"))) {
|
||||||
|
if (auto *layer = LayerShellQt::Window::get(windowHandle())) {
|
||||||
|
layer->setMargins(QMargins(0, 0, m_rightMargin, m_bottomMargin));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
if (QScreen *scr = screen() ? screen() : QGuiApplication::primaryScreen()) {
|
||||||
|
const QRect available = scr->availableGeometry();
|
||||||
|
move(available.right() - width() - m_rightMargin,
|
||||||
|
available.bottom() - height() - m_bottomMargin);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void PortalPanel::saveWindowState()
|
||||||
|
{
|
||||||
|
Config *config = Config::instance();
|
||||||
|
config->set(QStringLiteral("panel/rightMargin"), m_rightMargin);
|
||||||
|
config->set(QStringLiteral("panel/bottomMargin"), m_bottomMargin);
|
||||||
|
config->set(QStringLiteral("panel/listHeight"), m_manualListHeight);
|
||||||
|
config->set(QStringLiteral("panel/fixedPosition"), m_fixedPosition);
|
||||||
|
config->set(QStringLiteral("panel/fixedSize"), m_fixedSize);
|
||||||
|
config->sync();
|
||||||
|
}
|
||||||
|
|
||||||
|
void PortalPanel::mousePressEvent(QMouseEvent *event)
|
||||||
|
{
|
||||||
|
if (event->button() != Qt::LeftButton) {
|
||||||
|
Dtk::Widget::DBlurEffectWidget::mousePressEvent(event);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QPoint pos = event->position().toPoint();
|
||||||
|
if (!m_fixedSize && pos.y() <= 10) {
|
||||||
|
m_dragMode = 2;
|
||||||
|
m_dragStart = pos;
|
||||||
|
m_resizeStartHeight = m_view->height();
|
||||||
|
event->accept();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!m_fixedPosition) {
|
||||||
|
m_dragMode = 1;
|
||||||
|
m_dragStart = pos;
|
||||||
|
m_dragStartRight = m_rightMargin;
|
||||||
|
m_dragStartBottom = m_bottomMargin;
|
||||||
|
event->accept();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Dtk::Widget::DBlurEffectWidget::mousePressEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
void PortalPanel::mouseMoveEvent(QMouseEvent *event)
|
||||||
|
{
|
||||||
|
const QPoint pos = event->position().toPoint();
|
||||||
|
|
||||||
|
if (m_dragMode == 1) {
|
||||||
|
const QPoint delta = pos - m_dragStart;
|
||||||
|
m_rightMargin = qMax(0, m_dragStartRight - delta.x());
|
||||||
|
m_bottomMargin = qMax(0, m_dragStartBottom - delta.y());
|
||||||
|
applyMargins();
|
||||||
|
event->accept();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (m_dragMode == 2) {
|
||||||
|
const int delta = m_dragStart.y() - pos.y();
|
||||||
|
m_manualListHeight = qMax(120, m_resizeStartHeight + delta);
|
||||||
|
refreshLayout();
|
||||||
|
event->accept();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!m_fixedSize && pos.y() <= 10) {
|
||||||
|
setCursor(Qt::SizeVerCursor);
|
||||||
|
} else if (!m_fixedPosition && pos.x() <= 10) {
|
||||||
|
setCursor(Qt::SizeAllCursor);
|
||||||
|
} else {
|
||||||
|
unsetCursor();
|
||||||
|
}
|
||||||
|
Dtk::Widget::DBlurEffectWidget::mouseMoveEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
void PortalPanel::mouseReleaseEvent(QMouseEvent *event)
|
||||||
|
{
|
||||||
|
if (m_dragMode != 0) {
|
||||||
|
m_dragMode = 0;
|
||||||
|
saveWindowState();
|
||||||
|
event->accept();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Dtk::Widget::DBlurEffectWidget::mouseReleaseEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
void PortalPanel::contextMenuEvent(QContextMenuEvent *event)
|
||||||
|
{
|
||||||
|
showContextMenu(event->globalPos());
|
||||||
|
event->accept();
|
||||||
|
}
|
||||||
|
|
||||||
|
void PortalPanel::showContextMenu(const QPoint &globalPos)
|
||||||
|
{
|
||||||
|
QMenu menu(this);
|
||||||
|
|
||||||
|
QAction *fixedPosition = menu.addAction(tr("固定位置"));
|
||||||
|
fixedPosition->setCheckable(true);
|
||||||
|
fixedPosition->setChecked(m_fixedPosition);
|
||||||
|
|
||||||
|
QAction *resetPosition = menu.addAction(tr("恢复默认位置"));
|
||||||
|
|
||||||
|
QAction *fixedSize = menu.addAction(tr("固定大小"));
|
||||||
|
fixedSize->setCheckable(true);
|
||||||
|
fixedSize->setChecked(m_fixedSize);
|
||||||
|
|
||||||
|
QAction *autoHideFullscreen = menu.addAction(tr("全屏时隐藏"));
|
||||||
|
autoHideFullscreen->setCheckable(true);
|
||||||
|
autoHideFullscreen->setChecked(
|
||||||
|
Config::instance()->getBool(QStringLiteral("panel/hideOnFullscreen"), false));
|
||||||
|
|
||||||
|
menu.addSeparator();
|
||||||
|
|
||||||
|
const QString slimy = QStandardPaths::writableLocation(QStandardPaths::GenericDataLocation) +
|
||||||
|
QStringLiteral("/GXDE/gxde-k9/slimy/gxde-ai-see.slimy");
|
||||||
|
QAction *autostart = menu.addAction(tr("开机自启与后台保活"));
|
||||||
|
autostart->setCheckable(true);
|
||||||
|
autostart->setChecked(QFile::exists(slimy));
|
||||||
|
|
||||||
|
menu.addSeparator();
|
||||||
|
QAction *clear = menu.addAction(tr("清空暂存区"));
|
||||||
|
QAction *about = menu.addAction(tr("关于"));
|
||||||
|
QAction *quit = menu.addAction(tr("退出"));
|
||||||
|
|
||||||
|
QAction *chosen = menu.exec(globalPos);
|
||||||
|
if (!chosen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chosen == fixedPosition) {
|
||||||
|
m_fixedPosition = fixedPosition->isChecked();
|
||||||
|
saveWindowState();
|
||||||
|
} else if (chosen == resetPosition) {
|
||||||
|
m_rightMargin = 16;
|
||||||
|
m_bottomMargin = 16;
|
||||||
|
applyMargins();
|
||||||
|
saveWindowState();
|
||||||
|
} else if (chosen == fixedSize) {
|
||||||
|
m_fixedSize = fixedSize->isChecked();
|
||||||
|
saveWindowState();
|
||||||
|
} else if (chosen == autoHideFullscreen) {
|
||||||
|
Config::instance()->set(QStringLiteral("panel/hideOnFullscreen"),
|
||||||
|
autoHideFullscreen->isChecked());
|
||||||
|
Config::instance()->sync();
|
||||||
|
} else if (chosen == autostart) {
|
||||||
|
const bool enable = autostart->isChecked();
|
||||||
|
QDir().mkpath(QFileInfo(slimy).absolutePath());
|
||||||
|
if (enable) {
|
||||||
|
QFile file(slimy);
|
||||||
|
if (file.open(QIODevice::WriteOnly | QIODevice::Text)) {
|
||||||
|
QTextStream out(&file);
|
||||||
|
out << "#!/bin/bash\n"
|
||||||
|
"PID_DIR=\"${PID_DIR:-/tmp/GXDE/gxde-k9/}\"\n"
|
||||||
|
"mkdir -p \"$PID_DIR\"\n"
|
||||||
|
"PID_FILE=\"$PID_DIR/gxde-ai-see.pid\"\n"
|
||||||
|
"if [ -f \"$PID_FILE\" ]; then\n"
|
||||||
|
" PID=$(cat \"$PID_FILE\" 2>/dev/null)\n"
|
||||||
|
" [ -n \"$PID\" ] && [ -e \"/proc/$PID\" ] && exit 0\n"
|
||||||
|
" rm -f \"$PID_FILE\"\n"
|
||||||
|
"fi\n"
|
||||||
|
"/usr/bin/gxde-ai-see --daemon >/dev/null 2>&1 &\n"
|
||||||
|
"echo $! > \"$PID_FILE\"\n";
|
||||||
|
file.close();
|
||||||
|
file.setPermissions(QFileDevice::ReadOwner | QFileDevice::WriteOwner |
|
||||||
|
QFileDevice::ExeOwner);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
QFile::remove(slimy);
|
||||||
|
}
|
||||||
|
toast(enable ? tr("已开启开机自启与保活") : tr("已关闭开机自启与保活"));
|
||||||
|
} else if (chosen == clear) {
|
||||||
|
m_model->clearAll();
|
||||||
|
} else if (chosen == about) {
|
||||||
|
QMessageBox::about(
|
||||||
|
this, tr("关于小埃同学"),
|
||||||
|
tr("<h3>GXDE 小埃同学</h3><p>版本 %1</p>"
|
||||||
|
"<p>传送门、内容工厂、待办与小埃同学对话。</p>"
|
||||||
|
"<p>GPL-3.0-or-later · "
|
||||||
|
"<a href='https://gitee.com/GXDE-OS/gxde-ai-see'>gxde-ai-see</a></p>")
|
||||||
|
.arg(QCoreApplication::applicationVersion()));
|
||||||
|
} else if (chosen == quit) {
|
||||||
|
QCoreApplication::quit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void PortalPanel::showEvent(QShowEvent *event)
|
void PortalPanel::showEvent(QShowEvent *event)
|
||||||
{
|
{
|
||||||
Dtk::Widget::DBlurEffectWidget::showEvent(event);
|
Dtk::Widget::DBlurEffectWidget::showEvent(event);
|
||||||
refreshLayout();
|
refreshLayout();
|
||||||
|
applyMargins();
|
||||||
|
|
||||||
if (!QGuiApplication::platformName().startsWith(QLatin1String("wayland"))) {
|
if (!QGuiApplication::platformName().startsWith(QLatin1String("wayland"))) {
|
||||||
QScreen *scr = screen();
|
QScreen *scr = screen();
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ signals:
|
|||||||
|
|
||||||
protected:
|
protected:
|
||||||
void showEvent(QShowEvent *event) override;
|
void showEvent(QShowEvent *event) override;
|
||||||
|
void mousePressEvent(QMouseEvent *event) override;
|
||||||
|
void mouseMoveEvent(QMouseEvent *event) override;
|
||||||
|
void mouseReleaseEvent(QMouseEvent *event) override;
|
||||||
|
void contextMenuEvent(QContextMenuEvent *event) override;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
void setupUi();
|
void setupUi();
|
||||||
@@ -51,6 +55,9 @@ private:
|
|||||||
void runFactoryAction(const QString &id, const DropContent &content);
|
void runFactoryAction(const QString &id, const DropContent &content);
|
||||||
void showTextDialog(const QString &title, const QString &text);
|
void showTextDialog(const QString &title, const QString &text);
|
||||||
void toast(const QString &text);
|
void toast(const QString &text);
|
||||||
|
void applyMargins();
|
||||||
|
void saveWindowState();
|
||||||
|
void showContextMenu(const QPoint &globalPos);
|
||||||
|
|
||||||
StagingModel *m_model = nullptr;
|
StagingModel *m_model = nullptr;
|
||||||
StagingView *m_view = nullptr;
|
StagingView *m_view = nullptr;
|
||||||
@@ -64,4 +71,15 @@ private:
|
|||||||
QPushButton *m_selectAllButton = nullptr;
|
QPushButton *m_selectAllButton = nullptr;
|
||||||
QPushButton *m_invertButton = nullptr;
|
QPushButton *m_invertButton = nullptr;
|
||||||
QPushButton *m_deleteButton = nullptr;
|
QPushButton *m_deleteButton = nullptr;
|
||||||
|
|
||||||
|
int m_rightMargin = 16;
|
||||||
|
int m_bottomMargin = 16;
|
||||||
|
int m_manualListHeight = 0;
|
||||||
|
bool m_fixedPosition = true;
|
||||||
|
bool m_fixedSize = false;
|
||||||
|
int m_dragMode = 0; // 0 none, 1 move, 2 resize
|
||||||
|
QPoint m_dragStart;
|
||||||
|
int m_dragStartRight = 0;
|
||||||
|
int m_dragStartBottom = 0;
|
||||||
|
int m_resizeStartHeight = 0;
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user