diff --git a/.gitignore b/.gitignore index 2fe4120..bc1dbf9 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,10 @@ ui_*.h # test helpers tests/portal/pointer + +# debian build +obj-*/ +debian/.debhelper/ +debian/files +debian/debhelper-build-stamp +debian/gxde-ai-see/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..4bb246b --- /dev/null +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 06422f4..97a429c 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ 它把「拖拽」变成贯穿整个桌面的动作:拖到 **传送门**,选一个窗口,内容就落进那个窗口的输入框; 拖到 **内容工厂**,按类型分词、提链接、翻译、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 -cmake -B build -G Ninja +cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Release 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);其他环境自动降级。 +- 面板右键菜单:固定位置/大小、恢复默认位置、开机自启与保活、清空、关于。 --- diff --git a/debian/changelog b/debian/changelog new file mode 100644 index 0000000..cbed72a --- /dev/null +++ b/debian/changelog @@ -0,0 +1,5 @@ +gxde-ai-see (0.1.0) unstable; urgency=medium + + * Initial release: 传送门面板、暂存区、内容工厂、待办与小埃同学对话。 + + -- shenmo Thu, 30 Oct 2026 20:20:00 +0800 diff --git a/debian/control b/debian/control new file mode 100644 index 0000000..9ba249f --- /dev/null +++ b/debian/control @@ -0,0 +1,26 @@ +Source: gxde-ai-see +Section: utils +Priority: optional +Maintainer: shenmo +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 的流式对话。 diff --git a/debian/copyright b/debian/copyright new file mode 100644 index 0000000..345630f --- /dev/null +++ b/debian/copyright @@ -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". diff --git a/debian/gxde-ai-see.debhelper.log b/debian/gxde-ai-see.debhelper.log new file mode 100644 index 0000000..6991c52 --- /dev/null +++ b/debian/gxde-ai-see.debhelper.log @@ -0,0 +1 @@ +dh_auto_configure diff --git a/debian/rules b/debian/rules new file mode 100755 index 0000000..cacfc48 --- /dev/null +++ b/debian/rules @@ -0,0 +1,7 @@ +#!/usr/bin/make -f + +%: + dh $@ + +override_dh_auto_configure: + dh_auto_configure -- -DCMAKE_BUILD_TYPE=RelWithDebInfo diff --git a/debian/source/format b/debian/source/format new file mode 100644 index 0000000..89ae9db --- /dev/null +++ b/debian/source/format @@ -0,0 +1 @@ +3.0 (native) diff --git a/src/chat/chatclient.cpp b/src/chat/chatclient.cpp index bc1b4c7..8bc6241 100644 --- a/src/chat/chatclient.cpp +++ b/src/chat/chatclient.cpp @@ -12,24 +12,16 @@ #include "config.h" -ChatClient::ChatClient(QObject *parent) - : QObject(parent) -{ - m_manager.setRedirectPolicy(QNetworkRequest::NoLessSafeRedirectPolicy); -} +namespace { -void ChatClient::send(const QVector &messages) +bool prepareRequest(const QVector &messages, bool stream, + QNetworkRequest *request, QByteArray *body, QString *error) { - cancel(); - m_buffer.clear(); - m_fullText.clear(); - m_done = false; - const QString apiKey = Config::instance()->get(QStringLiteral("chat/apiKey")).toString().trimmed(); if (apiKey.isEmpty()) { - Q_EMIT failed(tr("还没有配置 API Key。请先点右上角设置,填入 DeepSeek API Key。")); - return; + *error = QObject::tr("还没有配置 API Key。请先点右上角设置,填入 DeepSeek API Key。"); + return false; } QString baseUrl = Config::instance() @@ -44,11 +36,6 @@ void ChatClient::send(const QVector &messages) baseUrl += QStringLiteral("/chat/completions"); } - const QString model = Config::instance() - ->get(QStringLiteral("chat/model"), - QStringLiteral("deepseek-chat")) - .toString(); - QJsonArray jsonMessages; for (const ChatMessage &message : messages) { QJsonObject object; @@ -57,33 +44,133 @@ void ChatClient::send(const QVector &messages) jsonMessages.append(object); } - QJsonObject body; - body.insert(QStringLiteral("model"), model); - body.insert(QStringLiteral("messages"), jsonMessages); - body.insert(QStringLiteral("stream"), true); - body.insert(QStringLiteral("temperature"), 0.7); + QJsonObject payload; + payload.insert(QStringLiteral("model"), + Config::instance() + ->get(QStringLiteral("chat/model"), QStringLiteral("deepseek-chat")) + .toString()); + payload.insert(QStringLiteral("messages"), jsonMessages); + payload.insert(QStringLiteral("stream"), stream); + payload.insert(QStringLiteral("temperature"), 0.7); - QNetworkRequest request{QUrl(baseUrl)}; - request.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/json")); - request.setRawHeader("Authorization", "Bearer " + apiKey.toUtf8()); - request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, - QNetworkRequest::NoLessSafeRedirectPolicy); + *request = QNetworkRequest{QUrl(baseUrl)}; + request->setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/json")); + request->setRawHeader("Authorization", "Bearer " + apiKey.toUtf8()); + request->setAttribute(QNetworkRequest::RedirectPolicyAttribute, + 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 &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::finished, this, &ChatClient::handleFinished); } -void ChatClient::cancel() +void ChatClient::sendOnce(const QVector &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; } - QNetworkReply *reply = m_reply; - m_reply = nullptr; - reply->disconnect(this); - reply->abort(); - reply->deleteLater(); + + m_onceReply = m_manager.post(request, body); + connect(m_onceReply, &QNetworkReply::finished, this, [this]() { + if (!m_onceReply) { + 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() @@ -148,18 +235,9 @@ void ChatClient::handleFinished() m_reply = nullptr; const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); - if (reply->error() != QNetworkReply::NoError && reply->error() != QNetworkReply::OperationCanceledError) { - const QByteArray body = reply->readAll(); - QString message = 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 (reply->error() != QNetworkReply::NoError && + reply->error() != QNetworkReply::OperationCanceledError) { + QString message = errorFromBody(reply->readAll(), reply->errorString()); if (status == 401) { message = tr("API Key 无效或已过期"); } @@ -168,7 +246,6 @@ void ChatClient::handleFinished() return; } - /* flush a final partial line */ if (!m_buffer.isEmpty()) { QByteArray line = m_buffer.trimmed(); if (line.startsWith("data:")) { diff --git a/src/chat/chatclient.h b/src/chat/chatclient.h index f7b374d..95824fe 100644 --- a/src/chat/chatclient.h +++ b/src/chat/chatclient.h @@ -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 { @@ -26,12 +29,15 @@ public: bool busy() const { return m_reply != nullptr; } void send(const QVector &messages); + void sendOnce(const QVector &messages); void cancel(); signals: void delta(const QString &text); void finished(const QString &text); void failed(const QString &error); + void onceFinished(const QString &text); + void onceFailed(const QString &error); private: void handleReadyRead(); @@ -40,6 +46,7 @@ private: QNetworkAccessManager m_manager; class QNetworkReply *m_reply = nullptr; + class QNetworkReply *m_onceReply = nullptr; QByteArray m_buffer; QString m_fullText; bool m_done = false; diff --git a/src/chat/chatwindow.cpp b/src/chat/chatwindow.cpp index c186640..c3c481d 100644 --- a/src/chat/chatwindow.cpp +++ b/src/chat/chatwindow.cpp @@ -5,7 +5,9 @@ #include "config.h" +#include #include +#include #include #include #include @@ -23,12 +25,16 @@ #include #include #include +#include +#include #include #include #include #include #include +#include #include +#include #include #include #include @@ -170,6 +176,42 @@ ChatWindow::ChatWindow(QWidget *parent) save(); 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 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) { if (m_current >= 0 && m_streamingIndex >= 0 && 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; }")); 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); settingsButton->setCursor(Qt::PointingHandCursor); settingsButton->setStyleSheet(QStringLiteral( @@ -244,7 +300,7 @@ void ChatWindow::setupUi() rightLayout->setSpacing(10); m_view = new ChatView(right); - m_view->setOpenExternalLinks(true); + m_view->setOpenExternalLinks(false); m_view->setFrameShape(QFrame::NoFrame); m_view->setStyleSheet(QStringLiteral( "QTextBrowser { background: transparent; color: rgba(240,240,240,0.94);" @@ -295,6 +351,7 @@ void ChatWindow::setupUi() &ChatWindow::handleDropOnConversation); connect(static_cast(m_view), &ChatView::dropped, this, &ChatWindow::handleDroppedMime); + connect(m_view, &QTextBrowser::anchorClicked, this, &ChatWindow::handleAnchor); auto *sendShortcut = new QShortcut(QKeySequence(Qt::CTRL | Qt::Key_Return), m_input); connect(sendShortcut, &QShortcut::activated, this, &ChatWindow::sendMessage); @@ -444,7 +501,8 @@ void ChatWindow::renderConversation() .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 cssClass = message.role == QStringLiteral("user") ? QStringLiteral("user") @@ -452,6 +510,17 @@ void ChatWindow::renderConversation() html += QStringLiteral("
%1
").arg(role); html += QStringLiteral("
%2
") .arg(cssClass, escapedHtml(message.content)); + + html += QStringLiteral("
"); + if (message.role == QStringLiteral("user")) { + html += QStringLiteral("编辑").arg(i); + } else if (message.role == QStringLiteral("assistant")) { + html += QStringLiteral("分支").arg(i); + if (i == conversation.messages.size() - 1) { + html += QStringLiteral(" · 重试"); + } + } + html += QStringLiteral("
"); } if (m_client->busy() && m_streamingIndex >= 0) { @@ -466,7 +535,7 @@ void ChatWindow::renderConversation() void ChatWindow::sendMessage() { - if (m_client->busy()) { + if (m_client->busy() || m_compressing) { return; } const QString text = m_input->toPlainText().trimmed(); @@ -482,15 +551,40 @@ void ChatWindow::sendMessage() conversation.title = text.left(24); } conversation.messages.append(ChatMessage{ QStringLiteral("user"), text }); - conversation.messages.append(ChatMessage{ QStringLiteral("assistant"), QString() }); conversation.updatedAt = QDateTime::currentMSecsSinceEpoch(); - m_streamingIndex = conversation.messages.size() - 1; + m_skipCompress = false; m_input->clear(); m_status->hide(); - m_sendButton->setEnabled(false); 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(); + sendPayload(); +} + +void ChatWindow::sendPayload() +{ + if (m_current < 0) { + return; + } + const ChatConversation &conversation = m_conversations[m_current]; QVector payload; const QString systemPrompt = @@ -498,12 +592,189 @@ void ChatWindow::sendMessage() if (!systemPrompt.isEmpty()) { 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)); } + 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 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 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("

