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:
2026-10-30 20:16:09 +08:00
parent e3c8da1a3f
commit 07f215bb34
15 changed files with 768 additions and 64 deletions
+126 -49
View File
@@ -12,24 +12,16 @@
#include "config.h"
ChatClient::ChatClient(QObject *parent)
: QObject(parent)
{
m_manager.setRedirectPolicy(QNetworkRequest::NoLessSafeRedirectPolicy);
}
namespace {
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 =
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<ChatMessage> &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<ChatMessage> &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<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::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;
}
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:")) {
+8 -1
View File
@@ -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<ChatMessage> &messages);
void sendOnce(const QVector<ChatMessage> &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;
+292 -7
View File
@@ -5,7 +5,9 @@
#include "config.h"
#include <QCheckBox>
#include <QComboBox>
#include <QDesktopServices>
#include <QDateTime>
#include <QDialog>
#include <QDialogButtonBox>
@@ -23,12 +25,16 @@
#include <QLabel>
#include <QLineEdit>
#include <QListWidget>
#include <QMenu>
#include <QMessageBox>
#include <QMimeData>
#include <QPlainTextEdit>
#include <QPushButton>
#include <QSaveFile>
#include <QScreen>
#include <QSpinBox>
#include <QShortcut>
#include <QTextCursor>
#include <QTextBrowser>
#include <QUrl>
#include <QUuid>
@@ -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<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) {
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<ChatView *>(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("<div class='role'>%1</div>").arg(role);
html += QStringLiteral("<div class='%1'>%2</div>")
.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) {
@@ -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<ChatMessage> 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<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)
{
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();
}
+13
View File
@@ -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<ChatConversation> 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;
};