Files
gxde-ai-see/src/chat/chatwindow.cpp
T
shenmo 630ca6cadc fix(panel): 暂存优先模式;工厂自身接收拖拽;去掉 drop 提示
- 暂存区非空且未拖拽时只显示暂存列表,隐藏传送门与内容工厂并收窄窗口
- 内容工厂自身区域拖入也会展开动作
- 面板矩形改用屏幕可用区 + 边距计算(layer-shell 下 mapToGlobal 不可靠)
- 离开概览只复位传送门状态,不关闭面板;125ms
- 移除“在哪里 drop”提示
2026-10-30 22:00:39 +08:00

1301 lines
46 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// SPDX-FileCopyrightText: 2026 GXDE OS Team
// SPDX-License-Identifier: GPL-3.0-or-later
#include "chatwindow.h"
#include "config.h"
#include "chatview.h"
#include "markdown.h"
#include "mcpclient.h"
#include <QCheckBox>
#include <QClipboard>
#include <QApplication>
#include <QComboBox>
#include <QDesktopServices>
#include <QDateTime>
#include <QDialog>
#include <QDialogButtonBox>
#include <QDir>
#include <QDragEnterEvent>
#include <QDropEvent>
#include <QFile>
#include <QFontMetrics>
#include <QFormLayout>
#include <QGuiApplication>
#include <QHBoxLayout>
#include <QImage>
#include <QInputDialog>
#include <QPainter>
#include <QStyledItemDelegate>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QLabel>
#include <QLineEdit>
#include <QListWidget>
#include <QMenu>
#include <QMessageBox>
#include <QMimeData>
#include <QPlainTextEdit>
#include <QPushButton>
#include <DTitlebar>
#include <QRegularExpression>
#include <QSaveFile>
#include <QScreen>
#include <QSpinBox>
#include <QShortcut>
#include <QTextCursor>
#include <QTextBrowser>
#include <QIcon>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QUrl>
#include <QUuid>
#include <QVBoxLayout>
namespace {
bool mimeOk(const QMimeData *mime)
{
return mime && (mime->hasUrls() || mime->hasText() || mime->hasImage());
}
} // namespace
class ChatSidebar : public QListWidget
{
Q_OBJECT
public:
explicit ChatSidebar(QWidget *parent = nullptr)
: QListWidget(parent)
{
setAcceptDrops(true);
}
signals:
void droppedAt(int index, const QMimeData *mime);
void left();
protected:
void leaveEvent(QEvent *event) override
{
Q_EMIT left();
QListWidget::leaveEvent(event);
}
protected:
void dragEnterEvent(QDragEnterEvent *event) override
{
if (mimeOk(event->mimeData())) {
event->acceptProposedAction();
} else {
event->ignore();
}
}
void dragMoveEvent(QDragMoveEvent *event) override
{
if (mimeOk(event->mimeData())) {
event->acceptProposedAction();
}
}
void dropEvent(QDropEvent *event) override
{
if (!mimeOk(event->mimeData())) {
event->ignore();
return;
}
const QModelIndex index = indexAt(event->position().toPoint());
Q_EMIT droppedAt(index.isValid() ? index.row() : -1, event->mimeData());
event->acceptProposedAction();
}
};
class ChatView : public QTextBrowser
{
Q_OBJECT
public:
explicit ChatView(QWidget *parent = nullptr)
: QTextBrowser(parent)
{
setAcceptDrops(true);
}
signals:
void dropped(const QMimeData *mime);
protected:
void dragEnterEvent(QDragEnterEvent *event) override
{
if (mimeOk(event->mimeData())) {
event->acceptProposedAction();
} else {
event->ignore();
}
}
void dragMoveEvent(QDragMoveEvent *event) override
{
if (mimeOk(event->mimeData())) {
event->acceptProposedAction();
}
}
void dropEvent(QDropEvent *event) override
{
if (!mimeOk(event->mimeData())) {
event->ignore();
return;
}
Q_EMIT dropped(event->mimeData());
event->acceptProposedAction();
}
};
namespace {
constexpr int kPreviewRole = Qt::UserRole + 1;
constexpr int kTimeRole = Qt::UserRole + 2;
}
class ConversationDelegate : public QStyledItemDelegate
{
public:
explicit ConversationDelegate(QObject *parent = nullptr)
: QStyledItemDelegate(parent)
{
}
void setHoverRow(int row) { m_hoverRow = row; }
QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const override
{
Q_UNUSED(option)
Q_UNUSED(index)
return QSize(0, 62);
}
void paint(QPainter *painter, const QStyleOptionViewItem &option,
const QModelIndex &index) const override
{
painter->save();
painter->setRenderHint(QPainter::Antialiasing, true);
const QRect row = option.rect.adjusted(2, 2, -2, -2);
const bool selected = option.state & QStyle::State_Selected;
if (selected || index.row() == m_hoverRow) {
painter->setPen(Qt::NoPen);
painter->setBrush(selected ? QColor(120, 180, 255, 62) : QColor(255, 255, 255, 16));
painter->drawRoundedRect(row, 8, 8);
}
QFont titleFont = option.font;
titleFont.setPixelSize(13);
titleFont.setWeight(QFont::DemiBold);
painter->setFont(titleFont);
painter->setPen(selected ? QColor(255, 255, 255, 245) : QColor(255, 255, 255, 215));
const QFontMetrics titleMetrics(titleFont);
painter->drawText(QRect(row.left() + 10, row.top() + 9, row.width() - 70, 18),
Qt::AlignLeft | Qt::AlignVCenter,
titleMetrics.elidedText(index.data(Qt::DisplayRole).toString(),
Qt::ElideRight, row.width() - 70));
QFont smallFont = option.font;
smallFont.setPixelSize(10);
painter->setFont(smallFont);
painter->setPen(QColor(255, 255, 255, 95));
painter->drawText(QRect(row.right() - 56, row.top() + 10, 46, 14),
Qt::AlignRight | Qt::AlignVCenter,
index.data(kTimeRole).toString());
smallFont.setPixelSize(11);
painter->setFont(smallFont);
painter->setPen(QColor(255, 255, 255, 115));
const QFontMetrics previewMetrics(smallFont);
painter->drawText(QRect(row.left() + 10, row.top() + 30, row.width() - 20, 16),
Qt::AlignLeft | Qt::AlignVCenter,
previewMetrics.elidedText(index.data(kPreviewRole).toString(),
Qt::ElideRight, row.width() - 20));
painter->restore();
}
private:
int m_hoverRow = -1;
};
ChatWindow::ChatWindow(QWidget *parent)
: Dtk::Widget::DMainWindow(parent)
, m_client(new ChatClient(this))
{
setWindowTitle(tr("小埃同学"));
titlebar()->setTitle(tr("小埃同学"));
titlebar()->setIcon(QApplication::windowIcon());
titlebar()->setAutoHideOnFullscreen(true);
setMinimumSize(760, 520);
resize(1000, 700);
m_storePath = Config::instance()->dataDir() + QStringLiteral("/chat.json");
setupUi();
McpManager::instance()->reload();
load();
/* DeepSeek renamed the API models; map the old presets once. */
const QString storedModel =
Config::instance()->get(QStringLiteral("chat/model")).toString();
if (storedModel == QStringLiteral("deepseek-chat")) {
Config::instance()->set(QStringLiteral("chat/model"), QStringLiteral("deepseek-flash"));
Config::instance()->sync();
} else if (storedModel == QStringLiteral("deepseek-reasoner")) {
Config::instance()->set(QStringLiteral("chat/model"), QStringLiteral("deepseek-v4-pro"));
Config::instance()->sync();
}
m_current = m_conversations.isEmpty() ? -1 : qBound(0, m_current, m_conversations.size() - 1);
updateSidebar();
renderConversation();
if (QScreen *screen = QGuiApplication::primaryScreen()) {
move(screen->availableGeometry().center() - rect().center());
}
connect(m_client, &ChatClient::delta, this, [this](const QString &text) {
if (m_current < 0 || m_streamingIndex < 0 ||
m_streamingIndex >= m_conversations[m_current].messages.size()) {
return;
}
m_conversations[m_current].messages[m_streamingIndex].content += text;
m_messages->updateMessage(
m_streamingIndex,
m_conversations[m_current].messages[m_streamingIndex].content);
});
connect(m_client, &ChatClient::finished, this, [this](const QString &) {
m_streamingIndex = -1;
m_toolScaffold = QJsonArray();
m_toolRounds = 0;
if (m_current >= 0) {
m_conversations[m_current].updatedAt = QDateTime::currentMSecsSinceEpoch();
}
save();
renderConversation();
});
connect(m_client, &ChatClient::toolCallsReady, this, [this](const QVector<ToolCall> &calls) {
if (m_current < 0 || m_streamingIndex < 0) {
return;
}
if (m_toolRounds >= 5) {
appendError(tr("工具调用次数过多,已停止"));
m_streamingIndex = -1;
return;
}
++m_toolRounds;
QStringList names;
for (const ToolCall &call : calls) {
names.append(call.name);
}
ChatMessage &message =
m_conversations[m_current].messages[m_streamingIndex];
message.content += QStringLiteral("%1🔧 调用工具:%2")
.arg(message.content.isEmpty() ? QString() : QStringLiteral("\n\n"),
names.join(QStringLiteral(", ")));
m_messages->updateMessage(m_streamingIndex, message.content);
runToolCalls(calls, 0, QJsonArray());
});
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()) {
m_conversations[m_current].messages[m_streamingIndex].content =
tr("⚠️ %1").arg(error);
}
m_streamingIndex = -1;
save();
renderConversation();
});
}
void ChatWindow::setupUi()
{
auto *central = new QWidget(this);
setCentralWidget(central);
auto *root = new QHBoxLayout(central);
root->setContentsMargins(0, 0, 0, 0);
root->setSpacing(0);
/* ---------------- sidebar ---------------- */
auto *sidebarPane = new QWidget(this);
sidebarPane->setObjectName(QStringLiteral("sidebar"));
sidebarPane->setFixedWidth(264);
sidebarPane->setStyleSheet(QStringLiteral(
"#sidebar { background: #17171b; border-right: 1px solid rgba(255,255,255,0.06); }"));
auto *sidebarLayout = new QVBoxLayout(sidebarPane);
sidebarLayout->setContentsMargins(14, 16, 14, 12);
sidebarLayout->setSpacing(10);
auto *brandRow = new QHBoxLayout;
brandRow->setSpacing(10);
auto *logo = new QLabel(sidebarPane);
logo->setFixedSize(34, 34);
const qreal ratio = logo->devicePixelRatioF();
QPixmap logoPixmap = QApplication::windowIcon().pixmap(
QSize(qRound(34 * ratio), qRound(34 * ratio)));
logoPixmap.setDevicePixelRatio(ratio);
logo->setPixmap(logoPixmap);
logo->setStyleSheet(QStringLiteral("border-radius: 8px;"));
brandRow->addWidget(logo);
auto *brandColumn = new QVBoxLayout;
brandColumn->setSpacing(1);
auto *brand = new QLabel(tr("小埃同学"), sidebarPane);
QFont brandFont = brand->font();
brandFont.setPixelSize(15);
brandFont.setWeight(QFont::DemiBold);
brand->setFont(brandFont);
brand->setStyleSheet(QStringLiteral("color: rgba(255,255,255,0.92);"));
brandColumn->addWidget(brand);
auto *brandSub = new QLabel(QStringLiteral("GXDE AI See"), sidebarPane);
brandSub->setStyleSheet(QStringLiteral("color: rgba(255,255,255,0.35); font-size: 10px;"));
brandColumn->addWidget(brandSub);
brandRow->addLayout(brandColumn);
brandRow->addStretch();
sidebarLayout->addLayout(brandRow);
auto *newButton = new QPushButton(tr("+ 新建对话"), sidebarPane);
newButton->setCursor(Qt::PointingHandCursor);
newButton->setFixedHeight(34);
newButton->setStyleSheet(QStringLiteral(
"QPushButton { color: white; background: rgba(74,144,255,0.85); border: none;"
" border-radius: 9px; font-size: 12px; }"
"QPushButton:hover { background: rgba(90,160,255,0.95); }"));
sidebarLayout->addWidget(newButton);
m_sidebar = new ChatSidebar(sidebarPane);
m_sidebar->setFrameShape(QFrame::NoFrame);
m_sidebar->setMouseTracking(true);
m_sidebar->setStyleSheet(
QStringLiteral("QListWidget { background: transparent; border: none; }"));
auto *conversationDelegate = new ConversationDelegate(m_sidebar);
m_sidebar->setItemDelegate(conversationDelegate);
connect(m_sidebar, &QListWidget::entered, this,
[this, conversationDelegate](const QModelIndex &index) {
conversationDelegate->setHoverRow(index.row());
m_sidebar->viewport()->update();
});
connect(static_cast<ChatSidebar *>(m_sidebar), &ChatSidebar::left, this,
[this, conversationDelegate]() {
conversationDelegate->setHoverRow(-1);
m_sidebar->viewport()->update();
});
sidebarLayout->addWidget(m_sidebar, 1);
auto *bottomRow = new QHBoxLayout;
bottomRow->setSpacing(6);
auto *settingsButton = new QPushButton(tr("⚙ 设置"), sidebarPane);
settingsButton->setCursor(Qt::PointingHandCursor);
settingsButton->setStyleSheet(QStringLiteral(
"QPushButton { color: rgba(255,255,255,0.7); background: rgba(255,255,255,0.06);"
" border: none; border-radius: 8px; padding: 6px 8px; font-size: 12px; }"
"QPushButton:hover { background: rgba(255,255,255,0.12); color: white; }"));
bottomRow->addWidget(settingsButton, 1);
auto *menuButton = new QPushButton(QStringLiteral("⋯"), sidebarPane);
menuButton->setCursor(Qt::PointingHandCursor);
menuButton->setFixedWidth(38);
menuButton->setStyleSheet(settingsButton->styleSheet());
bottomRow->addWidget(menuButton);
sidebarLayout->addLayout(bottomRow);
root->addWidget(sidebarPane);
/* ---------------- chat pane ---------------- */
auto *pane = new QWidget(this);
pane->setStyleSheet(QStringLiteral("background: #1e1e22;"));
auto *paneLayout = new QVBoxLayout(pane);
paneLayout->setContentsMargins(0, 0, 0, 0);
paneLayout->setSpacing(0);
auto *header = new QWidget(pane);
header->setFixedHeight(56);
header->setStyleSheet(QStringLiteral(
"QWidget { background: rgba(255,255,255,0.02);"
" border-bottom: 1px solid rgba(255,255,255,0.06); }"));
auto *headerLayout = new QHBoxLayout(header);
headerLayout->setContentsMargins(20, 0, 16, 0);
headerLayout->setSpacing(10);
m_titleLabel = new QLabel(tr("小埃同学"), header);
QFont titleFont = m_titleLabel->font();
titleFont.setPixelSize(15);
titleFont.setWeight(QFont::DemiBold);
m_titleLabel->setFont(titleFont);
m_titleLabel->setStyleSheet(QStringLiteral("color: rgba(255,255,255,0.92);"));
headerLayout->addWidget(m_titleLabel);
m_modelLabel = new QLabel(QStringLiteral("deepseek-flash"), header);
m_modelLabel->setStyleSheet(QStringLiteral(
"QLabel { color: rgba(160,200,240,0.75); background: rgba(74,144,255,0.13);"
" border-radius: 8px; padding: 2px 8px; font-size: 10px; }"));
headerLayout->addWidget(m_modelLabel);
headerLayout->addStretch();
paneLayout->addWidget(header);
m_messages = new ChatMessageList(pane);
paneLayout->addWidget(m_messages, 1);
m_status = new QLabel(pane);
m_status->setContentsMargins(20, 0, 20, 0);
m_status->setStyleSheet(QStringLiteral("color: rgba(255,170,120,0.9); font-size: 12px;"));
m_status->hide();
paneLayout->addWidget(m_status);
auto *inputPane = new QWidget(pane);
auto *inputLayout = new QVBoxLayout(inputPane);
inputLayout->setContentsMargins(20, 6, 20, 14);
inputLayout->setSpacing(5);
auto *inputFrame = new QFrame(inputPane);
inputFrame->setStyleSheet(QStringLiteral(
"QFrame { background: rgba(255,255,255,0.055);"
" border: 1px solid rgba(255,255,255,0.08); border-radius: 12px; }"));
auto *frameLayout = new QHBoxLayout(inputFrame);
frameLayout->setContentsMargins(12, 8, 8, 8);
frameLayout->setSpacing(8);
m_input = new QPlainTextEdit(inputFrame);
m_input->setPlaceholderText(tr("输入消息,Ctrl+Enter 发送;也可以把文件/文字拖进窗口"));
m_input->setFixedHeight(64);
m_input->setStyleSheet(QStringLiteral(
"QPlainTextEdit { background: transparent; color: rgba(245,245,245,0.95);"
" border: none; font-size: 13px; }"));
frameLayout->addWidget(m_input, 1);
m_sendButton = new QPushButton(tr("发送"), inputFrame);
m_sendButton->setFixedSize(72, 34);
m_sendButton->setCursor(Qt::PointingHandCursor);
m_sendButton->setStyleSheet(QStringLiteral(
"QPushButton { color: white; background: rgba(74,144,255,0.9); border: none;"
" border-radius: 9px; font-size: 13px; }"
"QPushButton:hover { background: rgba(90,160,255,1.0); }"
"QPushButton:disabled { background: rgba(255,255,255,0.1);"
" color: rgba(255,255,255,0.35); }"));
frameLayout->addWidget(m_sendButton, 0, Qt::AlignBottom);
inputLayout->addWidget(inputFrame);
auto *hint = new QLabel(tr("Enter 换行 · Ctrl+Enter 发送 · 支持拖入文件/链接/图片"), inputPane);
hint->setStyleSheet(QStringLiteral("color: rgba(255,255,255,0.3); font-size: 10px;"));
hint->setAlignment(Qt::AlignRight);
inputLayout->addWidget(hint);
paneLayout->addWidget(inputPane);
root->addWidget(pane, 1);
/* ---------------- connections ---------------- */
connect(newButton, &QPushButton::clicked, this, &ChatWindow::newConversation);
connect(settingsButton, &QPushButton::clicked, this, &ChatWindow::openSettings);
connect(m_sendButton, &QPushButton::clicked, this, &ChatWindow::sendMessage);
connect(m_input, &QPlainTextEdit::textChanged, this, [this]() {
m_sendButton->setEnabled(!m_input->toPlainText().trimmed().isEmpty() &&
!m_client->busy());
});
m_sendButton->setEnabled(false);
connect(m_sidebar, &QListWidget::currentRowChanged, this, &ChatWindow::selectConversation);
connect(static_cast<ChatSidebar *>(m_sidebar), &ChatSidebar::droppedAt, this,
&ChatWindow::handleDropOnConversation);
connect(m_messages, &ChatMessageList::dropped, this, &ChatWindow::handleDroppedMime);
connect(m_messages, &ChatMessageList::linkActivated, this, &ChatWindow::handleAnchor);
connect(m_messages, &ChatMessageList::copyRequested, this, [this](int index) {
if (m_current >= 0 && index >= 0 &&
index < m_conversations.at(m_current).messages.size()) {
QApplication::clipboard()->setText(
m_conversations.at(m_current).messages.at(index).content);
}
});
connect(m_messages, &ChatMessageList::editRequested, this, &ChatWindow::editMessage);
connect(m_messages, &ChatMessageList::forkRequested, this, &ChatWindow::forkFrom);
connect(m_messages, &ChatMessageList::retryRequested, this, &ChatWindow::retryLast);
auto *menu = new QMenu(menuButton);
menu->addAction(tr("压缩上下文"), this, [this]() { beginCompression(false); });
menu->addAction(tr("重试上一条"), this, &ChatWindow::retryLast);
menu->addAction(tr("重新加载 MCP"), this, [this]() {
McpManager::instance()->reload();
const QStringList status = McpManager::instance()->statusLines();
m_status->setText(status.isEmpty() ? tr("没有配置 MCP 服务器")
: status.join(QStringLiteral(" · ")));
m_status->show();
});
menu->addSeparator();
menu->addAction(tr("关于"), this, &ChatWindow::showAbout);
menuButton->setMenu(menu);
auto *sendShortcut = new QShortcut(QKeySequence(Qt::CTRL | Qt::Key_Return), m_input);
connect(sendShortcut, &QShortcut::activated, this, &ChatWindow::sendMessage);
auto *sendShortcut2 = new QShortcut(QKeySequence(Qt::CTRL | Qt::Key_Enter), m_input);
connect(sendShortcut2, &QShortcut::activated, this, &ChatWindow::sendMessage);
}
void ChatWindow::load()
{
QFile file(m_storePath);
if (!file.open(QIODevice::ReadOnly)) {
return;
}
const QJsonDocument document = QJsonDocument::fromJson(file.readAll());
if (!document.isObject()) {
return;
}
const QJsonObject root = document.object();
m_conversations.clear();
for (const QJsonValue &value :
root.value(QStringLiteral("conversations")).toArray()) {
const QJsonObject object = value.toObject();
ChatConversation conversation;
conversation.id = object.value(QStringLiteral("id")).toString();
conversation.title = object.value(QStringLiteral("title")).toString();
conversation.updatedAt =
static_cast<qint64>(object.value(QStringLiteral("updatedAt")).toDouble());
for (const QJsonValue &messageValue :
object.value(QStringLiteral("messages")).toArray()) {
const QJsonObject messageObject = messageValue.toObject();
ChatMessage message;
message.role = messageObject.value(QStringLiteral("role")).toString();
message.content = messageObject.value(QStringLiteral("content")).toString();
conversation.messages.append(message);
}
m_conversations.append(conversation);
}
m_current = root.value(QStringLiteral("current")).toInt(-1);
}
void ChatWindow::save() const
{
QJsonArray conversations;
for (const ChatConversation &conversation : m_conversations) {
QJsonArray messages;
for (const ChatMessage &message : conversation.messages) {
QJsonObject object;
object.insert(QStringLiteral("role"), message.role);
object.insert(QStringLiteral("content"), message.content);
messages.append(object);
}
QJsonObject object;
object.insert(QStringLiteral("id"), conversation.id);
object.insert(QStringLiteral("title"), conversation.title);
object.insert(QStringLiteral("updatedAt"),
static_cast<double>(conversation.updatedAt));
object.insert(QStringLiteral("messages"), messages);
conversations.append(object);
}
QJsonObject root;
root.insert(QStringLiteral("version"), 1);
root.insert(QStringLiteral("current"), m_current);
root.insert(QStringLiteral("conversations"), conversations);
QSaveFile file(m_storePath);
if (!file.open(QIODevice::WriteOnly)) {
return;
}
file.write(QJsonDocument(root).toJson(QJsonDocument::Indented));
file.commit();
}
void ChatWindow::newConversation()
{
if (m_client->busy()) {
m_client->cancel();
m_streamingIndex = -1;
}
ChatConversation conversation;
conversation.id = QUuid::createUuid().toString(QUuid::WithoutBraces);
conversation.title = tr("新对话");
conversation.updatedAt = QDateTime::currentMSecsSinceEpoch();
m_conversations.prepend(conversation);
m_current = 0;
updateSidebar();
renderConversation();
save();
m_input->setFocus();
}
void ChatWindow::selectConversation(int index)
{
if (index < 0 || index >= m_conversations.size() || index == m_current) {
return;
}
if (m_client->busy()) {
m_client->cancel();
m_streamingIndex = -1;
}
m_current = index;
renderConversation();
}
void ChatWindow::updateSidebar()
{
const QSignalBlocker blocker(m_sidebar);
m_sidebar->clear();
for (const ChatConversation &conversation : m_conversations) {
auto *item = new QListWidgetItem(conversation.title, m_sidebar);
QString preview = tr("新对话");
for (int i = conversation.messages.size() - 1; i >= 0; --i) {
const ChatMessage &message = conversation.messages.at(i);
if (!message.content.isEmpty()) {
preview = message.content.simplified().left(60);
break;
}
}
item->setData(kPreviewRole, preview);
item->setData(kTimeRole,
QDateTime::fromMSecsSinceEpoch(conversation.updatedAt)
.toString(QStringLiteral("HH:mm")));
}
if (m_current >= 0 && m_current < m_sidebar->count()) {
m_sidebar->setCurrentRow(m_current);
}
updateHeader();
}
void ChatWindow::renderConversation()
{
if (m_current < 0 || m_conversations.isEmpty()) {
m_messages->setMessages({});
updateHeader();
return;
}
m_messages->setMessages(m_conversations.at(m_current).messages);
updateHeader();
}
void ChatWindow::updateHeader()
{
const bool valid = m_current >= 0 && m_current < m_conversations.size();
m_titleLabel->setText(valid ? m_conversations.at(m_current).title : tr("小埃同学"));
m_modelLabel->setText(Config::instance()
->get(QStringLiteral("chat/model"),
QStringLiteral("deepseek-flash"))
.toString());
}
void ChatWindow::sendMessage()
{
if (m_client->busy() || m_compressing) {
return;
}
const QString text = m_input->toPlainText().trimmed();
if (text.isEmpty()) {
return;
}
if (m_current < 0) {
newConversation();
}
ChatConversation &conversation = m_conversations[m_current];
if (conversation.title == tr("新对话")) {
conversation.title = text.left(24);
}
conversation.messages.append(ChatMessage{ QStringLiteral("user"), text });
conversation.updatedAt = QDateTime::currentMSecsSinceEpoch();
m_skipCompress = false;
m_toolScaffold = QJsonArray();
m_toolRounds = 0;
m_input->clear();
m_status->hide();
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 =
Config::instance()->get(QStringLiteral("chat/systemPrompt")).toString().trimmed();
if (!systemPrompt.isEmpty()) {
payload.append(ChatMessage{ QStringLiteral("system"), systemPrompt });
}
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));
}
if (m_toolRounds >= 5) {
m_client->setTools(QJsonArray());
} else {
m_client->setTools(McpManager::instance()->toolDefinitions());
}
m_client->send(payload, m_toolScaffold);
}
void ChatWindow::runToolCalls(const QVector<ToolCall> &calls, int index,
const QJsonArray &results)
{
if (index >= calls.size()) {
QJsonObject assistant;
assistant.insert(QStringLiteral("role"), QStringLiteral("assistant"));
assistant.insert(QStringLiteral("content"), QJsonValue::Null);
QJsonArray toolCalls;
for (const ToolCall &call : calls) {
QJsonObject function;
function.insert(QStringLiteral("name"), call.name);
function.insert(QStringLiteral("arguments"), call.arguments);
toolCalls.append(QJsonObject{ { QStringLiteral("id"), call.id },
{ QStringLiteral("type"), QStringLiteral("function") },
{ QStringLiteral("function"), function } });
}
assistant.insert(QStringLiteral("tool_calls"), toolCalls);
m_toolScaffold.append(assistant);
for (const QJsonValue &result : results) {
m_toolScaffold.append(result);
}
sendPayload();
return;
}
const ToolCall call = calls.at(index);
QJsonObject arguments;
const QJsonDocument document = QJsonDocument::fromJson(call.arguments.toUtf8());
if (document.isObject()) {
arguments = document.object();
}
McpManager::instance()->callTool(
call.name, arguments,
[this, calls, index, results](const QJsonObject &response) {
QString text;
const QJsonArray content = response.value(QStringLiteral("content")).toArray();
for (const QJsonValue &value : content) {
const QJsonObject object = value.toObject();
if (object.value(QStringLiteral("type")).toString() ==
QStringLiteral("text")) {
text += object.value(QStringLiteral("text")).toString();
text += QLatin1Char('\n');
}
}
if (text.isEmpty()) {
text = response.contains(QStringLiteral("error"))
? response.value(QStringLiteral("error")).toString()
: QString::fromUtf8(
QJsonDocument(response).toJson(QJsonDocument::Compact));
}
QJsonArray next = results;
next.append(QJsonObject{ { QStringLiteral("role"), QStringLiteral("tool") },
{ QStringLiteral("tool_call_id"), calls.at(index).id },
{ QStringLiteral("content"), text } });
runToolCalls(calls, index + 1, next);
});
}
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.startsWith(QStringLiteral("gxde-ai-see:copy/"))) {
const int index = target.section(QLatin1Char('/'), -1).toInt();
if (m_current >= 0 && index >= 0 &&
index < m_conversations.at(m_current).messages.size()) {
QApplication::clipboard()->setText(
m_conversations.at(m_current).messages.at(index).content);
}
} 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);
m_status->show();
}
void ChatWindow::openSettings()
{
QDialog dialog(this);
dialog.setWindowTitle(tr("小埃同学设置"));
dialog.setMinimumWidth(460);
auto *layout = new QVBoxLayout(&dialog);
auto *form = new QFormLayout;
auto *keyEdit = new QLineEdit(Config::instance()
->get(QStringLiteral("chat/apiKey"))
.toString(),
&dialog);
keyEdit->setEchoMode(QLineEdit::Password);
keyEdit->setPlaceholderText(QStringLiteral("sk-…"));
form->addRow(tr("DeepSeek API Key"), keyEdit);
auto *baseEdit = new QLineEdit(Config::instance()
->get(QStringLiteral("chat/baseUrl"),
QStringLiteral("https://api.deepseek.com"))
.toString(),
&dialog);
form->addRow(tr("API 地址"), baseEdit);
auto *modelBox = new QComboBox(&dialog);
modelBox->setEditable(true);
modelBox->addItems({ QStringLiteral("deepseek-flash"),
QStringLiteral("deepseek-reasoner") });
modelBox->setCurrentText(Config::instance()
->get(QStringLiteral("chat/model"),
QStringLiteral("deepseek-flash"))
.toString());
auto *modelRow = new QHBoxLayout;
modelRow->setSpacing(6);
modelRow->addWidget(modelBox, 1);
auto *fetchModels = new QPushButton(tr("获取模型列表"), &dialog);
modelRow->addWidget(fetchModels);
form->addRow(tr("模型"), modelRow);
connect(fetchModels, &QPushButton::clicked, &dialog, [=, &dialog]() {
QString base = baseEdit->text().trimmed();
while (base.endsWith(QLatin1Char('/'))) {
base.chop(1);
}
if (base.endsWith(QStringLiteral("/chat/completions"))) {
base.chop(int(qstrlen("/chat/completions")));
}
QNetworkRequest request{QUrl(base + QStringLiteral("/models"))};
request.setRawHeader("Authorization",
"Bearer " + keyEdit->text().trimmed().toUtf8());
auto *manager = new QNetworkAccessManager(&dialog);
QNetworkReply *reply = manager->get(request);
fetchModels->setEnabled(false);
connect(reply, &QNetworkReply::finished, &dialog, [=]() {
fetchModels->setEnabled(true);
const QByteArray body = reply->readAll();
const QJsonDocument document = QJsonDocument::fromJson(body);
const QJsonArray models =
document.object().value(QStringLiteral("data")).toArray();
if (reply->error() != QNetworkReply::NoError || models.isEmpty()) {
fetchModels->setText(tr("获取失败"));
fetchModels->setToolTip(QString::fromUtf8(body).left(200));
reply->deleteLater();
return;
}
const QString current = modelBox->currentText();
modelBox->clear();
for (const QJsonValue &value : models) {
const QJsonObject object = value.toObject();
const QString id = object.value(QStringLiteral("id")).toString();
if (id.isEmpty()) {
continue;
}
modelBox->addItem(id);
const QString name = object.value(QStringLiteral("name")).toString();
if (!name.isEmpty()) {
modelBox->setItemData(modelBox->count() - 1, name, Qt::ToolTipRole);
}
}
const int index = modelBox->findText(current);
if (index >= 0) {
modelBox->setCurrentIndex(index);
}
fetchModels->setText(tr("已更新"));
reply->deleteLater();
});
});
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 *mcpEdit = new QPlainTextEdit(&dialog);
mcpEdit->setFixedHeight(90);
mcpEdit->setPlaceholderText(
QStringLiteral("[{\"name\":\"fs\",\"command\":\"npx\","
"\"args\":[\"-y\",\"@modelcontextprotocol/server-filesystem\","
"\"/tmp\"]}]"));
mcpEdit->setPlainText(
Config::instance()->get(QStringLiteral("chat/mcpServers")).toString());
form->addRow(tr("MCP 服务器(JSON)"), mcpEdit);
auto *systemEdit = new QPlainTextEdit(&dialog);
systemEdit->setFixedHeight(80);
systemEdit->setPlainText(Config::instance()
->get(QStringLiteral("chat/systemPrompt"))
.toString());
form->addRow(tr("系统提示"), systemEdit);
layout->addLayout(form);
auto *hint = new QLabel(
tr("还没有账号?前往 <a href='https://platform.deepseek.com'>platform.deepseek.com</a> "
"开通并创建 API Key。Key 只保存在本机配置文件中。"),
&dialog);
hint->setWordWrap(true);
hint->setOpenExternalLinks(true);
hint->setStyleSheet(QStringLiteral("color: rgba(255,255,255,0.55); font-size: 12px;"));
layout->addWidget(hint);
auto *buttons = new QDialogButtonBox(QDialogButtonBox::Save | 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) {
Config::instance()->set(QStringLiteral("chat/apiKey"), keyEdit->text().trimmed());
Config::instance()->set(QStringLiteral("chat/baseUrl"), baseEdit->text().trimmed());
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());
const QJsonDocument mcpDocument =
QJsonDocument::fromJson(mcpEdit->toPlainText().toUtf8());
if (mcpDocument.isArray() || mcpEdit->toPlainText().trimmed().isEmpty()) {
Config::instance()->set(QStringLiteral("chat/mcpServers"),
mcpEdit->toPlainText().trimmed());
McpManager::instance()->reload();
} else {
m_status->setText(tr("MCP 配置不是合法的 JSON 数组,已忽略"));
m_status->show();
}
Config::instance()->sync();
renderConversation();
}
}
void ChatWindow::handleDroppedMime(const QMimeData *mime)
{
if (!mime) {
return;
}
QString text;
if (mime->hasUrls()) {
for (const QUrl &url : mime->urls()) {
if (!text.isEmpty()) {
text += QLatin1Char('\n');
}
text += url.isLocalFile() ? QStringLiteral("[文件] %1").arg(url.toLocalFile())
: url.toString();
}
} else if (mime->hasImage()) {
const QImage image = qvariant_cast<QImage>(mime->imageData());
if (!image.isNull()) {
const QString dir = Config::instance()->cacheDir() + QStringLiteral("/chat");
QDir().mkpath(dir);
const QString path = dir + QLatin1Char('/')
+ QUuid::createUuid().toString(QUuid::WithoutBraces) + QStringLiteral(".png");
image.save(path);
text = QStringLiteral("[图片] %1").arg(path);
}
} else if (mime->hasText()) {
text = mime->text();
}
if (text.isEmpty()) {
return;
}
m_input->moveCursor(QTextCursor::End);
if (!m_input->toPlainText().isEmpty()) {
m_input->insertPlainText(QStringLiteral("\n"));
}
m_input->insertPlainText(text);
m_input->setFocus();
}
void ChatWindow::handleDropOnConversation(int index, const QMimeData *mime)
{
if (index < 0) {
newConversation();
} else {
selectConversation(index);
}
handleDroppedMime(mime);
show();
raise();
activateWindow();
}
void ChatWindow::startConversationWith(const QString &text, const QStringList &files)
{
activateForAsk();
newConversation();
QString content = text;
if (!files.isEmpty()) {
if (!content.isEmpty()) {
content += QLatin1Char('\n');
}
content += files.join(QLatin1Char('\n'));
}
if (!content.isEmpty()) {
m_input->setPlainText(content);
m_input->moveCursor(QTextCursor::End);
const QString title = text.isEmpty() ? QFileInfo(files.value(0)).fileName()
: text.simplified().left(24);
if (m_current >= 0 && !title.isEmpty()) {
m_conversations[m_current].title = title;
save();
updateSidebar();
renderConversation();
}
}
m_input->setFocus();
}
void ChatWindow::activateForAsk()
{
show();
raise();
activateWindow();
m_input->setFocus();
}
void ChatWindow::showEvent(QShowEvent *event)
{
QWidget::showEvent(event);
m_input->setFocus();
}
#include "chatwindow.moc"