GXDE 小埃同学

" + "

版本 %1

" + "

GXDE 桌面的 AI 助手:传送门、内容工厂、待办与对话。

" + "

许可证:GPL-3.0-or-later
" + "仓库:" + "gitee.com/GXDE-OS/gxde-ai-see

") + .arg(QCoreApplication::applicationVersion())); +} + void ChatWindow::appendError(const QString &message) { m_status->setText(message); @@ -544,6 +815,17 @@ void ChatWindow::openSettings() .toString()); 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); systemEdit->setFixedHeight(80); systemEdit->setPlainText(Config::instance() @@ -574,6 +856,9 @@ void ChatWindow::openSettings() Config::instance()->set(QStringLiteral("chat/model"), modelBox->currentText()); Config::instance()->set(QStringLiteral("chat/systemPrompt"), systemEdit->toPlainText()); + Config::instance()->set(QStringLiteral("chat/contextLength"), contextSpin->value()); + Config::instance()->set(QStringLiteral("chat/autoCompress"), + autoCompressBox->isChecked()); Config::instance()->sync(); renderConversation(); } diff --git a/src/chat/chatwindow.h b/src/chat/chatwindow.h index 671e64e..22085ab 100644 --- a/src/chat/chatwindow.h +++ b/src/chat/chatwindow.h @@ -14,6 +14,7 @@ class QPlainTextEdit; class QPushButton; class QTextBrowser; class QMimeData; +class QUrl; struct ChatConversation { @@ -56,6 +57,15 @@ private: void renderConversation(); void updateSidebar(); 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 appendError(const QString &message); int currentIndex() const { return m_current; } @@ -70,5 +80,8 @@ private: QVector m_conversations; int m_current = -1; int m_streamingIndex = -1; + bool m_compressing = false; + bool m_afterCompressSend = false; + bool m_skipCompress = false; QString m_storePath; }; diff --git a/src/ui/portalpanel.cpp b/src/ui/portalpanel.cpp index a7f5056..d11cfab 100644 --- a/src/ui/portalpanel.cpp +++ b/src/ui/portalpanel.cpp @@ -15,11 +15,17 @@ #include #include #include +#include #include #include +#include #include #include -#include #include "staging/stagingitem.h" +#include +#include +#include + +#include "staging/stagingitem.h" #include "staging/stagingmodel.h" #include @@ -674,6 +680,12 @@ PortalPanel::PortalPanel(StagingModel *model, QWidget *parent) setupWindow(); 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]() { if (m_countLabel) { m_countLabel->setText(tr("%1 项").arg(m_model->count())); @@ -724,7 +736,7 @@ void PortalPanel::setupWindow() anchors |= LayerShellQt::Window::AnchorRight; anchors |= LayerShellQt::Window::AnchorBottom; 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->setKeyboardInteractivity( LayerShellQt::Window::KeyboardInteractivityNone); @@ -867,7 +879,8 @@ void PortalPanel::updateGeometryForContent() const int maxListHeight = qMax(120, screenHeight / 3); 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); const int headerHeight = 26; @@ -1086,10 +1099,205 @@ void PortalPanel::dismiss() 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("

