feat(chat): 重做聊天窗口界面
- 消息改成气泡列表:头像、左右分栏、markdown 正文、hover 显示复制/编辑/分支/重试 - 会话列表:应用图标 + 名称、每项显示标题/最后一条预览/时间、hover 与选中态 - 输入区:圆角输入框内嵌发送按钮、快捷键提示 - 空会话欢迎页;消息区域支持拖放 - 头部显示当前会话标题与模型徽章 - 设置/更多按钮移到侧栏底部
This commit is contained in:
+2
-1
@@ -13,7 +13,8 @@
|
||||
- 文字:ICU 分词、提取链接、朗读
|
||||
- 文件/图片:打开、复制路径、保存图片、占位 OCR/描述
|
||||
- 待办窗口:layer-shell、不抢焦点、复选框/删除/新建、持久化
|
||||
- 小埃同学对话:DeepSeek/自定义 API 流式输出、多会话、设置、三块拖放区、
|
||||
- 小埃同学对话:气泡消息/头像/会话列表(标题+预览+时间)/输入区重做;
|
||||
DeepSeek/自定义 API 流式输出、多会话、设置、三块拖放区、
|
||||
编辑/重试/分支、上下文条数与自动压缩、Markdown、关于
|
||||
- MCP:stdio JSON-RPC,自动 initialize / tools-list,工具以 function calling 暴露给模型并在对话中执行
|
||||
- 合成器 portal(flake-wm / gxde-wlcom):org.gxde.Portal、缩略图坐标映射、
|
||||
|
||||
@@ -35,6 +35,8 @@ set(GXDE_AI_SEE_SOURCES
|
||||
src/chat/chatclient.cpp
|
||||
src/chat/chatwindow.cpp
|
||||
src/chat/mcpclient.cpp
|
||||
src/chat/markdown.cpp
|
||||
src/chat/chatview.cpp
|
||||
src/ui/portalpanel.cpp
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,442 @@
|
||||
// SPDX-FileCopyrightText: 2026 GXDE OS Team
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "chatview.h"
|
||||
|
||||
#include <QMimeData>
|
||||
|
||||
#include "markdown.h"
|
||||
|
||||
#include <QDragEnterEvent>
|
||||
#include <QDropEvent>
|
||||
#include <QHBoxLayout>
|
||||
#include <QScrollBar>
|
||||
#include <QLabel>
|
||||
#include <QTextBrowser>
|
||||
#include <QTextDocument>
|
||||
#include <QAbstractTextDocumentLayout>
|
||||
#include <QMimeData>
|
||||
#include <QTimer>
|
||||
#include <QToolButton>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr int kAvatarSize = 30;
|
||||
constexpr int kBubbleMaxWidth = 580;
|
||||
|
||||
QString avatarStyle(bool user)
|
||||
{
|
||||
return user ? QStringLiteral("QLabel { background: rgba(74,144,255,0.85); color: white;"
|
||||
" border-radius: %1px; font-size: 12px; }")
|
||||
.arg(kAvatarSize / 2)
|
||||
: QStringLiteral("QLabel { background: rgba(255,255,255,0.14); color: #eaeaea;"
|
||||
" border-radius: %1px; font-size: 12px; }")
|
||||
.arg(kAvatarSize / 2);
|
||||
}
|
||||
|
||||
QToolButton *actionButton(const QString &text, QWidget *parent)
|
||||
{
|
||||
auto *button = new QToolButton(parent);
|
||||
button->setText(text);
|
||||
button->setCursor(Qt::PointingHandCursor);
|
||||
button->setFocusPolicy(Qt::NoFocus);
|
||||
button->setAutoRaise(true);
|
||||
button->setStyleSheet(QStringLiteral(
|
||||
"QToolButton { color: rgba(160,200,240,0.75); background: transparent; border: none;"
|
||||
" font-size: 11px; padding: 2px 4px; }"
|
||||
"QToolButton:hover { color: #cfe5ff; }"));
|
||||
return button;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
/** QTextBrowser that grows to fit its document. */
|
||||
class BubbleText : public QTextBrowser
|
||||
{
|
||||
public:
|
||||
explicit BubbleText(QWidget *parent = nullptr)
|
||||
: QTextBrowser(parent)
|
||||
{
|
||||
setFrameShape(QFrame::NoFrame);
|
||||
setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
|
||||
setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
|
||||
setOpenExternalLinks(false);
|
||||
setMinimumWidth(160);
|
||||
setMaximumWidth(kBubbleMaxWidth);
|
||||
setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Minimum);
|
||||
setStyleSheet(QStringLiteral("QTextBrowser { background: transparent; border: none; }"));
|
||||
document()->setDocumentMargin(0);
|
||||
document()->setDefaultStyleSheet(QStringLiteral(
|
||||
"body { color: #ececec; }"
|
||||
"pre { background: #2b2b31; }"
|
||||
"code { background: #2b2b31; }"
|
||||
"a { color: #7aa7d9; }"
|
||||
"h1, h2, h3 { margin: 4px 0; }"
|
||||
".li { margin-left: 10px; }"
|
||||
".gap { height: 6px; }"));
|
||||
connect(document()->documentLayout(), &QAbstractTextDocumentLayout::documentSizeChanged,
|
||||
this, [this]() { fitHeight(); });
|
||||
}
|
||||
|
||||
void setContent(const QString &html)
|
||||
{
|
||||
setHtml(html);
|
||||
fitHeight();
|
||||
}
|
||||
|
||||
protected:
|
||||
void resizeEvent(QResizeEvent *event) override
|
||||
{
|
||||
QTextBrowser::resizeEvent(event);
|
||||
fitHeight();
|
||||
}
|
||||
|
||||
private:
|
||||
void fitHeight()
|
||||
{
|
||||
const int wanted = int(document()->size().height()) + 4;
|
||||
if (wanted > 0 && height() != wanted) {
|
||||
setFixedHeight(wanted);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
class MessageRow : public QWidget
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
MessageRow(const ChatMessage &message, int index, bool isLast, QWidget *parent = nullptr)
|
||||
: QWidget(parent)
|
||||
, m_message(message)
|
||||
, m_index(index)
|
||||
, m_isUser(message.role == QStringLiteral("user"))
|
||||
, m_isLast(isLast)
|
||||
{
|
||||
auto *row = new QHBoxLayout(this);
|
||||
row->setContentsMargins(4, 2, 4, 2);
|
||||
row->setSpacing(8);
|
||||
|
||||
auto *avatar = new QLabel(m_isUser ? tr("你") : tr("埃"), this);
|
||||
avatar->setFixedSize(kAvatarSize, kAvatarSize);
|
||||
avatar->setAlignment(Qt::AlignCenter);
|
||||
avatar->setStyleSheet(avatarStyle(m_isUser));
|
||||
|
||||
auto *column = new QVBoxLayout;
|
||||
column->setContentsMargins(0, 0, 0, 0);
|
||||
column->setSpacing(3);
|
||||
|
||||
auto *meta = new QLabel(m_isUser ? tr("你") : tr("小埃"), this);
|
||||
meta->setStyleSheet(QStringLiteral("color: rgba(255,255,255,0.4); font-size: 10px;"));
|
||||
meta->setAlignment(m_isUser ? Qt::AlignRight : Qt::AlignLeft);
|
||||
column->addWidget(meta);
|
||||
|
||||
auto *bubble = new QFrame(this);
|
||||
bubble->setMaximumWidth(kBubbleMaxWidth + 26);
|
||||
bubble->setStyleSheet(
|
||||
m_isUser ? QStringLiteral("QFrame { background: rgba(74,144,255,0.20);"
|
||||
" border-radius: 12px; }")
|
||||
: QStringLiteral("QFrame { background: rgba(255,255,255,0.065);"
|
||||
" border-radius: 12px; }"));
|
||||
auto *bubbleLayout = new QVBoxLayout(bubble);
|
||||
bubbleLayout->setContentsMargins(13, 9, 13, 9);
|
||||
bubbleLayout->setSpacing(0);
|
||||
|
||||
if (m_isUser) {
|
||||
auto *label = new QLabel(bubble);
|
||||
label->setTextFormat(Qt::RichText);
|
||||
label->setWordWrap(true);
|
||||
label->setTextInteractionFlags(Qt::TextSelectableByMouse);
|
||||
label->setMaximumWidth(kBubbleMaxWidth);
|
||||
label->setStyleSheet(QStringLiteral("QLabel { color: #f2f2f2; font-size: 13px; }"));
|
||||
label->setText(escapedHtml(message.content));
|
||||
m_label = label;
|
||||
bubbleLayout->addWidget(label);
|
||||
} else {
|
||||
m_body = new BubbleText(bubble);
|
||||
m_body->setContent(markdownToHtml(message.content));
|
||||
connect(m_body, &QTextBrowser::anchorClicked, this,
|
||||
[this](const QUrl &url) { Q_EMIT linkActivated(url); });
|
||||
bubbleLayout->addWidget(m_body);
|
||||
}
|
||||
column->addWidget(bubble);
|
||||
|
||||
m_actions = new QWidget(this);
|
||||
auto *actions = new QHBoxLayout(m_actions);
|
||||
actions->setContentsMargins(2, 0, 2, 0);
|
||||
actions->setSpacing(6);
|
||||
|
||||
auto *copy = actionButton(tr("复制"), m_actions);
|
||||
connect(copy, &QToolButton::clicked, this,
|
||||
[this]() { Q_EMIT copyRequested(m_index); });
|
||||
actions->addWidget(copy);
|
||||
|
||||
if (m_isUser) {
|
||||
auto *edit = actionButton(tr("编辑"), m_actions);
|
||||
connect(edit, &QToolButton::clicked, this,
|
||||
[this]() { Q_EMIT editRequested(m_index); });
|
||||
actions->addWidget(edit);
|
||||
} else {
|
||||
auto *fork = actionButton(tr("分支"), m_actions);
|
||||
connect(fork, &QToolButton::clicked, this,
|
||||
[this]() { Q_EMIT forkRequested(m_index); });
|
||||
actions->addWidget(fork);
|
||||
if (m_isLast) {
|
||||
auto *retry = actionButton(tr("重试"), m_actions);
|
||||
connect(retry, &QToolButton::clicked, this,
|
||||
[this]() { Q_EMIT retryRequested(); });
|
||||
actions->addWidget(retry);
|
||||
}
|
||||
}
|
||||
actions->addStretch();
|
||||
m_actions->hide();
|
||||
column->addWidget(m_actions);
|
||||
|
||||
if (m_isUser) {
|
||||
row->addStretch(1);
|
||||
row->addLayout(column);
|
||||
row->addWidget(avatar, 0, Qt::AlignTop);
|
||||
} else {
|
||||
row->addWidget(avatar, 0, Qt::AlignTop);
|
||||
row->addLayout(column);
|
||||
row->addStretch(1);
|
||||
}
|
||||
}
|
||||
|
||||
int rowIndex() const { return m_index; }
|
||||
|
||||
void setContent(const QString &text)
|
||||
{
|
||||
m_message.content = text;
|
||||
if (m_body) {
|
||||
m_body->setContent(markdownToHtml(text));
|
||||
} else if (m_label) {
|
||||
m_label->setText(escapedHtml(text));
|
||||
}
|
||||
}
|
||||
|
||||
signals:
|
||||
void copyRequested(int index);
|
||||
void editRequested(int index);
|
||||
void forkRequested(int index);
|
||||
void retryRequested();
|
||||
void linkActivated(const QUrl &url);
|
||||
|
||||
protected:
|
||||
void enterEvent(QEnterEvent *event) override
|
||||
{
|
||||
m_actions->show();
|
||||
QWidget::enterEvent(event);
|
||||
}
|
||||
|
||||
void leaveEvent(QEvent *event) override
|
||||
{
|
||||
m_actions->hide();
|
||||
QWidget::leaveEvent(event);
|
||||
}
|
||||
|
||||
private:
|
||||
ChatMessage m_message;
|
||||
int m_index = -1;
|
||||
bool m_isUser = false;
|
||||
bool m_isLast = false;
|
||||
QWidget *m_actions = nullptr;
|
||||
BubbleText *m_body = nullptr;
|
||||
QLabel *m_label = nullptr;
|
||||
};
|
||||
|
||||
ChatMessageList::ChatMessageList(QWidget *parent)
|
||||
: QScrollArea(parent)
|
||||
{
|
||||
setAcceptDrops(true);
|
||||
setWidgetResizable(true);
|
||||
setFrameShape(QFrame::NoFrame);
|
||||
setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
|
||||
setStyleSheet(QStringLiteral("QScrollArea { background: transparent; border: none; }"));
|
||||
viewport()->setStyleSheet(QStringLiteral("background: transparent;"));
|
||||
|
||||
m_container = new QWidget;
|
||||
m_container->setStyleSheet(QStringLiteral("background: transparent;"));
|
||||
m_layout = new QVBoxLayout(m_container);
|
||||
m_layout->setContentsMargins(8, 10, 8, 10);
|
||||
m_layout->setSpacing(6);
|
||||
m_layout->addStretch(1);
|
||||
setWidget(m_container);
|
||||
|
||||
m_welcome = new QWidget(m_container);
|
||||
auto *welcomeLayout = new QVBoxLayout(m_welcome);
|
||||
welcomeLayout->setContentsMargins(24, 40, 24, 24);
|
||||
welcomeLayout->setSpacing(10);
|
||||
welcomeLayout->setAlignment(Qt::AlignHCenter | Qt::AlignTop);
|
||||
|
||||
auto *bigAvatar = new QLabel(tr("埃"), m_welcome);
|
||||
bigAvatar->setFixedSize(64, 64);
|
||||
bigAvatar->setAlignment(Qt::AlignCenter);
|
||||
bigAvatar->setStyleSheet(QStringLiteral(
|
||||
"QLabel { background: rgba(74,144,255,0.22); color: #cfe5ff; border-radius: 32px;"
|
||||
" font-size: 26px; }"));
|
||||
welcomeLayout->addWidget(bigAvatar, 0, Qt::AlignHCenter);
|
||||
|
||||
auto *title = new QLabel(tr("你好,我是小埃同学"), m_welcome);
|
||||
QFont titleFont = title->font();
|
||||
titleFont.setPixelSize(20);
|
||||
titleFont.setWeight(QFont::DemiBold);
|
||||
title->setFont(titleFont);
|
||||
title->setStyleSheet(QStringLiteral("color: rgba(255,255,255,0.9);"));
|
||||
welcomeLayout->addWidget(title, 0, Qt::AlignHCenter);
|
||||
|
||||
auto *hint = new QLabel(
|
||||
tr("把文件、链接或文字拖进窗口,或者直接输入问题。\n"
|
||||
"第一次使用请在左下角「设置」里填入 DeepSeek API Key。"),
|
||||
m_welcome);
|
||||
hint->setAlignment(Qt::AlignCenter);
|
||||
hint->setWordWrap(true);
|
||||
hint->setStyleSheet(QStringLiteral("color: rgba(255,255,255,0.5); font-size: 12px;"));
|
||||
welcomeLayout->addWidget(hint, 0, Qt::AlignHCenter);
|
||||
|
||||
m_layout->insertWidget(0, m_welcome);
|
||||
}
|
||||
|
||||
void ChatMessageList::setMessages(const QVector<ChatMessage> &messages)
|
||||
{
|
||||
m_messages = messages;
|
||||
rebuild();
|
||||
}
|
||||
|
||||
void ChatMessageList::rebuild()
|
||||
{
|
||||
for (MessageRow *row : m_rows) {
|
||||
m_layout->removeWidget(row);
|
||||
row->deleteLater();
|
||||
}
|
||||
m_rows.clear();
|
||||
|
||||
bool hasConversation = false;
|
||||
for (const ChatMessage &message : m_messages) {
|
||||
if (message.role == QStringLiteral("system")) {
|
||||
continue;
|
||||
}
|
||||
hasConversation = true;
|
||||
}
|
||||
m_welcome->setVisible(!hasConversation);
|
||||
|
||||
const int lastAssistant = [this]() {
|
||||
int last = -1;
|
||||
for (int i = 0; i < m_messages.size(); ++i) {
|
||||
if (m_messages.at(i).role == QStringLiteral("assistant")) {
|
||||
last = i;
|
||||
}
|
||||
}
|
||||
return last;
|
||||
}();
|
||||
|
||||
for (int i = 0; i < m_messages.size(); ++i) {
|
||||
const ChatMessage &message = m_messages.at(i);
|
||||
if (message.role == QStringLiteral("system")) {
|
||||
if (!message.content.isEmpty()) {
|
||||
auto *note = new QLabel(message.content, m_container);
|
||||
note->setWordWrap(true);
|
||||
note->setAlignment(Qt::AlignCenter);
|
||||
note->setStyleSheet(QStringLiteral(
|
||||
"QLabel { color: rgba(255,255,255,0.42); font-size: 11px;"
|
||||
" background: rgba(255,255,255,0.04); border-radius: 8px;"
|
||||
" padding: 6px 10px; }"));
|
||||
m_layout->insertWidget(m_layout->count() - 1, note);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
auto *row = new MessageRow(message, i, i == lastAssistant, m_container);
|
||||
connect(row, &MessageRow::copyRequested, this, &ChatMessageList::copyRequested);
|
||||
connect(row, &MessageRow::editRequested, this, &ChatMessageList::editRequested);
|
||||
connect(row, &MessageRow::forkRequested, this, &ChatMessageList::forkRequested);
|
||||
connect(row, &MessageRow::retryRequested, this, &ChatMessageList::retryRequested);
|
||||
connect(row, &MessageRow::linkActivated, this, &ChatMessageList::linkActivated);
|
||||
m_layout->insertWidget(m_layout->count() - 1, row);
|
||||
m_rows.append(row);
|
||||
}
|
||||
|
||||
QTimer::singleShot(0, this, &ChatMessageList::scrollToBottom);
|
||||
}
|
||||
|
||||
void ChatMessageList::appendMessage(const ChatMessage &message)
|
||||
{
|
||||
m_messages.append(message);
|
||||
rebuild();
|
||||
}
|
||||
|
||||
void ChatMessageList::updateMessage(int index, const QString &content)
|
||||
{
|
||||
if (index < 0 || index >= m_messages.size()) {
|
||||
return;
|
||||
}
|
||||
m_messages[index].content = content;
|
||||
|
||||
for (MessageRow *row : m_rows) {
|
||||
if (row->rowIndex() == index) {
|
||||
row->setContent(content);
|
||||
break;
|
||||
}
|
||||
}
|
||||
QTimer::singleShot(0, this, &ChatMessageList::scrollToBottom);
|
||||
}
|
||||
|
||||
void ChatMessageList::appendNote(const QString &text)
|
||||
{
|
||||
auto *note = new QLabel(text, m_container);
|
||||
note->setWordWrap(true);
|
||||
note->setAlignment(Qt::AlignCenter);
|
||||
note->setStyleSheet(QStringLiteral(
|
||||
"QLabel { color: rgba(255,255,255,0.45); font-size: 11px;"
|
||||
" background: rgba(255,255,255,0.04); border-radius: 8px; padding: 5px 10px; }"));
|
||||
m_layout->insertWidget(m_layout->count() - 1, note);
|
||||
QTimer::singleShot(0, this, &ChatMessageList::scrollToBottom);
|
||||
}
|
||||
|
||||
void ChatMessageList::dragEnterEvent(QDragEnterEvent *event)
|
||||
{
|
||||
if (event->mimeData() &&
|
||||
(event->mimeData()->hasUrls() || event->mimeData()->hasText() ||
|
||||
event->mimeData()->hasImage())) {
|
||||
event->acceptProposedAction();
|
||||
return;
|
||||
}
|
||||
QScrollArea::dragEnterEvent(event);
|
||||
}
|
||||
|
||||
void ChatMessageList::dragMoveEvent(QDragMoveEvent *event)
|
||||
{
|
||||
if (event->mimeData() &&
|
||||
(event->mimeData()->hasUrls() || event->mimeData()->hasText() ||
|
||||
event->mimeData()->hasImage())) {
|
||||
event->acceptProposedAction();
|
||||
return;
|
||||
}
|
||||
QScrollArea::dragMoveEvent(event);
|
||||
}
|
||||
|
||||
void ChatMessageList::dropEvent(QDropEvent *event)
|
||||
{
|
||||
if (event->mimeData() &&
|
||||
(event->mimeData()->hasUrls() || event->mimeData()->hasText() ||
|
||||
event->mimeData()->hasImage())) {
|
||||
Q_EMIT dropped(event->mimeData());
|
||||
event->acceptProposedAction();
|
||||
return;
|
||||
}
|
||||
QScrollArea::dropEvent(event);
|
||||
}
|
||||
|
||||
void ChatMessageList::clear()
|
||||
{
|
||||
m_messages.clear();
|
||||
rebuild();
|
||||
}
|
||||
|
||||
void ChatMessageList::scrollToBottom()
|
||||
{
|
||||
verticalScrollBar()->setValue(verticalScrollBar()->maximum());
|
||||
}
|
||||
|
||||
#include "chatview.moc"
|
||||
@@ -0,0 +1,55 @@
|
||||
// SPDX-FileCopyrightText: 2026 GXDE OS Team
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QScrollArea>
|
||||
#include <QVector>
|
||||
|
||||
#include "chatclient.h"
|
||||
|
||||
class QLabel;
|
||||
class QMimeData;
|
||||
class QUrl;
|
||||
class QVBoxLayout;
|
||||
|
||||
/**
|
||||
* Scrollable message list with chat bubbles, avatars and inline actions.
|
||||
*/
|
||||
class ChatMessageList : public QScrollArea
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit ChatMessageList(QWidget *parent = nullptr);
|
||||
|
||||
void setMessages(const QVector<ChatMessage> &messages);
|
||||
void appendMessage(const ChatMessage &message);
|
||||
void updateMessage(int index, const QString &content);
|
||||
void appendNote(const QString &text);
|
||||
void clear();
|
||||
|
||||
void scrollToBottom();
|
||||
|
||||
signals:
|
||||
void dropped(const QMimeData *mime);
|
||||
void copyRequested(int index);
|
||||
void editRequested(int index);
|
||||
void forkRequested(int index);
|
||||
void retryRequested();
|
||||
void linkActivated(const QUrl &url);
|
||||
|
||||
protected:
|
||||
void dragEnterEvent(QDragEnterEvent *event) override;
|
||||
void dragMoveEvent(QDragMoveEvent *event) override;
|
||||
void dropEvent(QDropEvent *event) override;
|
||||
|
||||
private:
|
||||
void rebuild();
|
||||
|
||||
QWidget *m_container = nullptr;
|
||||
QVBoxLayout *m_layout = nullptr;
|
||||
QWidget *m_welcome = nullptr;
|
||||
QVector<class MessageRow *> m_rows;
|
||||
QVector<ChatMessage> m_messages;
|
||||
};
|
||||
+287
-262
@@ -4,6 +4,8 @@
|
||||
#include "chatwindow.h"
|
||||
|
||||
#include "config.h"
|
||||
#include "chatview.h"
|
||||
#include "markdown.h"
|
||||
#include "mcpclient.h"
|
||||
|
||||
#include <QCheckBox>
|
||||
@@ -18,11 +20,14 @@
|
||||
#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>
|
||||
@@ -52,72 +57,6 @@ bool mimeOk(const QMimeData *mime)
|
||||
return mime && (mime->hasUrls() || mime->hasText() || mime->hasImage());
|
||||
}
|
||||
|
||||
QString escapedHtml(const QString &text)
|
||||
{
|
||||
QString html = text.toHtmlEscaped();
|
||||
html.replace(QLatin1Char('\n'), QStringLiteral("<br/>"));
|
||||
return html;
|
||||
}
|
||||
|
||||
/** Lightweight Markdown to HTML for assistant messages. */
|
||||
QString renderInline(QString text)
|
||||
{
|
||||
text = text.toHtmlEscaped();
|
||||
text.replace(QRegularExpression(QStringLiteral("`([^`]+)`")),
|
||||
QStringLiteral("<code>\\1</code>"));
|
||||
text.replace(QRegularExpression(QStringLiteral("\\*\\*([^*]+)\\*\\*")),
|
||||
QStringLiteral("<b>\\1</b>"));
|
||||
text.replace(QRegularExpression(QStringLiteral("\\[([^\\]]+)\\]\\((https?://[^)\\s]+)\\)")),
|
||||
QStringLiteral("<a href=\"\\2\">\\1</a>"));
|
||||
text.replace(QRegularExpression(QStringLiteral("(^|[\\s(])(https?://[^\\s<]+)")),
|
||||
QStringLiteral("\\1<a href=\"\\2\">\\2</a>"));
|
||||
return text;
|
||||
}
|
||||
|
||||
QString markdownToHtml(const QString &markdown)
|
||||
{
|
||||
QString html;
|
||||
QString code;
|
||||
bool inCode = false;
|
||||
|
||||
const QStringList lines = markdown.split(QLatin1Char('\n'));
|
||||
for (const QString &line : lines) {
|
||||
if (line.trimmed().startsWith(QStringLiteral("```"))) {
|
||||
if (inCode) {
|
||||
html += QStringLiteral("<pre><code>%1</code></pre>").arg(code.toHtmlEscaped());
|
||||
code.clear();
|
||||
}
|
||||
inCode = !inCode;
|
||||
continue;
|
||||
}
|
||||
if (inCode) {
|
||||
code += line;
|
||||
code += QLatin1Char('\n');
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith(QStringLiteral("### "))) {
|
||||
html += QStringLiteral("<h3>%1</h3>").arg(renderInline(line.mid(4)));
|
||||
} else if (line.startsWith(QStringLiteral("## "))) {
|
||||
html += QStringLiteral("<h2>%1</h2>").arg(renderInline(line.mid(3)));
|
||||
} else if (line.startsWith(QStringLiteral("# "))) {
|
||||
html += QStringLiteral("<h2>%1</h2>").arg(renderInline(line.mid(2)));
|
||||
} else if (line.startsWith(QStringLiteral("- ")) ||
|
||||
line.startsWith(QStringLiteral("* "))) {
|
||||
html += QStringLiteral("<div class='li'>• %1</div>").arg(renderInline(line.mid(2)));
|
||||
} else if (line.trimmed().isEmpty()) {
|
||||
html += QStringLiteral("<div class='gap'></div>");
|
||||
} else {
|
||||
html += QStringLiteral("<div>%1</div>").arg(renderInline(line));
|
||||
}
|
||||
}
|
||||
|
||||
if (inCode) {
|
||||
html += QStringLiteral("<pre><code>%1</code></pre>").arg(code.toHtmlEscaped());
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
class ChatSidebar : public QListWidget
|
||||
@@ -133,6 +72,14 @@ public:
|
||||
|
||||
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
|
||||
@@ -205,6 +152,77 @@ protected:
|
||||
}
|
||||
};
|
||||
|
||||
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)
|
||||
: QWidget(parent)
|
||||
, m_client(new ChatClient(this))
|
||||
@@ -231,7 +249,9 @@ ChatWindow::ChatWindow(QWidget *parent)
|
||||
return;
|
||||
}
|
||||
m_conversations[m_current].messages[m_streamingIndex].content += text;
|
||||
renderConversation();
|
||||
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;
|
||||
@@ -263,7 +283,7 @@ ChatWindow::ChatWindow(QWidget *parent)
|
||||
message.content += QStringLiteral("%1🔧 调用工具:%2")
|
||||
.arg(message.content.isEmpty() ? QString() : QStringLiteral("\n\n"),
|
||||
names.join(QStringLiteral(", ")));
|
||||
renderConversation();
|
||||
m_messages->updateMessage(m_streamingIndex, message.content);
|
||||
|
||||
runToolCalls(calls, 0, QJsonArray());
|
||||
});
|
||||
@@ -321,52 +341,197 @@ void ChatWindow::setupUi()
|
||||
root->setContentsMargins(0, 0, 0, 0);
|
||||
root->setSpacing(0);
|
||||
|
||||
/* ---------------- sidebar ---------------- */
|
||||
auto *sidebarPane = new QWidget(this);
|
||||
sidebarPane->setObjectName(QStringLiteral("sidebar"));
|
||||
sidebarPane->setFixedWidth(250);
|
||||
sidebarPane->setFixedWidth(264);
|
||||
sidebarPane->setStyleSheet(QStringLiteral(
|
||||
"#sidebar { background: rgba(255,255,255,0.045);"
|
||||
" border-right: 1px solid rgba(255,255,255,0.08); }"));
|
||||
"#sidebar { background: #17171b; border-right: 1px solid rgba(255,255,255,0.06); }"));
|
||||
|
||||
auto *sidebarLayout = new QVBoxLayout(sidebarPane);
|
||||
sidebarLayout->setContentsMargins(12, 14, 12, 12);
|
||||
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);
|
||||
logo->setPixmap(QApplication::windowIcon().pixmap(34, 34));
|
||||
logo->setScaledContents(true);
|
||||
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(17);
|
||||
brandFont.setPixelSize(15);
|
||||
brandFont.setWeight(QFont::DemiBold);
|
||||
brand->setFont(brandFont);
|
||||
brand->setStyleSheet(QStringLiteral("color: rgba(255,255,255,0.92);"));
|
||||
sidebarLayout->addWidget(brand);
|
||||
|
||||
auto *subtitle = new QLabel(tr("把内容拖进对话即可提问"), sidebarPane);
|
||||
subtitle->setStyleSheet(QStringLiteral("color: rgba(255,255,255,0.4); font-size: 11px;"));
|
||||
sidebarLayout->addWidget(subtitle);
|
||||
|
||||
m_sidebar = new ChatSidebar(sidebarPane);
|
||||
m_sidebar->setFrameShape(QFrame::NoFrame);
|
||||
m_sidebar->setStyleSheet(QStringLiteral(
|
||||
"QListWidget { background: transparent; border: none; color: rgba(255,255,255,0.82); }"
|
||||
"QListWidget::item { padding: 8px 6px; border-radius: 7px; }"
|
||||
"QListWidget::item:hover { background: rgba(255,255,255,0.07); }"
|
||||
"QListWidget::item:selected { background: rgba(120,180,255,0.24); color: white; }"));
|
||||
sidebarLayout->addWidget(m_sidebar, 1);
|
||||
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: rgba(255,255,255,0.85); background: rgba(255,255,255,0.08);"
|
||||
" border: none; border-radius: 8px; padding: 7px; font-size: 12px; }"
|
||||
"QPushButton:hover { background: rgba(120,180,255,0.28); color: white; }"));
|
||||
"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);
|
||||
|
||||
auto *menuButton = new QPushButton(tr("⋯ 更多"), sidebarPane);
|
||||
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->setStyleSheet(QStringLiteral(
|
||||
"QPushButton { color: rgba(255,255,255,0.55); background: transparent;"
|
||||
" border: none; padding: 2px; font-size: 12px; }"
|
||||
"QPushButton:hover { color: white; }"));
|
||||
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-chat"), 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);
|
||||
@@ -380,113 +545,6 @@ void ChatWindow::setupUi()
|
||||
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(
|
||||
"QPushButton { color: rgba(255,255,255,0.55); background: transparent;"
|
||||
" border: none; padding: 4px; font-size: 12px; }"
|
||||
"QPushButton:hover { color: white; }"));
|
||||
sidebarLayout->addWidget(settingsButton);
|
||||
|
||||
root->addWidget(sidebarPane);
|
||||
|
||||
auto *right = new QWidget(this);
|
||||
auto *rightLayout = new QVBoxLayout(right);
|
||||
rightLayout->setContentsMargins(14, 14, 14, 12);
|
||||
rightLayout->setSpacing(10);
|
||||
|
||||
m_view = new ChatView(right);
|
||||
m_view->setOpenExternalLinks(false);
|
||||
m_view->setFrameShape(QFrame::NoFrame);
|
||||
m_view->setStyleSheet(QStringLiteral(
|
||||
"QTextBrowser { background: transparent; color: rgba(240,240,240,0.94);"
|
||||
" border: none; font-size: 14px; }"));
|
||||
rightLayout->addWidget(m_view, 1);
|
||||
|
||||
m_status = new QLabel(right);
|
||||
m_status->setStyleSheet(QStringLiteral("color: rgba(255,170,120,0.9); font-size: 12px;"));
|
||||
m_status->hide();
|
||||
rightLayout->addWidget(m_status);
|
||||
|
||||
auto *inputRow = new QHBoxLayout;
|
||||
inputRow->setSpacing(10);
|
||||
|
||||
m_input = new QPlainTextEdit(right);
|
||||
m_input->setPlaceholderText(tr("输入消息,Ctrl+Enter 发送;也可以把文件/文字拖进窗口"));
|
||||
m_input->setFixedHeight(84);
|
||||
m_input->setStyleSheet(QStringLiteral(
|
||||
"QPlainTextEdit { background: rgba(255,255,255,0.06); color: rgba(245,245,245,0.95);"
|
||||
" border: 1px solid rgba(255,255,255,0.08); border-radius: 10px; padding: 8px;"
|
||||
" font-size: 13px; }"));
|
||||
inputRow->addWidget(m_input, 1);
|
||||
|
||||
m_sendButton = new QPushButton(tr("发送"), right);
|
||||
m_sendButton->setFixedSize(76, 40);
|
||||
m_sendButton->setCursor(Qt::PointingHandCursor);
|
||||
m_sendButton->setStyleSheet(QStringLiteral(
|
||||
"QPushButton { color: white; background: rgba(74,144,255,0.85); border: none;"
|
||||
" border-radius: 10px; font-size: 13px; }"
|
||||
"QPushButton:hover { background: rgba(90,160,255,0.95); }"
|
||||
"QPushButton:disabled { background: rgba(255,255,255,0.12); color: rgba(255,255,255,0.4); }"));
|
||||
inputRow->addWidget(m_sendButton);
|
||||
|
||||
rightLayout->addLayout(inputRow);
|
||||
root->addWidget(right, 1);
|
||||
|
||||
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);
|
||||
|
||||
m_sidebar->setContextMenuPolicy(Qt::CustomContextMenu);
|
||||
connect(m_sidebar, &QListWidget::customContextMenuRequested, this,
|
||||
[this](const QPoint &pos) {
|
||||
const QModelIndex index = m_sidebar->indexAt(pos);
|
||||
if (!index.isValid() || index.row() >= m_conversations.size()) {
|
||||
return;
|
||||
}
|
||||
const int row = index.row();
|
||||
QMenu menu(this);
|
||||
QAction *rename = menu.addAction(tr("重命名对话"));
|
||||
QAction *remove = menu.addAction(tr("删除对话"));
|
||||
QAction *chosen = menu.exec(m_sidebar->viewport()->mapToGlobal(pos));
|
||||
if (chosen == rename) {
|
||||
bool ok = false;
|
||||
const QString title = QInputDialog::getText(
|
||||
nullptr, tr("重命名对话"), tr("名称"), QLineEdit::Normal,
|
||||
m_conversations.at(row).title, &ok);
|
||||
if (ok && !title.trimmed().isEmpty()) {
|
||||
m_conversations[row].title = title.trimmed();
|
||||
save();
|
||||
updateSidebar();
|
||||
renderConversation();
|
||||
}
|
||||
} else if (chosen == remove) {
|
||||
if (m_client->busy()) {
|
||||
m_client->cancel();
|
||||
m_streamingIndex = -1;
|
||||
}
|
||||
m_conversations.removeAt(row);
|
||||
m_current = m_conversations.isEmpty() ? -1
|
||||
: qBound(0, row, m_conversations.size() - 1);
|
||||
save();
|
||||
updateSidebar();
|
||||
renderConversation();
|
||||
}
|
||||
});
|
||||
connect(static_cast<ChatSidebar *>(m_sidebar), &ChatSidebar::droppedAt, this,
|
||||
&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);
|
||||
@@ -599,78 +657,45 @@ void ChatWindow::updateSidebar()
|
||||
const QSignalBlocker blocker(m_sidebar);
|
||||
m_sidebar->clear();
|
||||
for (const ChatConversation &conversation : m_conversations) {
|
||||
m_sidebar->addItem(conversation.title);
|
||||
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()
|
||||
{
|
||||
QString html;
|
||||
html += QStringLiteral(
|
||||
"<style>body{color:#e9e9e9;font-size:14px;} .role{font-size:11px;color:#8a8a8a;"
|
||||
"margin-top:14px;} .user{color:#9ecbff;} .assistant{color:#efefef;}"
|
||||
".welcome{color:#b9b9b9;line-height:1.6;} pre{background:rgba(255,255,255,0.07);"
|
||||
"padding:8px;border-radius:6px;} h1,h2,h3{margin:8px 0 4px 0;} .li{margin-left:10px;} "
|
||||
".gap{height:6px;} code{background:rgba(255,255,255,0.1);"
|
||||
"border-radius:4px;padding:1px 4px;}</style>");
|
||||
|
||||
if (m_current < 0 || m_conversations.isEmpty()) {
|
||||
html += QStringLiteral(
|
||||
"<div class='welcome'><h3>你好,我是小埃同学。</h3>"
|
||||
"<p>把小埃当成桌面上的一个抽屉:<br/>"
|
||||
"· 把文件、链接、文字拖到左侧列表空白处 = 新建对话<br/>"
|
||||
"· 拖到左侧某个对话 = 给那个对话补充内容<br/>"
|
||||
"· 拖到右侧对话区 = 发给当前对话</p>"
|
||||
"<p>第一次使用需要配置 DeepSeek API Key:点左下角「设置」,"
|
||||
"在 <a href='https://platform.deepseek.com'>platform.deepseek.com</a> "
|
||||
"创建 Key 后填进来。</p></div>");
|
||||
m_view->setHtml(html);
|
||||
m_messages->setMessages({});
|
||||
updateHeader();
|
||||
return;
|
||||
}
|
||||
m_messages->setMessages(m_conversations.at(m_current).messages);
|
||||
updateHeader();
|
||||
}
|
||||
|
||||
const ChatConversation &conversation = m_conversations.at(m_current);
|
||||
if (conversation.messages.isEmpty()) {
|
||||
html += QStringLiteral("<div class='welcome'><h3>%1</h3>"
|
||||
"<p>直接输入问题,或者把内容拖进来。</p></div>")
|
||||
.arg(escapedHtml(conversation.title));
|
||||
}
|
||||
|
||||
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")
|
||||
: QStringLiteral("assistant");
|
||||
html += QStringLiteral("<div class='role'>%1</div>").arg(role);
|
||||
const QString body = message.role == QStringLiteral("assistant")
|
||||
? markdownToHtml(message.content)
|
||||
: escapedHtml(message.content);
|
||||
html += QStringLiteral("<div class='%1'>%2</div>").arg(cssClass, body);
|
||||
|
||||
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:copy/%1'>复制</a> · ").arg(i);
|
||||
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) {
|
||||
html += QStringLiteral("<div class='role'>%1</div><div class='assistant'>…</div>")
|
||||
.arg(tr("小埃"));
|
||||
}
|
||||
|
||||
m_view->setHtml(html);
|
||||
m_view->moveCursor(QTextCursor::End);
|
||||
m_view->ensureCursorVisible();
|
||||
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-chat"))
|
||||
.toString());
|
||||
}
|
||||
|
||||
void ChatWindow::sendMessage()
|
||||
|
||||
@@ -13,7 +13,7 @@ class QLabel;
|
||||
class QListWidget;
|
||||
class QPlainTextEdit;
|
||||
class QPushButton;
|
||||
class QTextBrowser;
|
||||
class ChatMessageList;
|
||||
class QMimeData;
|
||||
class QUrl;
|
||||
|
||||
@@ -56,6 +56,7 @@ private:
|
||||
void newConversation();
|
||||
void selectConversation(int index);
|
||||
void renderConversation();
|
||||
void updateHeader();
|
||||
void updateSidebar();
|
||||
void sendMessage();
|
||||
void requestCompletion();
|
||||
@@ -73,7 +74,9 @@ private:
|
||||
int currentIndex() const { return m_current; }
|
||||
|
||||
QListWidget *m_sidebar = nullptr;
|
||||
QTextBrowser *m_view = nullptr;
|
||||
ChatMessageList *m_messages = nullptr;
|
||||
QLabel *m_titleLabel = nullptr;
|
||||
QLabel *m_modelLabel = nullptr;
|
||||
QPlainTextEdit *m_input = nullptr;
|
||||
QPushButton *m_sendButton = nullptr;
|
||||
QLabel *m_status = nullptr;
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
// SPDX-FileCopyrightText: 2026 GXDE OS Team
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "markdown.h"
|
||||
|
||||
#include <QRegularExpression>
|
||||
|
||||
QString escapedHtml(const QString &text)
|
||||
{
|
||||
QString html = text.toHtmlEscaped();
|
||||
html.replace(QLatin1Char('\n'), QStringLiteral("<br/>"));
|
||||
return html;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
QString renderInline(QString text)
|
||||
{
|
||||
text = text.toHtmlEscaped();
|
||||
text.replace(QRegularExpression(QStringLiteral("`([^`]+)`")),
|
||||
QStringLiteral("<code>\\1</code>"));
|
||||
text.replace(QRegularExpression(QStringLiteral("\\*\\*([^*]+)\\*\\*")),
|
||||
QStringLiteral("<b>\\1</b>"));
|
||||
text.replace(
|
||||
QRegularExpression(QStringLiteral("\\[([^\\]]+)\\]\\((https?://[^)\\s]+)\\)")),
|
||||
QStringLiteral("<a href=\"\\2\">\\1</a>"));
|
||||
text.replace(QRegularExpression(QStringLiteral("(^|[\\s(])(https?://[^\\s<]+)")),
|
||||
QStringLiteral("\\1<a href=\"\\2\">\\2</a>"));
|
||||
return text;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
QString markdownToHtml(const QString &markdown)
|
||||
{
|
||||
QString html;
|
||||
QString code;
|
||||
bool inCode = false;
|
||||
|
||||
const QStringList lines = markdown.split(QLatin1Char('\n'));
|
||||
for (const QString &line : lines) {
|
||||
if (line.trimmed().startsWith(QStringLiteral("```"))) {
|
||||
if (inCode) {
|
||||
html += QStringLiteral("<pre><code>%1</code></pre>").arg(code.toHtmlEscaped());
|
||||
code.clear();
|
||||
}
|
||||
inCode = !inCode;
|
||||
continue;
|
||||
}
|
||||
if (inCode) {
|
||||
code += line;
|
||||
code += QLatin1Char('\n');
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith(QStringLiteral("### "))) {
|
||||
html += QStringLiteral("<h3>%1</h3>").arg(renderInline(line.mid(4)));
|
||||
} else if (line.startsWith(QStringLiteral("## "))) {
|
||||
html += QStringLiteral("<h2>%1</h2>").arg(renderInline(line.mid(3)));
|
||||
} else if (line.startsWith(QStringLiteral("# "))) {
|
||||
html += QStringLiteral("<h2>%1</h2>").arg(renderInline(line.mid(2)));
|
||||
} else if (line.startsWith(QStringLiteral("- ")) ||
|
||||
line.startsWith(QStringLiteral("* "))) {
|
||||
html += QStringLiteral("<div class='li'>• %1</div>").arg(renderInline(line.mid(2)));
|
||||
} else if (line.trimmed().isEmpty()) {
|
||||
html += QStringLiteral("<div class='gap'></div>");
|
||||
} else {
|
||||
html += QStringLiteral("<div>%1</div>").arg(renderInline(line));
|
||||
}
|
||||
}
|
||||
|
||||
if (inCode) {
|
||||
html += QStringLiteral("<pre><code>%1</code></pre>").arg(code.toHtmlEscaped());
|
||||
}
|
||||
return html;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// SPDX-FileCopyrightText: 2026 GXDE OS Team
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QString>
|
||||
|
||||
/** Lightweight Markdown to HTML used by assistant bubbles. */
|
||||
QString markdownToHtml(const QString &markdown);
|
||||
|
||||
/** Escape plain text and keep line breaks. */
|
||||
QString escapedHtml(const QString &text);
|
||||
@@ -4,6 +4,7 @@
|
||||
#include <DApplication>
|
||||
#include <QCommandLineOption>
|
||||
#include <QCommandLineParser>
|
||||
#include <QIcon>
|
||||
#include <QLocale>
|
||||
#include <QStandardPaths>
|
||||
#include <QTranslator>
|
||||
@@ -19,6 +20,7 @@ int main(int argc, char *argv[])
|
||||
app.setApplicationName(QStringLiteral("gxde-ai-see"));
|
||||
app.setApplicationDisplayName(QStringLiteral("GXDE 小埃同学"));
|
||||
app.setApplicationVersion(QStringLiteral("0.1.0"));
|
||||
app.setWindowIcon(QIcon::fromTheme(QStringLiteral("gxde-ai-see")));
|
||||
app.setQuitOnLastWindowClosed(false);
|
||||
|
||||
const QString locale = QLocale::system().name();
|
||||
|
||||
Reference in New Issue
Block a user