From 798f5aa7ce230aa571e813e22c88dd325ca5c3d8 Mon Sep 17 00:00:00 2001 From: shenmo Date: Fri, 30 Oct 2026 20:40:45 +0800 Subject: [PATCH] =?UTF-8?q?feat(chat):=20=E9=87=8D=E5=81=9A=E8=81=8A?= =?UTF-8?q?=E5=A4=A9=E7=AA=97=E5=8F=A3=E7=95=8C=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 消息改成气泡列表:头像、左右分栏、markdown 正文、hover 显示复制/编辑/分支/重试 - 会话列表:应用图标 + 名称、每项显示标题/最后一条预览/时间、hover 与选中态 - 输入区:圆角输入框内嵌发送按钮、快捷键提示 - 空会话欢迎页;消息区域支持拖放 - 头部显示当前会话标题与模型徽章 - 设置/更多按钮移到侧栏底部 --- CHANGELOG.md | 3 +- CMakeLists.txt | 2 + src/chat/chatview.cpp | 442 ++++++++++++++++++++++++++++++++ src/chat/chatview.h | 55 ++++ src/chat/chatwindow.cpp | 549 +++++++++++++++++++++------------------- src/chat/chatwindow.h | 7 +- src/chat/markdown.cpp | 76 ++++++ src/chat/markdown.h | 12 + src/main.cpp | 2 + 9 files changed, 883 insertions(+), 265 deletions(-) create mode 100644 src/chat/chatview.cpp create mode 100644 src/chat/chatview.h create mode 100644 src/chat/markdown.cpp create mode 100644 src/chat/markdown.h diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a82815..6611de3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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、缩略图坐标映射、 diff --git a/CMakeLists.txt b/CMakeLists.txt index 6fa1fb3..c1602c0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 ) diff --git a/src/chat/chatview.cpp b/src/chat/chatview.cpp new file mode 100644 index 0000000..3cef516 --- /dev/null +++ b/src/chat/chatview.cpp @@ -0,0 +1,442 @@ +// SPDX-FileCopyrightText: 2026 GXDE OS Team +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "chatview.h" + +#include + +#include "markdown.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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 &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" diff --git a/src/chat/chatview.h b/src/chat/chatview.h new file mode 100644 index 0000000..967801f --- /dev/null +++ b/src/chat/chatview.h @@ -0,0 +1,55 @@ +// SPDX-FileCopyrightText: 2026 GXDE OS Team +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include + +#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 &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 m_rows; + QVector m_messages; +}; diff --git a/src/chat/chatwindow.cpp b/src/chat/chatwindow.cpp index 761c7cc..d228dbb 100644 --- a/src/chat/chatwindow.cpp +++ b/src/chat/chatwindow.cpp @@ -4,6 +4,8 @@ #include "chatwindow.h" #include "config.h" +#include "chatview.h" +#include "markdown.h" #include "mcpclient.h" #include @@ -18,11 +20,14 @@ #include #include #include +#include #include #include #include #include #include +#include +#include #include #include #include @@ -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("
")); - return html; -} - -/** Lightweight Markdown to HTML for assistant messages. */ -QString renderInline(QString text) -{ - text = text.toHtmlEscaped(); - text.replace(QRegularExpression(QStringLiteral("`([^`]+)`")), - QStringLiteral("\\1")); - text.replace(QRegularExpression(QStringLiteral("\\*\\*([^*]+)\\*\\*")), - QStringLiteral("\\1")); - text.replace(QRegularExpression(QStringLiteral("\\[([^\\]]+)\\]\\((https?://[^)\\s]+)\\)")), - QStringLiteral("\\1")); - text.replace(QRegularExpression(QStringLiteral("(^|[\\s(])(https?://[^\\s<]+)")), - QStringLiteral("\\1\\2")); - 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("
%1
").arg(code.toHtmlEscaped()); - code.clear(); - } - inCode = !inCode; - continue; - } - if (inCode) { - code += line; - code += QLatin1Char('\n'); - continue; - } - - if (line.startsWith(QStringLiteral("### "))) { - html += QStringLiteral("

%1

").arg(renderInline(line.mid(4))); - } else if (line.startsWith(QStringLiteral("## "))) { - html += QStringLiteral("

%1

").arg(renderInline(line.mid(3))); - } else if (line.startsWith(QStringLiteral("# "))) { - html += QStringLiteral("

%1

").arg(renderInline(line.mid(2))); - } else if (line.startsWith(QStringLiteral("- ")) || - line.startsWith(QStringLiteral("* "))) { - html += QStringLiteral("
• %1
").arg(renderInline(line.mid(2))); - } else if (line.trimmed().isEmpty()) { - html += QStringLiteral("
"); - } else { - html += QStringLiteral("
%1
").arg(renderInline(line)); - } - } - - if (inCode) { - html += QStringLiteral("
%1
").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(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(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(m_sidebar), &ChatSidebar::droppedAt, this, - &ChatWindow::handleDropOnConversation); - connect(static_cast(m_view), &ChatView::dropped, this, - &ChatWindow::handleDroppedMime); - connect(m_view, &QTextBrowser::anchorClicked, this, &ChatWindow::handleAnchor); auto *sendShortcut = new QShortcut(QKeySequence(Qt::CTRL | Qt::Key_Return), m_input); connect(sendShortcut, &QShortcut::activated, this, &ChatWindow::sendMessage); @@ -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( - ""); - if (m_current < 0 || m_conversations.isEmpty()) { - html += QStringLiteral( - "

你好,我是小埃同学。

" - "

把小埃当成桌面上的一个抽屉:
" - "· 把文件、链接、文字拖到左侧列表空白处 = 新建对话
" - "· 拖到左侧某个对话 = 给那个对话补充内容
" - "· 拖到右侧对话区 = 发给当前对话

" - "

第一次使用需要配置 DeepSeek API Key:点左下角「设置」," - "在 platform.deepseek.com " - "创建 Key 后填进来。

"); - 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("

%1

" - "

直接输入问题,或者把内容拖进来。

") - .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("
%1
").arg(role); - const QString body = message.role == QStringLiteral("assistant") - ? markdownToHtml(message.content) - : escapedHtml(message.content); - html += QStringLiteral("
%2
").arg(cssClass, body); - - html += QStringLiteral("
"); - if (message.role == QStringLiteral("user")) { - html += QStringLiteral("编辑").arg(i); - } else if (message.role == QStringLiteral("assistant")) { - html += QStringLiteral("复制 · ").arg(i); - html += QStringLiteral("分支").arg(i); - if (i == conversation.messages.size() - 1) { - html += QStringLiteral(" · 重试"); - } - } - html += QStringLiteral("
"); - } - - if (m_client->busy() && m_streamingIndex >= 0) { - html += QStringLiteral("
%1
…
") - .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() diff --git a/src/chat/chatwindow.h b/src/chat/chatwindow.h index ec39894..e0d628f 100644 --- a/src/chat/chatwindow.h +++ b/src/chat/chatwindow.h @@ -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; diff --git a/src/chat/markdown.cpp b/src/chat/markdown.cpp new file mode 100644 index 0000000..e3ede37 --- /dev/null +++ b/src/chat/markdown.cpp @@ -0,0 +1,76 @@ +// SPDX-FileCopyrightText: 2026 GXDE OS Team +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "markdown.h" + +#include + +QString escapedHtml(const QString &text) +{ + QString html = text.toHtmlEscaped(); + html.replace(QLatin1Char('\n'), QStringLiteral("
")); + return html; +} + +namespace { + +QString renderInline(QString text) +{ + text = text.toHtmlEscaped(); + text.replace(QRegularExpression(QStringLiteral("`([^`]+)`")), + QStringLiteral("\\1")); + text.replace(QRegularExpression(QStringLiteral("\\*\\*([^*]+)\\*\\*")), + QStringLiteral("\\1")); + text.replace( + QRegularExpression(QStringLiteral("\\[([^\\]]+)\\]\\((https?://[^)\\s]+)\\)")), + QStringLiteral("\\1")); + text.replace(QRegularExpression(QStringLiteral("(^|[\\s(])(https?://[^\\s<]+)")), + QStringLiteral("\\1\\2")); + 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("
%1
").arg(code.toHtmlEscaped()); + code.clear(); + } + inCode = !inCode; + continue; + } + if (inCode) { + code += line; + code += QLatin1Char('\n'); + continue; + } + + if (line.startsWith(QStringLiteral("### "))) { + html += QStringLiteral("

%1

").arg(renderInline(line.mid(4))); + } else if (line.startsWith(QStringLiteral("## "))) { + html += QStringLiteral("

%1

").arg(renderInline(line.mid(3))); + } else if (line.startsWith(QStringLiteral("# "))) { + html += QStringLiteral("

%1

").arg(renderInline(line.mid(2))); + } else if (line.startsWith(QStringLiteral("- ")) || + line.startsWith(QStringLiteral("* "))) { + html += QStringLiteral("
• %1
").arg(renderInline(line.mid(2))); + } else if (line.trimmed().isEmpty()) { + html += QStringLiteral("
"); + } else { + html += QStringLiteral("
%1
").arg(renderInline(line)); + } + } + + if (inCode) { + html += QStringLiteral("
%1
").arg(code.toHtmlEscaped()); + } + return html; +} diff --git a/src/chat/markdown.h b/src/chat/markdown.h new file mode 100644 index 0000000..ecd499f --- /dev/null +++ b/src/chat/markdown.h @@ -0,0 +1,12 @@ +// SPDX-FileCopyrightText: 2026 GXDE OS Team +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include + +/** 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); diff --git a/src/main.cpp b/src/main.cpp index 4c8c993..8b3a920 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include #include @@ -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();