GXDE 小埃同学

版本 %1

" + "

传送门、内容工厂、待办与小埃同学对话。

" + "

GPL-3.0-or-later · " + "gxde-ai-see

") + .arg(QCoreApplication::applicationVersion())); + } else if (chosen == quit) { + QCoreApplication::quit(); + } +} + void PortalPanel::showEvent(QShowEvent *event) { Dtk::Widget::DBlurEffectWidget::showEvent(event); refreshLayout(); + applyMargins(); if (!QGuiApplication::platformName().startsWith(QLatin1String("wayland"))) { QScreen *scr = screen(); diff --git a/src/ui/portalpanel.h b/src/ui/portalpanel.h index a523936..9a38f09 100644 --- a/src/ui/portalpanel.h +++ b/src/ui/portalpanel.h @@ -40,6 +40,10 @@ signals: protected: 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: void setupUi(); @@ -51,6 +55,9 @@ private: void runFactoryAction(const QString &id, const DropContent &content); void showTextDialog(const QString &title, const QString &text); void toast(const QString &text); + void applyMargins(); + void saveWindowState(); + void showContextMenu(const QPoint &globalPos); StagingModel *m_model = nullptr; StagingView *m_view = nullptr; @@ -64,4 +71,15 @@ private: QPushButton *m_selectAllButton = nullptr; QPushButton *m_invertButton = 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; };